[debian-edu-commits] debian-edu/ 23/26: New upstream version 0.9.9

Dominik George natureshadow-guest at moszumanska.debian.org
Fri Oct 7 19:05:42 UTC 2016


This is an automated email from the git hooks/post-receive script.

natureshadow-guest pushed a commit to branch master
in repository guacamole-client.

commit b9c007cf451f1b1c68bfbd48a1a9f3d3efe6b320
Author: Dominik George <nik at naturalnet.de>
Date:   Fri Oct 7 20:53:32 2016 +0200

    New upstream version 0.9.9
---
 CONTRIBUTING                                       |   66 +
 LICENSE                                            |   19 +
 README                                             |   10 +-
 doc/guacamole-example/COPYING                      |  661 --
 doc/guacamole-example/LICENSE                      |   19 +
 doc/guacamole-example/pom.xml                      |   20 +-
 .../net/example/DummyGuacamoleTunnelServlet.java   |   52 +-
 .../src/main/webapp/WEB-INF/web.xml                |   32 +-
 .../src/main/webapp/guacamole.css                  |   29 +-
 doc/guacamole-example/src/main/webapp/index.html   |   53 +-
 extensions/guacamole-auth-jdbc/LICENSE             |   19 +
 extensions/guacamole-auth-jdbc/README              |  113 +
 .../modules/guacamole-auth-jdbc-base/pom.xml       |  103 +
 .../jdbc/JDBCAuthenticationProviderModule.java     |  165 +
 .../guacamole/auth/jdbc/JDBCEnvironment.java       |  106 +
 .../ActiveConnectionDirectory.java                 |   83 +
 .../ActiveConnectionPermissionService.java         |  176 +
 .../ActiveConnectionPermissionSet.java             |   48 +
 .../activeconnection/ActiveConnectionService.java  |  164 +
 .../activeconnection/TrackedActiveConnection.java  |  169 +
 .../auth/jdbc/activeconnection/package-info.java   |   26 +
 .../auth/jdbc/base/DirectoryObjectService.java     |  155 +
 .../auth/jdbc/base/GroupedObjectModel.java         |   67 +
 .../auth/jdbc/base/ModeledDirectoryObject.java     |   50 +
 .../jdbc/base/ModeledDirectoryObjectMapper.java    |  140 +
 .../jdbc/base/ModeledDirectoryObjectService.java   |  435 +
 .../jdbc/base/ModeledGroupedDirectoryObject.java   |   78 +
 .../base/ModeledGroupedDirectoryObjectService.java |  197 +
 .../guacamole/auth/jdbc/base/ModeledObject.java    |   82 +
 .../guacamole/auth/jdbc/base/ObjectModel.java      |   90 +
 .../guacamole/auth/jdbc/base/RestrictedObject.java |   76 +
 .../guacamole/auth/jdbc/base/package-info.java     |   28 +
 .../auth/jdbc/connection/ConnectionDirectory.java  |   89 +
 .../auth/jdbc/connection/ConnectionMapper.java     |   92 +
 .../auth/jdbc/connection/ConnectionModel.java      |  176 +
 .../jdbc/connection/ConnectionRecordMapper.java    |  116 +
 .../jdbc/connection/ConnectionRecordModel.java     |  201 +
 .../connection/ConnectionRecordSearchTerm.java     |  296 +
 .../auth/jdbc/connection/ConnectionRecordSet.java  |  108 +
 .../connection/ConnectionRecordSortPredicate.java  |   82 +
 .../auth/jdbc/connection/ConnectionService.java    |  511 ++
 .../auth/jdbc/connection/ModeledConnection.java    |  259 +
 .../jdbc/connection/ModeledConnectionRecord.java   |   89 +
 .../connection/ModeledGuacamoleConfiguration.java  |  120 +
 .../auth/jdbc/connection/ParameterMapper.java      |   75 +
 .../auth/jdbc/connection/ParameterModel.java       |  107 +
 .../auth/jdbc/connection/package-info.java         |   26 +
 .../connectiongroup/ConnectionGroupDirectory.java  |   89 +
 .../connectiongroup/ConnectionGroupMapper.java     |   92 +
 .../jdbc/connectiongroup/ConnectionGroupModel.java |  180 +
 .../connectiongroup/ConnectionGroupService.java    |  258 +
 .../connectiongroup/ModeledConnectionGroup.java    |  247 +
 .../jdbc/connectiongroup/RootConnectionGroup.java  |  150 +
 .../auth/jdbc/connectiongroup/package-info.java    |   26 +
 .../guacamole/auth/jdbc/package-info.java          |   29 +
 .../jdbc/permission/AbstractPermissionService.java |   84 +
 .../ConnectionGroupPermissionMapper.java           |   30 +
 .../ConnectionGroupPermissionService.java          |   69 +
 .../permission/ConnectionGroupPermissionSet.java   |   47 +
 .../permission/ConnectionPermissionMapper.java     |   30 +
 .../permission/ConnectionPermissionService.java    |   69 +
 .../jdbc/permission/ConnectionPermissionSet.java   |   47 +
 .../permission/ModeledObjectPermissionService.java |  210 +
 .../jdbc/permission/ModeledPermissionService.java  |  158 +
 .../jdbc/permission/ObjectPermissionMapper.java    |   83 +
 .../jdbc/permission/ObjectPermissionModel.java     |   66 +
 .../jdbc/permission/ObjectPermissionService.java   |   99 +
 .../auth/jdbc/permission/ObjectPermissionSet.java  |  125 +
 .../auth/jdbc/permission/PermissionMapper.java     |   73 +
 .../auth/jdbc/permission/PermissionModel.java      |  110 +
 .../auth/jdbc/permission/PermissionService.java    |  136 +
 .../jdbc/permission/SystemPermissionMapper.java    |   53 +
 .../jdbc/permission/SystemPermissionModel.java     |   41 +
 .../jdbc/permission/SystemPermissionService.java   |  171 +
 .../auth/jdbc/permission/SystemPermissionSet.java  |  115 +
 .../auth/jdbc/permission/UserPermissionMapper.java |   30 +
 .../jdbc/permission/UserPermissionService.java     |   69 +
 .../auth/jdbc/permission/UserPermissionSet.java    |   47 +
 .../auth/jdbc/permission/package-info.java         |   26 +
 .../jdbc/security/PasswordEncryptionService.java   |   46 +
 .../security/SHA256PasswordEncryptionService.java  |   65 +
 .../guacamole/auth/jdbc/security/SaltService.java  |   35 +
 .../jdbc/security/SecureRandomSaltService.java     |   46 +
 .../guacamole/auth/jdbc/security/package-info.java |   26 +
 .../tunnel/AbstractGuacamoleTunnelService.java     |  558 ++
 .../auth/jdbc/tunnel/ActiveConnectionMultimap.java |  128 +
 .../auth/jdbc/tunnel/ActiveConnectionRecord.java   |  265 +
 .../auth/jdbc/tunnel/GuacamoleTunnelService.java   |  151 +
 .../jdbc/tunnel/ManagedInetGuacamoleSocket.java    |   71 +
 .../jdbc/tunnel/ManagedSSLGuacamoleSocket.java     |   71 +
 .../tunnel/RestrictedGuacamoleTunnelService.java   |  216 +
 .../glyptodon/guacamole/auth/jdbc/tunnel/Seat.java |   89 +
 .../guacamole/auth/jdbc/tunnel/package-info.java   |   27 +
 .../auth/jdbc/user/AuthenticatedUser.java          |  178 +
 .../jdbc/user/AuthenticationProviderService.java   |  119 +
 .../guacamole/auth/jdbc/user/ModeledUser.java      |  598 ++
 .../guacamole/auth/jdbc/user/UserContext.java      |  179 +
 .../guacamole/auth/jdbc/user/UserDirectory.java    |   89 +
 .../guacamole/auth/jdbc/user/UserMapper.java       |   47 +
 .../guacamole/auth/jdbc/user/UserModel.java        |  329 +
 .../guacamole/auth/jdbc/user/UserService.java      |  389 +
 .../guacamole/auth/jdbc/user/package-info.java     |   26 +
 .../src/main/resources/translations/en.json        |   58 +
 .../src/main/resources/translations/fr.json        |   13 +
 .../src/main/resources/translations/ru.json        |   13 +
 .../modules/guacamole-auth-jdbc-mysql/pom.xml      |   82 +
 .../schema/001-create-schema.sql                   |  257 +
 .../schema/002-create-admin-user.sql               |   50 +
 .../schema/upgrade/upgrade-pre-0.8.2.sql           |   89 +
 .../schema/upgrade/upgrade-pre-0.9.6.sql           |   39 +
 .../schema/upgrade/upgrade-pre-0.9.7.sql           |   34 +
 .../schema/upgrade/upgrade-pre-0.9.8.sql           |   55 +
 .../schema/upgrade/upgrade-pre-0.9.9.sql           |   29 +
 .../auth/mysql/MySQLAuthenticationProvider.java    |  120 +
 .../mysql/MySQLAuthenticationProviderModule.java   |   97 +
 .../guacamole/net/auth/mysql/MySQLEnvironment.java |  276 +
 .../net/auth/mysql/MySQLGuacamoleProperties.java   |  171 +
 .../guacamole/net/auth/mysql/package-info.java     |   27 +
 .../src/main/resources/guac-manifest.json          |   19 +
 .../auth/jdbc/connection/ConnectionMapper.xml      |  172 +
 .../jdbc/connection/ConnectionRecordMapper.xml     |  200 +
 .../auth/jdbc/connection/ParameterMapper.xml       |   71 +
 .../jdbc/connectiongroup/ConnectionGroupMapper.xml |  173 +
 .../permission/ConnectionGroupPermissionMapper.xml |  120 +
 .../jdbc/permission/ConnectionPermissionMapper.xml |  120 +
 .../jdbc/permission/SystemPermissionMapper.xml     |   93 +
 .../auth/jdbc/permission/UserPermissionMapper.xml  |  129 +
 .../guacamole/auth/jdbc/user/UserMapper.xml        |  183 +
 .../modules/guacamole-auth-jdbc-postgresql/pom.xml |   82 +
 .../schema/001-create-schema.sql                   |  301 +
 .../schema/002-create-admin-user.sql               |   53 +
 .../schema/upgrade/upgrade-pre-0.9.7.sql           |   34 +
 .../schema/upgrade/upgrade-pre-0.9.8.sql           |   55 +
 .../schema/upgrade/upgrade-pre-0.9.9.sql           |   29 +
 .../PostgreSQLAuthenticationProvider.java          |  128 +
 .../PostgreSQLAuthenticationProviderModule.java    |   98 +
 .../auth/postgresql/PostgreSQLEnvironment.java     |  278 +
 .../postgresql/PostgreSQLGuacamoleProperties.java  |  180 +
 .../guacamole/auth/postgresql/package-info.java    |   26 +
 .../src/main/resources/guac-manifest.json          |   19 +
 .../auth/jdbc/connection/ConnectionMapper.xml      |  172 +
 .../jdbc/connection/ConnectionRecordMapper.xml     |  200 +
 .../auth/jdbc/connection/ParameterMapper.xml       |   71 +
 .../jdbc/connectiongroup/ConnectionGroupMapper.xml |  173 +
 .../permission/ConnectionGroupPermissionMapper.xml |  120 +
 .../jdbc/permission/ConnectionPermissionMapper.xml |  120 +
 .../jdbc/permission/SystemPermissionMapper.xml     |   93 +
 .../auth/jdbc/permission/UserPermissionMapper.xml  |  129 +
 .../guacamole/auth/jdbc/user/UserMapper.xml        |  184 +
 extensions/guacamole-auth-jdbc/pom.xml             |   72 +
 .../guacamole-auth-jdbc/src/main/assembly/dist.xml |   45 +
 extensions/guacamole-auth-ldap/LICENSE             |  489 +-
 extensions/guacamole-auth-ldap/pom.xml             |   48 +-
 .../guacamole-auth-ldap/src/main/assembly/dist.xml |   55 +-
 .../net/auth/ldap/LDAPAuthenticationProvider.java  |  313 +-
 .../ldap/properties/LDAPGuacamoleProperties.java   |  110 -
 .../auth/ldap/AuthenticationProviderService.java   |  283 +
 .../guacamole/auth/ldap/ConfigurationService.java  |  194 +
 .../guacamole/auth/ldap/EncryptionMethod.java      |   69 +
 .../auth/ldap/EncryptionMethodProperty.java        |   63 +
 .../guacamole/auth/ldap/EscapingService.java       |  125 +
 .../ldap/LDAPAuthenticationProviderModule.java     |   89 +
 .../guacamole/auth/ldap/LDAPConnectionService.java |  196 +
 .../auth/ldap/LDAPGuacamoleProperties.java         |  139 +
 .../guacamole/auth/ldap/StringListProperty.java    |   67 +
 .../auth/ldap/connection/ConnectionService.java    |  198 +
 .../auth/ldap/user/AuthenticatedUser.java          |   71 +
 .../guacamole/auth/ldap/user/UserContext.java      |  220 +
 .../guacamole/auth/ldap/user/UserService.java      |  316 +
 .../src/main/resources/guac-manifest.json          |   16 +
 .../src/main/resources/translations/en.json        |    7 +
 extensions/guacamole-auth-mysql/README             |  171 -
 .../guacamole-auth-mysql/doc/example/settings.xml  |   21 -
 extensions/guacamole-auth-mysql/pom.xml            |  131 -
 .../schema/001-create-schema.sql                   |  207 -
 .../schema/002-create-admin-user.sql               |   17 -
 .../schema/upgrade/upgrade-pre-0.8.2.sql           |   68 -
 .../src/main/assembly/dist.xml                     |   54 -
 .../net/auth/mysql/ActiveConnectionMap.java        |  515 --
 .../net/auth/mysql/ConnectionDirectory.java        |  342 -
 .../net/auth/mysql/ConnectionGroupDirectory.java   |  306 -
 .../auth/mysql/MySQLAuthenticationProvider.java    |  197 -
 .../guacamole/net/auth/mysql/MySQLConnection.java  |  156 -
 .../net/auth/mysql/MySQLConnectionGroup.java       |  193 -
 .../net/auth/mysql/MySQLConnectionRecord.java      |  103 -
 .../guacamole/net/auth/mysql/MySQLConstants.java   |  279 -
 .../net/auth/mysql/MySQLGuacamoleSocket.java       |  115 -
 .../guacamole/net/auth/mysql/MySQLUser.java        |  193 -
 .../guacamole/net/auth/mysql/MySQLUserContext.java |  108 -
 .../guacamole/net/auth/mysql/UserDirectory.java    |  721 --
 .../guacamole/net/auth/mysql/package-info.java     |    7 -
 .../mysql/properties/MySQLGuacamoleProperties.java |  124 -
 .../net/auth/mysql/properties/package-info.java    |    7 -
 .../auth/mysql/service/ConnectionGroupService.java |  411 -
 .../net/auth/mysql/service/ConnectionService.java  |  490 --
 .../mysql/service/PasswordEncryptionService.java   |   69 -
 .../auth/mysql/service/PermissionCheckService.java |  848 --
 .../service/SHA256PasswordEncryptionService.java   |   90 -
 .../net/auth/mysql/service/SaltService.java        |   48 -
 .../mysql/service/SecureRandomSaltService.java     |   60 -
 .../net/auth/mysql/service/UserService.java        |  381 -
 .../net/auth/mysql/service/package-info.java       |    7 -
 .../src/main/resources/generatorConfig.xml         |  114 -
 extensions/guacamole-auth-noauth/LICENSE           |   19 +
 extensions/guacamole-auth-noauth/pom.xml           |   36 +-
 .../src/main/assembly/dist.xml                     |   43 +-
 .../auth/noauth/NoAuthConfigContentHandler.java    |   55 +-
 .../net/auth/noauth/NoAuthenticationProvider.java  |  108 +-
 .../src/main/resources/guac-manifest.json          |   16 +
 .../src/main/resources/translations/en.json        |    7 +
 guacamole-common-js/LICENSE                        |  489 +-
 guacamole-common-js/jsdoc-conf.json                |    9 +
 guacamole-common-js/pom.xml                        |   56 +-
 guacamole-common-js/src/main/resources/audio.js    |  228 -
 .../src/main/resources/guacamole.js                | 1662 ----
 guacamole-common-js/src/main/resources/keyboard.js |  622 --
 guacamole-common-js/src/main/resources/layer.js    | 1210 ---
 guacamole-common-js/src/main/resources/mouse.js    |  836 --
 .../src/main/resources/oskeyboard.js               |  653 --
 guacamole-common-js/src/main/resources/tunnel.js   |  832 --
 .../src/main/webapp/common/license.js              |   23 +
 .../src/main/webapp/modules/ArrayBufferReader.js   |   79 +
 .../src/main/webapp/modules/ArrayBufferWriter.js   |  103 +
 .../src/main/webapp/modules/AudioPlayer.js         |  653 ++
 .../src/main/webapp/modules/BlobReader.js          |  131 +
 .../src/main/webapp/modules/Client.js              | 1450 +++
 .../src/main/webapp/modules/DataURIReader.js       |   87 +
 .../src/main/webapp/modules/Display.js             | 1387 +++
 .../src/main/webapp/modules/InputStream.js         |   73 +
 .../src/main/webapp/modules/IntegerPool.js         |   79 +
 .../src/main/webapp/modules/JSONReader.js          |  117 +
 .../src/main/webapp/modules/Keyboard.js            | 1162 +++
 .../src/main/webapp/modules/Layer.js               |  904 ++
 .../src/main/webapp/modules/Mouse.js               | 1090 +++
 .../src/main/webapp/modules/Namespace.js           |   29 +
 .../src/main/webapp/modules/Object.js              |  213 +
 .../src/main/webapp/modules/OnScreenKeyboard.js    |  946 ++
 .../src/main/webapp/modules/OutputStream.js        |   71 +
 .../src/main/webapp/modules/Parser.js              |  159 +
 .../src/main/webapp/modules/Status.js              |  190 +
 .../src/main/webapp/modules/StringReader.js        |  170 +
 .../src/main/webapp/modules/StringWriter.js        |  194 +
 .../src/main/webapp/modules/Tunnel.js              | 1003 +++
 .../src/main/webapp/modules/Version.js             |   33 +
 .../src/main/webapp/modules/VideoPlayer.js         |  111 +
 guacamole-common-js/static.xml                     |   12 +-
 guacamole-common/LICENSE                           |  489 +-
 .../doc/example/ExampleTunnelServlet.java          |   31 +-
 guacamole-common/pom.xml                           |   44 +-
 .../guacamole/GuacamoleClientBadTypeException.java |   71 +
 .../guacamole/GuacamoleClientException.java        |   63 +-
 .../guacamole/GuacamoleClientOverrunException.java |   73 +
 .../guacamole/GuacamoleClientTimeoutException.java |   70 +
 .../guacamole/GuacamoleClientTooManyException.java |   72 +
 .../GuacamoleConnectionClosedException.java        |   72 +
 .../glyptodon/guacamole/GuacamoleException.java    |   71 +-
 .../GuacamoleResourceConflictException.java        |   72 +
 .../GuacamoleResourceNotFoundException.java        |   63 +-
 .../guacamole/GuacamoleSecurityException.java      |   63 +-
 .../guacamole/GuacamoleServerBusyException.java    |   71 +
 .../guacamole/GuacamoleServerException.java        |   69 +-
 .../guacamole/GuacamoleUnauthorizedException.java  |   72 +
 .../guacamole/GuacamoleUnsupportedException.java   |   71 +
 .../guacamole/GuacamoleUpstreamException.java      |   72 +
 .../GuacamoleUpstreamTimeoutException.java         |   72 +
 .../glyptodon/guacamole/io/GuacamoleReader.java    |   56 +-
 .../glyptodon/guacamole/io/GuacamoleWriter.java    |   56 +-
 .../guacamole/io/ReaderGuacamoleReader.java        |   66 +-
 .../guacamole/io/WriterGuacamoleWriter.java        |   66 +-
 .../org/glyptodon/guacamole/io/package-info.java   |   21 +
 .../guacamole/net/AbstractGuacamoleTunnel.java     |  133 +
 .../guacamole/net/DelegatingGuacamoleTunnel.java   |  104 +
 .../glyptodon/guacamole/net/GuacamoleSocket.java   |   56 +-
 .../glyptodon/guacamole/net/GuacamoleTunnel.java   |  140 +-
 .../guacamole/net/InetGuacamoleSocket.java         |   62 +-
 .../guacamole/net/SSLGuacamoleSocket.java          |   57 +-
 .../guacamole/net/SimpleGuacamoleTunnel.java       |   69 +
 .../org/glyptodon/guacamole/net/package-info.java  |   21 +
 .../java/org/glyptodon/guacamole/package-info.java |   21 +
 .../protocol/ConfiguredGuacamoleSocket.java        |  141 +-
 .../protocol/FilteredGuacamoleReader.java          |   99 +
 .../protocol/FilteredGuacamoleSocket.java          |  102 +
 .../protocol/FilteredGuacamoleWriter.java          |  107 +
 .../protocol/GuacamoleClientInformation.java       |  104 +-
 .../guacamole/protocol/GuacamoleConfiguration.java |  139 +-
 .../guacamole/protocol/GuacamoleFilter.java        |   51 +
 .../guacamole/protocol/GuacamoleInstruction.java   |   70 +-
 .../guacamole/protocol/GuacamoleParser.java        |  244 +
 .../guacamole/protocol/GuacamoleStatus.java        |  173 +
 .../glyptodon/guacamole/protocol/package-info.java |   21 +
 .../guacamole/servlet/GuacamoleHTTPTunnel.java     |   78 +
 .../guacamole/servlet/GuacamoleHTTPTunnelMap.java  |  213 +
 .../servlet/GuacamoleHTTPTunnelServlet.java        |  358 +-
 .../guacamole/servlet/GuacamoleSession.java        |  140 +-
 .../glyptodon/guacamole/servlet/package-info.java  |   21 +
 .../GuacamoleWebSocketTunnelEndpoint.java          |  246 +
 .../guacamole/io/ReaderGuacamoleReaderTest.java    |   91 +
 .../protocol/FilteredGuacamoleReaderTest.java      |   95 +
 .../protocol/FilteredGuacamoleWriterTest.java      |   73 +
 .../guacamole/protocol/GuacamoleParserTest.java    |  126 +
 guacamole-ext/LICENSE                              |  489 +-
 guacamole-ext/pom.xml                              |   41 +-
 .../guacamole/environment/Environment.java         |  154 +
 .../guacamole/environment/LocalEnvironment.java    |  321 +
 .../org/glyptodon/guacamole/form/BooleanField.java |   53 +
 .../org/glyptodon/guacamole/form/DateField.java    |   97 +
 .../org/glyptodon/guacamole/form/EnumField.java    |   48 +
 .../java/org/glyptodon/guacamole/form/Field.java   |  222 +
 .../org/glyptodon/guacamole/form/FieldOption.java  |  105 +
 .../java/org/glyptodon/guacamole/form/Form.java    |  114 +
 .../glyptodon/guacamole/form/MultilineField.java   |   42 +
 .../org/glyptodon/guacamole/form/NumericField.java |   88 +
 .../glyptodon/guacamole/form/PasswordField.java    |   43 +
 .../org/glyptodon/guacamole/form/TextField.java    |   43 +
 .../org/glyptodon/guacamole/form/TimeField.java    |   97 +
 .../glyptodon/guacamole/form/TimeZoneField.java    |   66 +
 .../glyptodon/guacamole/form/UsernameField.java    |   43 +
 .../org/glyptodon/guacamole/form/package-info.java |   27 +
 .../net/auth/AbstractActiveConnection.java         |  120 +
 .../net/auth/AbstractAuthenticatedUser.java        |   73 +
 .../guacamole/net/auth/AbstractConnection.java     |   74 +-
 .../net/auth/AbstractConnectionGroup.java          |   74 +-
 .../glyptodon/guacamole/net/auth/AbstractUser.java |   61 +-
 .../guacamole/net/auth/ActiveConnection.java       |  128 +
 .../guacamole/net/auth/AuthenticatedUser.java      |   51 +
 .../guacamole/net/auth/AuthenticationProvider.java |  190 +-
 .../glyptodon/guacamole/net/auth/Connectable.java  |   63 +
 .../glyptodon/guacamole/net/auth/Connection.java   |  110 +-
 .../guacamole/net/auth/ConnectionGroup.java        |  169 +-
 .../guacamole/net/auth/ConnectionRecord.java       |   87 +-
 .../guacamole/net/auth/ConnectionRecordSet.java    |  131 +
 .../glyptodon/guacamole/net/auth/Credentials.java  |   57 +-
 .../glyptodon/guacamole/net/auth/Directory.java    |  127 +-
 .../glyptodon/guacamole/net/auth/Identifiable.java |   52 +
 .../org/glyptodon/guacamole/net/auth/User.java     |  171 +-
 .../glyptodon/guacamole/net/auth/UserContext.java  |  159 +-
 .../net/auth/credentials/CredentialsInfo.java      |   93 +
 .../credentials/GuacamoleCredentialsException.java |  100 +
 .../GuacamoleInsufficientCredentialsException.java |   82 +
 .../GuacamoleInvalidCredentialsException.java      |   80 +
 .../glyptodon/guacamole/net/auth/package-info.java |   21 +
 .../auth/permission/ConnectionGroupPermission.java |  121 -
 .../net/auth/permission/ConnectionPermission.java  |  121 -
 .../net/auth/permission/ObjectPermission.java      |  130 +-
 .../net/auth/permission/ObjectPermissionSet.java   |  134 +
 .../guacamole/net/auth/permission/Permission.java  |   57 +-
 .../net/auth/permission/PermissionSet.java         |   82 +
 .../net/auth/permission/SystemPermission.java      |   57 +-
 .../net/auth/permission/SystemPermissionSet.java   |   89 +
 .../net/auth/permission/UserPermission.java        |  116 -
 .../net/auth/permission/package-info.java          |   21 +
 .../auth/simple/SimpleAuthenticationProvider.java  |  241 +-
 .../net/auth/simple/SimpleConnection.java          |  120 +-
 .../net/auth/simple/SimpleConnectionDirectory.java |  128 +-
 .../net/auth/simple/SimpleConnectionGroup.java     |  137 +-
 .../simple/SimpleConnectionGroupDirectory.java     |  109 +-
 .../net/auth/simple/SimpleConnectionRecordSet.java |   62 +
 .../guacamole/net/auth/simple/SimpleDirectory.java |  143 +
 .../net/auth/simple/SimpleObjectPermissionSet.java |  142 +
 .../net/auth/simple/SimpleSystemPermissionSet.java |  113 +
 .../guacamole/net/auth/simple/SimpleUser.java      |  222 +-
 .../net/auth/simple/SimpleUserContext.java         |  222 +-
 .../net/auth/simple/SimpleUserDirectory.java       |  109 +-
 .../guacamole/net/auth/simple/package-info.java    |   21 +
 .../net/event/AuthenticationFailureEvent.java      |   22 +
 .../net/event/AuthenticationSuccessEvent.java      |   22 +
 .../guacamole/net/event/CredentialEvent.java       |   22 +
 .../guacamole/net/event/TunnelCloseEvent.java      |   22 +
 .../guacamole/net/event/TunnelConnectEvent.java    |   23 +-
 .../glyptodon/guacamole/net/event/TunnelEvent.java |   22 +
 .../glyptodon/guacamole/net/event/UserEvent.java   |   22 +
 .../listener/AuthenticationFailureListener.java    |   22 +
 .../listener/AuthenticationSuccessListener.java    |   22 +
 .../net/event/listener/TunnelCloseListener.java    |   22 +
 .../net/event/listener/TunnelConnectListener.java  |   22 +
 .../guacamole/net/event/listener/package-info.java |   21 +
 .../guacamole/net/event/package-info.java          |   21 +
 .../properties/BooleanGuacamoleProperty.java       |   57 +-
 .../properties/FileGuacamoleProperty.java          |   57 +-
 .../guacamole/properties/GuacamoleHome.java        |   70 +-
 .../guacamole/properties/GuacamoleProperties.java  |   70 +-
 .../guacamole/properties/GuacamoleProperty.java    |   57 +-
 .../properties/IntegerGuacamoleProperty.java       |   57 +-
 .../properties/LongGuacamoleProperty.java          |   52 +
 .../properties/StringGuacamoleProperty.java        |   57 +-
 .../guacamole/properties/package-info.java         |   21 +
 .../guacamole/protocols/ProtocolInfo.java          |  122 +
 .../glyptodon/guacamole/token/StandardTokens.java  |   79 +
 .../org/glyptodon/guacamole/token/TokenFilter.java |  234 +
 .../glyptodon/guacamole/xml/DocumentHandler.java   |  205 +
 .../org/glyptodon/guacamole/xml/TagHandler.java    |   70 +
 .../org/glyptodon/guacamole/xml/package-info.java  |   28 +
 .../org/glyptodon/guacamole/protocols/rdp.json     |  254 +
 .../org/glyptodon/guacamole/protocols/ssh.json     |   83 +
 .../org/glyptodon/guacamole/protocols/telnet.json  |   58 +
 .../org/glyptodon/guacamole/protocols/vnc.json     |  135 +
 .../glyptodon/guacamole/token/TokenFilterTest.java |  103 +
 guacamole/COPYING                                  |  661 --
 guacamole/LICENSE                                  |   19 +
 guacamole/doc/example/guacamole.properties         |   26 -
 guacamole/nb-configuration.xml                     |   18 +
 guacamole/pom.xml                                  |  231 +-
 .../net/basic/BasicFileAuthenticationProvider.java |  147 +-
 .../net/basic/AuthenticatingHttpServlet.java       |  354 -
 .../net/basic/BasicGuacamoleTunnelServlet.java     |  380 +-
 .../glyptodon/guacamole/net/basic/BasicLogin.java  |   48 -
 .../glyptodon/guacamole/net/basic/BasicLogout.java |   50 -
 .../net/basic/BasicServletContextListener.java     |  103 +
 .../guacamole/net/basic/ClipboardState.java        |  154 +
 .../guacamole/net/basic/EnvironmentModule.java     |   60 +
 .../guacamole/net/basic/GuacamoleClassLoader.java  |   64 +-
 .../guacamole/net/basic/GuacamoleSession.java      |  222 +
 .../guacamole/net/basic/HTTPTunnelRequest.java     |   90 +
 .../guacamole/net/basic/ProtocolInfo.java          |   99 -
 .../guacamole/net/basic/ProtocolParameter.java     |  171 -
 .../net/basic/ProtocolParameterOption.java         |   76 -
 .../guacamole/net/basic/TunnelLoader.java          |   44 +
 .../guacamole/net/basic/TunnelModule.java          |  113 +
 .../guacamole/net/basic/TunnelRequest.java         |  372 +
 .../guacamole/net/basic/TunnelRequestService.java  |  359 +
 .../net/basic/WebSocketSupportLoader.java          |  114 -
 .../guacamole/net/basic/auth/Authorization.java    |   32 +-
 .../guacamole/net/basic/auth/UserMapping.java      |   37 +-
 .../guacamole/net/basic/auth/package-info.java     |   21 +
 .../connectiongroups/ConnectionGroupUtility.java   |   67 -
 .../net/basic/crud/connectiongroups/Create.java    |   71 -
 .../net/basic/crud/connectiongroups/Delete.java    |   56 -
 .../connectiongroups/DummyConnectionGroup.java     |   39 -
 .../net/basic/crud/connectiongroups/List.java      |  214 -
 .../net/basic/crud/connectiongroups/Move.java      |   62 -
 .../net/basic/crud/connectiongroups/Update.java    |   66 -
 .../basic/crud/connectiongroups/package-info.java  |    6 -
 .../basic/crud/connections/ConnectionUtility.java  |   68 -
 .../net/basic/crud/connections/Create.java         |   93 -
 .../net/basic/crud/connections/Delete.java         |   56 -
 .../basic/crud/connections/DummyConnection.java    |   33 -
 .../guacamole/net/basic/crud/connections/List.java |  338 -
 .../guacamole/net/basic/crud/connections/Move.java |   62 -
 .../net/basic/crud/connections/Update.java         |   88 -
 .../net/basic/crud/connections/package-info.java   |    6 -
 .../guacamole/net/basic/crud/permissions/List.java |  220 -
 .../net/basic/crud/permissions/package-info.java   |    6 -
 .../guacamole/net/basic/crud/protocols/List.java   |  300 -
 .../net/basic/crud/protocols/package-info.java     |    6 -
 .../guacamole/net/basic/crud/users/Create.java     |   61 -
 .../guacamole/net/basic/crud/users/Delete.java     |   54 -
 .../guacamole/net/basic/crud/users/DummyUser.java  |   46 -
 .../guacamole/net/basic/crud/users/List.java       |  102 -
 .../guacamole/net/basic/crud/users/Update.java     |  307 -
 .../net/basic/crud/users/package-info.java         |    6 -
 .../net/basic/event/SessionListenerCollection.java |  132 -
 .../guacamole/net/basic/event/package-info.java    |    6 -
 .../extension/AuthenticationProviderFacade.java    |  203 +
 .../net/basic/extension/DirectoryClassLoader.java  |  154 +
 .../guacamole/net/basic/extension/Extension.java   |  495 ++
 .../net/basic/extension/ExtensionManifest.java     |  371 +
 .../net/basic/extension/ExtensionModule.java       |  440 +
 .../basic/extension/LanguageResourceService.java   |  442 +
 .../net/basic/extension/package-info.java          |   27 +
 .../guacamole/net/basic/log/LogModule.java         |   99 +
 .../guacamole/net/basic/package-info.java          |   21 +
 .../properties/AuthenticationProviderProperty.java |   70 +-
 .../basic/properties/BasicGuacamoleProperties.java |   63 +-
 .../basic/properties/EventListenersProperty.java   |   68 -
 .../net/basic/properties/StringSetProperty.java    |   66 +
 .../net/basic/properties/package-info.java         |   21 +
 .../net/basic/resource/AbstractResource.java       |   82 +
 .../net/basic/resource/ByteArrayResource.java      |   62 +
 .../net/basic/resource/ClassPathResource.java      |   85 +
 .../guacamole/net/basic/resource/Resource.java     |   67 +
 .../net/basic/resource/ResourceServlet.java        |  126 +
 .../net/basic/resource/SequenceResource.java       |  153 +
 .../net/basic/resource/WebApplicationResource.java |  116 +
 .../guacamole/net/basic/resource/package-info.java |   28 +
 .../guacamole/net/basic/rest/APIError.java         |  183 +
 .../guacamole/net/basic/rest/APIException.java     |   85 +
 .../guacamole/net/basic/rest/APIPatch.java         |  104 +
 .../guacamole/net/basic/rest/APIRequest.java       |  107 +
 .../net/basic/rest/ObjectRetrievalService.java     |  280 +
 .../glyptodon/guacamole/net/basic/rest/PATCH.java  |   40 +
 .../net/basic/rest/RESTExceptionWrapper.java       |  274 +
 .../net/basic/rest/RESTMethodMatcher.java          |  109 +
 .../net/basic/rest/RESTServiceModule.java          |  105 +
 .../rest/activeconnection/APIActiveConnection.java |  130 +
 .../ActiveConnectionRESTService.java               |  196 +
 .../basic/rest/auth/APIAuthenticationResponse.java |  105 +
 .../basic/rest/auth/APIAuthenticationResult.java   |  133 +
 .../net/basic/rest/auth/AuthTokenGenerator.java    |   39 +
 .../net/basic/rest/auth/AuthenticationService.java |  475 +
 .../net/basic/rest/auth/BasicTokenSessionMap.java  |  189 +
 .../rest/auth/SecureRandomAuthTokenGenerator.java  |   48 +
 .../net/basic/rest/auth/TokenRESTService.java      |  230 +
 .../net/basic/rest/auth/TokenSessionMap.java       |   69 +
 .../net/basic/rest/auth/package-info.java          |   27 +
 .../net/basic/rest/connection/APIConnection.java   |  233 +
 .../rest/connection/APIConnectionWrapper.java      |  138 +
 .../rest/connection/ConnectionRESTService.java     |  349 +
 .../net/basic/rest/connection/package-info.java    |   27 +
 .../rest/connectiongroup/APIConnectionGroup.java   |  272 +
 .../connectiongroup/APIConnectionGroupWrapper.java |  124 +
 .../ConnectionGroupRESTService.java                |  287 +
 .../rest/connectiongroup/ConnectionGroupTree.java  |  259 +
 .../basic/rest/connectiongroup/package-info.java   |   28 +
 .../basic/rest/history/APIConnectionRecord.java    |  163 +
 .../history/APIConnectionRecordSortPredicate.java  |  148 +
 .../net/basic/rest/history/HistoryRESTService.java |  147 +
 .../net/basic/rest/history/package-info.java       |   28 +
 .../basic/rest/language/LanguageRESTService.java   |   63 +
 .../net/basic/rest/language/package-info.java      |   27 +
 .../guacamole/net/basic/rest/package-info.java     |   27 +
 .../basic/rest/permission/APIPermissionSet.java    |  300 +
 .../net/basic/rest/permission/package-info.java    |   27 +
 .../net/basic/rest/schema/SchemaRESTService.java   |  199 +
 .../net/basic/rest/schema/package-info.java        |   27 +
 .../guacamole/net/basic/rest/user/APIUser.java     |  130 +
 .../net/basic/rest/user/APIUserPasswordUpdate.java |   82 +
 .../net/basic/rest/user/APIUserWrapper.java        |  115 +
 .../net/basic/rest/user/PermissionSetPatch.java    |   98 +
 .../net/basic/rest/user/UserRESTService.java       |  647 ++
 .../net/basic/rest/user/package-info.java          |   27 +
 .../BasicGuacamoleWebSocketTunnelEndpoint.java     |  120 +
 .../net/basic/websocket/WebSocketTunnelModule.java |  104 +
 .../basic/websocket/WebSocketTunnelRequest.java    |   70 +
 .../BasicGuacamoleWebSocketTunnelServlet.java      |   52 +
 .../jetty8/GuacamoleWebSocketTunnelServlet.java    |  232 +
 .../websocket/jetty8/WebSocketTunnelModule.java    |   73 +
 .../net/basic/websocket/jetty8/package-info.java   |   27 +
 .../jetty9/BasicGuacamoleWebSocketCreator.java     |   72 +
 .../BasicGuacamoleWebSocketTunnelListener.java     |   59 +
 .../BasicGuacamoleWebSocketTunnelServlet.java      |   54 +
 .../jetty9/GuacamoleWebSocketTunnelListener.java   |  243 +
 .../websocket/jetty9/WebSocketTunnelModule.java    |   73 +
 .../websocket/jetty9/WebSocketTunnelRequest.java   |   76 +
 .../net/basic/websocket/jetty9/package-info.java   |   28 +
 .../net/basic/websocket/package-info.java          |   28 +
 .../BasicGuacamoleWebSocketTunnelServlet.java      |   52 +
 .../tomcat/GuacamoleWebSocketTunnelServlet.java    |  265 +
 .../websocket/tomcat/WebSocketTunnelModule.java    |   73 +
 .../net/basic/websocket/tomcat/package-info.java   |   29 +
 .../guacamole/net/basic/xml/DocumentHandler.java   |  196 -
 .../guacamole/net/basic/xml/TagHandler.java        |   66 -
 .../guacamole/net/basic/xml/package-info.java      |    7 -
 .../net/basic/xml/protocol/OptionTagHandler.java   |   61 -
 .../net/basic/xml/protocol/ParamTagHandler.java    |  112 -
 .../net/basic/xml/protocol/ProtocolTagHandler.java |   77 -
 .../net/basic/xml/protocol/package-info.java       |    7 -
 .../xml/user_mapping/AuthorizeTagHandler.java      |  147 -
 .../xml/user_mapping/ConnectionTagHandler.java     |  106 -
 .../basic/xml/user_mapping/ParamTagHandler.java    |   70 -
 .../basic/xml/user_mapping/ProtocolTagHandler.java |   66 -
 .../xml/user_mapping/UserMappingTagHandler.java    |   74 -
 .../net/basic/xml/user_mapping/package-info.java   |    6 -
 .../basic/xml/usermapping/AuthorizeTagHandler.java |  151 +
 .../xml/usermapping/ConnectionTagHandler.java      |  110 +
 .../net/basic/xml/usermapping/ParamTagHandler.java |   74 +
 .../basic/xml/usermapping/ProtocolTagHandler.java  |   70 +
 .../xml/usermapping/UserMappingTagHandler.java     |   78 +
 .../net/basic/xml/usermapping/package-info.java    |   27 +
 guacamole/src/main/resources/logback.xml           |   37 +
 .../sourceforge/guacamole/net/protocols/rdp.xml    |   44 -
 .../sourceforge/guacamole/net/protocols/ssh.xml    |   28 -
 .../sourceforge/guacamole/net/protocols/vnc.xml    |   23 -
 guacamole/src/main/webapp/WEB-INF/web.xml          |  270 +-
 guacamole/src/main/webapp/admin.xhtml              |   99 -
 guacamole/src/main/webapp/agpl-3.0-standalone.html |  688 --
 guacamole/src/main/webapp/app/auth/authModule.js   |   26 +
 .../app/auth/service/authenticationService.js      |  343 +
 .../src/main/webapp/app/client/clientModule.js     |   36 +
 .../app/client/controllers/clientController.js     |  734 ++
 .../webapp/app/client/directives/guacClient.js     |  450 +
 .../app/client/directives/guacFileBrowser.js       |  292 +
 .../app/client/directives/guacFileTransfer.js      |  237 +
 .../client/directives/guacFileTransferManager.js   |   99 +
 .../webapp/app/client/directives/guacThumbnail.js  |  162 +
 .../webapp/app/client/directives/guacViewport.js   |  111 +
 .../main/webapp/app/client/services/guacAudio.js   |   42 +
 .../app/client/services/guacClientManager.js       |  172 +
 .../main/webapp/app/client/services/guacImage.js   |  138 +
 .../main/webapp/app/client/services/guacVideo.js   |   40 +
 .../src/main/webapp/app/client/styles/client.css   |  128 +
 .../src/main/webapp/app/client/styles/display.css  |   68 +
 .../main/webapp/app/client/styles/file-browser.css |   52 +
 .../app/client/styles/file-transfer-dialog.css     |  121 +
 .../webapp/app/client/styles/filesystem-menu.css   |   75 +
 .../main/webapp/app/client/styles/guac-menu.css    |  188 +
 .../src/main/webapp/app/client/styles/keyboard.css |   35 +
 .../src/main/webapp/app/client/styles/menu.css     |  149 +
 .../webapp/app/client/styles/thumbnail-display.css |   38 +
 .../webapp/app/client/styles/transfer-manager.css  |   46 +
 .../src/main/webapp/app/client/styles/transfer.css |  135 +
 .../src/main/webapp/app/client/styles/viewport.css |   30 +
 .../main/webapp/app/client/templates/client.html   |  192 +
 .../src/main/webapp/app/client/templates/file.html |   30 +
 .../webapp/app/client/templates/guacClient.html    |   34 +
 .../app/client/templates/guacFileBrowser.html      |   27 +
 .../app/client/templates/guacFileTransfer.html     |   43 +
 .../client/templates/guacFileTransferManager.html  |   43 +
 .../webapp/app/client/templates/guacThumbnail.html |   31 +
 .../webapp/app/client/templates/guacViewport.html  |   23 +
 .../webapp/app/client/types/ClientProperties.js    |  109 +
 .../main/webapp/app/client/types/ManagedClient.js  |  496 ++
 .../webapp/app/client/types/ManagedClientState.js  |  159 +
 .../main/webapp/app/client/types/ManagedDisplay.js |  177 +
 .../webapp/app/client/types/ManagedFileDownload.js |  156 +
 .../app/client/types/ManagedFileTransferState.js   |  136 +
 .../webapp/app/client/types/ManagedFileUpload.js   |  236 +
 .../webapp/app/client/types/ManagedFilesystem.js   |  334 +
 .../webapp/app/element/directives/guacFocus.js     |   76 +
 .../webapp/app/element/directives/guacMarker.js    |   62 +
 .../webapp/app/element/directives/guacResize.js    |  117 +
 .../webapp/app/element/directives/guacScroll.js    |   85 +
 .../webapp/app/element/directives/guacUpload.js    |   95 +
 .../src/main/webapp/app/element/elementModule.js   |   27 +
 .../webapp/app/element/styles/resize-sensor.css    |   33 +
 .../main/webapp/app/element/templates/blank.html   |   29 +
 .../src/main/webapp/app/element/types/Marker.js    |   50 +
 .../main/webapp/app/element/types/ScrollState.js   |   63 +
 .../form/controllers/checkboxFieldController.js    |   40 +
 .../app/form/controllers/dateFieldController.js    |   92 +
 .../app/form/controllers/numberFieldController.js  |   40 +
 .../form/controllers/passwordFieldController.js    |   75 +
 .../app/form/controllers/selectFieldController.js  |   70 +
 .../app/form/controllers/timeFieldController.js    |   92 +
 .../form/controllers/timeZoneFieldController.js    |  711 ++
 .../src/main/webapp/app/form/directives/form.js    |  172 +
 .../main/webapp/app/form/directives/formField.js   |  121 +
 .../webapp/app/form/directives/guacLenientDate.js  |   83 +
 .../webapp/app/form/directives/guacLenientTime.js  |  103 +
 guacamole/src/main/webapp/app/form/formModule.js   |   26 +
 .../main/webapp/app/form/services/formService.js   |  265 +
 .../src/main/webapp/app/form/styles/form-field.css |   50 +
 guacamole/src/main/webapp/app/form/styles/form.css |   27 +
 .../webapp/app/form/templates/checkboxField.html   |    1 +
 .../main/webapp/app/form/templates/dateField.html  |    9 +
 .../src/main/webapp/app/form/templates/form.html   |   35 +
 .../main/webapp/app/form/templates/formField.html  |   31 +
 .../webapp/app/form/templates/numberField.html     |    1 +
 .../webapp/app/form/templates/passwordField.html   |    4 +
 .../webapp/app/form/templates/selectField.html     |    1 +
 .../webapp/app/form/templates/textAreaField.html   |    1 +
 .../main/webapp/app/form/templates/textField.html  |    1 +
 .../main/webapp/app/form/templates/timeField.html  |    9 +
 .../webapp/app/form/templates/timeZoneField.html   |   14 +
 .../src/main/webapp/app/form/types/FieldType.js    |   92 +
 .../app/groupList/directives/guacGroupList.js      |  249 +
 .../groupList/directives/guacGroupListFilter.js    |  212 +
 .../main/webapp/app/groupList/groupListModule.js   |   27 +
 .../app/groupList/templates/guacGroupList.html     |   63 +
 .../groupList/templates/guacGroupListFilter.html   |   27 +
 .../webapp/app/groupList/types/GroupListItem.js    |  281 +
 .../src/main/webapp/app/history/historyModule.js   |   26 +
 .../webapp/app/history/services/guacHistory.js     |  103 +
 .../main/webapp/app/history/types/HistoryEntry.js  |   55 +
 .../webapp/app/home/controllers/homeController.js  |  133 +
 .../app/home/directives/guacRecentConnections.js   |  202 +
 guacamole/src/main/webapp/app/home/homeModule.js   |   23 +
 guacamole/src/main/webapp/app/home/styles/home.css |   55 +
 .../main/webapp/app/home/templates/connection.html |   40 +
 .../webapp/app/home/templates/connectionGroup.html |   26 +
 .../app/home/templates/guacRecentConnections.html  |   61 +
 .../src/main/webapp/app/home/templates/home.html   |   56 +
 .../main/webapp/app/home/types/ActiveConnection.js |   55 +
 .../main/webapp/app/home/types/RecentConnection.js |   55 +
 .../app/index/config/indexHttpPatchConfig.js       |   34 +
 .../webapp/app/index/config/indexRouteConfig.js    |  174 +
 .../app/index/config/indexTranslationConfig.js     |   47 +
 .../app/index/controllers/indexController.js       |  172 +
 guacamole/src/main/webapp/app/index/indexModule.js |   40 +
 .../src/main/webapp/app/index/styles/animation.css |   53 +
 .../src/main/webapp/app/index/styles/buttons.css   |  137 +
 .../src/main/webapp/app/index/styles/dialog.css    |   97 +
 .../main/webapp/app/index/styles/font-carlito.css  |   50 +
 .../src/main/webapp/app/index/styles/headers.css   |  107 +
 .../src/main/webapp/app/index/styles/input.css     |   48 +
 .../src/main/webapp/app/index/styles/lists.css     |   96 +
 .../src/main/webapp/app/index/styles/loading.css   |   79 +
 .../main/webapp/app/index/styles/sorted-tables.css |   64 +
 .../src/main/webapp/app/index/styles/status.css    |   83 +
 guacamole/src/main/webapp/app/index/styles/ui.css  |  237 +
 .../main/webapp/app/list/directives/guacFilter.js  |  117 +
 .../main/webapp/app/list/directives/guacPager.js   |  303 +
 .../webapp/app/list/directives/guacSortOrder.js    |  104 +
 guacamole/src/main/webapp/app/list/listModule.js   |   27 +
 .../src/main/webapp/app/list/styles/filter.css     |   36 +
 .../src/main/webapp/app/list/styles/pager.css      |   90 +
 .../main/webapp/app/list/templates/guacFilter.html |   27 +
 .../main/webapp/app/list/templates/guacPager.html  |   46 +
 .../main/webapp/app/list/types/FilterPattern.js    |  258 +
 .../src/main/webapp/app/list/types/FilterToken.js  |  232 +
 .../src/main/webapp/app/list/types/IPv4Network.js  |  129 +
 .../src/main/webapp/app/list/types/IPv6Network.js  |  230 +
 .../src/main/webapp/app/list/types/SortOrder.js    |  149 +
 .../src/main/webapp/app/locale/localeModule.js     |   26 +
 .../app/locale/services/translationLoader.js       |  160 +
 .../locale/services/translationStringService.js    |   49 +
 .../src/main/webapp/app/login/directives/login.js  |  190 +
 guacamole/src/main/webapp/app/login/loginModule.js |   30 +
 .../src/main/webapp/app/login/styles/animation.css |   37 +
 .../src/main/webapp/app/login/styles/dialog.css    |  106 +
 .../src/main/webapp/app/login/styles/input.css     |   50 +
 .../src/main/webapp/app/login/styles/login.css     |   89 +
 .../src/main/webapp/app/login/templates/login.html |   57 +
 .../controllers/manageConnectionController.js      |  455 +
 .../controllers/manageConnectionGroupController.js |  296 +
 .../app/manage/controllers/manageUserController.js |  962 ++
 .../app/manage/directives/locationChooser.js       |  172 +
 .../src/main/webapp/app/manage/manageModule.js     |   34 +
 .../main/webapp/app/manage/styles/attributes.css   |   65 +
 .../app/manage/styles/connection-parameter.css     |   49 +
 .../src/main/webapp/app/manage/styles/forms.css    |   32 +
 .../webapp/app/manage/styles/locationChooser.css   |   38 +
 .../main/webapp/app/manage/styles/manage-user.css  |   78 +
 .../templates/connectionGroupPermission.html       |   28 +
 .../app/manage/templates/connectionPermission.html |   36 +
 .../app/manage/templates/locationChooser.html      |   36 +
 .../templates/locationChooserConnectionGroup.html  |   25 +
 .../app/manage/templates/manageConnection.html     |  113 +
 .../manage/templates/manageConnectionGroup.html    |   74 +
 .../webapp/app/manage/templates/manageUser.html    |  117 +
 .../webapp/app/manage/types/HistoryEntryWrapper.js |   83 +
 .../main/webapp/app/manage/types/ManageableUser.js |   56 +
 .../app/navigation/directives/guacPageList.js      |  249 +
 .../app/navigation/directives/guacUserMenu.js      |  146 +
 .../main/webapp/app/navigation/navigationModule.js |   30 +
 .../app/navigation/services/userPageService.js     |  423 +
 .../webapp/app/navigation/styles/page-tabs.css     |   58 +
 .../webapp/app/navigation/styles/user-menu.css     |  216 +
 .../app/navigation/templates/guacPageList.html     |   34 +
 .../app/navigation/templates/guacUserMenu.html     |   55 +
 .../app/navigation/types/ClientIdentifier.js       |  157 +
 .../main/webapp/app/navigation/types/MenuAction.js |   77 +
 .../webapp/app/navigation/types/PageDefinition.js  |   78 +
 .../notification/directives/guacNotification.js    |   89 +
 .../webapp/app/notification/notificationModule.js  |   28 +
 .../app/notification/services/guacNotification.js  |   90 +
 .../app/notification/styles/notification.css       |  106 +
 .../notification/templates/guacNotification.html   |   53 +
 .../webapp/app/notification/types/Notification.js  |   91 +
 .../app/notification/types/NotificationAction.js   |   76 +
 .../notification/types/NotificationCountdown.js    |   79 +
 .../app/notification/types/NotificationProgress.js |   87 +
 .../src/main/webapp/app/osk/directives/guacOsk.js  |  126 +
 guacamole/src/main/webapp/app/osk/oskModule.js     |   26 +
 guacamole/src/main/webapp/app/osk/styles/osk.css   |  217 +
 .../src/main/webapp/app/osk/templates/guacOsk.html |   23 +
 guacamole/src/main/webapp/app/rest/restModule.js   |   27 +
 .../app/rest/services/activeConnectionService.js   |  174 +
 .../main/webapp/app/rest/services/cacheService.js  |   82 +
 .../app/rest/services/connectionGroupService.js    |  203 +
 .../webapp/app/rest/services/connectionService.js  |  210 +
 .../webapp/app/rest/services/dataSourceService.js  |  125 +
 .../webapp/app/rest/services/historyService.js     |   90 +
 .../webapp/app/rest/services/languageService.js    |   64 +
 .../webapp/app/rest/services/permissionService.js  |  254 +
 .../main/webapp/app/rest/services/schemaService.js |  171 +
 .../main/webapp/app/rest/services/userService.js   |  278 +
 .../main/webapp/app/rest/types/ActiveConnection.js |   86 +
 .../src/main/webapp/app/rest/types/Connection.js   |  105 +
 .../main/webapp/app/rest/types/ConnectionGroup.js  |  145 +
 .../app/rest/types/ConnectionHistoryEntry.js       |  192 +
 guacamole/src/main/webapp/app/rest/types/Error.js  |  122 +
 guacamole/src/main/webapp/app/rest/types/Field.js  |  164 +
 guacamole/src/main/webapp/app/rest/types/Form.js   |   61 +
 .../webapp/app/rest/types/PermissionFlagSet.js     |  206 +
 .../main/webapp/app/rest/types/PermissionPatch.js  |   94 +
 .../main/webapp/app/rest/types/PermissionSet.js    |  655 ++
 .../src/main/webapp/app/rest/types/Protocol.js     |   62 +
 guacamole/src/main/webapp/app/rest/types/User.js   |   72 +
 .../webapp/app/rest/types/UserPasswordUpdate.js    |   61 +
 .../app/settings/controllers/settingsController.js |   55 +
 .../directives/guacSettingsConnectionHistory.js    |  190 +
 .../settings/directives/guacSettingsConnections.js |  241 +
 .../settings/directives/guacSettingsPreferences.js |  211 +
 .../settings/directives/guacSettingsSessions.js    |  405 +
 .../app/settings/directives/guacSettingsUsers.js   |  269 +
 .../app/settings/services/preferenceService.js     |  201 +
 .../src/main/webapp/app/settings/settingsModule.js |   33 +
 .../main/webapp/app/settings/styles/buttons.css    |   58 +
 .../main/webapp/app/settings/styles/history.css    |   59 +
 .../webapp/app/settings/styles/input-method.css    |   26 +
 .../main/webapp/app/settings/styles/mouse-mode.css |   47 +
 .../webapp/app/settings/styles/preferences.css     |   27 +
 .../main/webapp/app/settings/styles/sessions.css   |   34 +
 .../main/webapp/app/settings/styles/settings.css   |   78 +
 .../webapp/app/settings/templates/connection.html  |   40 +
 .../app/settings/templates/connectionGroup.html    |   25 +
 .../webapp/app/settings/templates/settings.html    |   42 +
 .../templates/settingsConnectionHistory.html       |   75 +
 .../settings/templates/settingsConnections.html    |   60 +
 .../settings/templates/settingsPreferences.html    |  122 +
 .../app/settings/templates/settingsSessions.html   |   77 +
 .../app/settings/templates/settingsUsers.html      |   60 +
 .../app/settings/types/ActiveConnectionWrapper.js  |   80 +
 .../types/ConnectionHistoryEntryWrapper.js         |  121 +
 .../app/storage/services/sessionStorageFactory.js  |  134 +
 .../src/main/webapp/app/storage/storageModule.js   |   28 +
 .../webapp/app/textInput/directives/guacKey.js     |  115 +
 .../app/textInput/directives/guacTextInput.js      |  362 +
 .../main/webapp/app/textInput/styles/textInput.css |  125 +
 .../webapp/app/textInput/templates/guacKey.html    |   25 +
 .../app/textInput/templates/guacTextInput.html     |   27 +
 .../main/webapp/app/textInput/textInputModule.js   |   26 +
 .../webapp/app/touch/directives/guacTouchDrag.js   |  192 +
 .../webapp/app/touch/directives/guacTouchPinch.js  |  213 +
 guacamole/src/main/webapp/app/touch/touchModule.js |   26 +
 guacamole/src/main/webapp/client.xhtml             |  151 -
 .../main/webapp/fonts/carlito/Carlito-Bold.woff    |  Bin 0 -> 281212 bytes
 .../main/webapp/fonts/carlito/Carlito-Italic.woff  |  Bin 0 -> 283500 bytes
 .../main/webapp/fonts/carlito/Carlito-Regular.woff |  Bin 0 -> 269832 bytes
 guacamole/src/main/webapp/fonts/carlito/LICENSE    |   95 +
 .../webapp/generated/templates-main/templates.js   | 2311 +++++
 .../main/webapp/images/action-icons/guac-back.png  |  Bin 0 -> 586 bytes
 .../main/webapp/images/action-icons/guac-close.png |  Bin 704 -> 0 bytes
 .../images/action-icons/guac-config-dark.png       |  Bin 0 -> 966 bytes
 .../webapp/images/action-icons/guac-config.png     |  Bin 1063 -> 1230 bytes
 .../webapp/images/action-icons/guac-group-add.png  |  Bin 0 -> 525 bytes
 .../webapp/images/action-icons/guac-hide-pass.png  |  Bin 0 -> 721 bytes
 .../webapp/images/action-icons/guac-home-dark.png  |  Bin 0 -> 780 bytes
 .../main/webapp/images/action-icons/guac-home.png  |  Bin 0 -> 874 bytes
 .../webapp/images/action-icons/guac-key-dark.png   |  Bin 0 -> 728 bytes
 .../main/webapp/images/action-icons/guac-key.png   |  Bin 0 -> 702 bytes
 .../images/action-icons/guac-logout-dark.png       |  Bin 0 -> 1032 bytes
 .../webapp/images/action-icons/guac-logout.png     |  Bin 0 -> 1024 bytes
 .../images/action-icons/guac-monitor-add.png       |  Bin 703 -> 560 bytes
 .../webapp/images/action-icons/guac-show-pass.png  |  Bin 0 -> 709 bytes
 .../webapp/images/action-icons/guac-user-add.png   |  Bin 971 -> 810 bytes
 .../src/main/webapp/images/arrows/arrows-d.png     |  Bin 3182 -> 0 bytes
 .../src/main/webapp/images/arrows/arrows-l.png     |  Bin 2750 -> 0 bytes
 .../src/main/webapp/images/arrows/arrows-r.png     |  Bin 2784 -> 0 bytes
 .../src/main/webapp/images/arrows/arrows-u.png     |  Bin 3185 -> 0 bytes
 guacamole/src/main/webapp/images/arrows/down.png   |  Bin 0 -> 282 bytes
 guacamole/src/main/webapp/images/arrows/up.png     |  Bin 0 -> 237 bytes
 guacamole/src/main/webapp/images/checkmark.png     |  Bin 0 -> 569 bytes
 guacamole/src/main/webapp/images/circle-arrows.png |  Bin 0 -> 888 bytes
 guacamole/src/main/webapp/images/cog.png           |  Bin 0 -> 4651 bytes
 guacamole/src/main/webapp/images/drive.png         |  Bin 0 -> 752 bytes
 guacamole/src/main/webapp/images/file.png          |  Bin 0 -> 471 bytes
 guacamole/src/main/webapp/images/folder-closed.png |  Bin 0 -> 487 bytes
 guacamole/src/main/webapp/images/folder-open.png   |  Bin 0 -> 803 bytes
 guacamole/src/main/webapp/images/folder-up.png     |  Bin 0 -> 819 bytes
 guacamole/src/main/webapp/images/guac-tricolor.png |  Bin 0 -> 14890 bytes
 .../src/main/webapp/images/guacamole-logo-24.png   |  Bin 1520 -> 0 bytes
 guacamole/src/main/webapp/images/lock.png          |  Bin 0 -> 511 bytes
 .../{guacamole-logo-144.png => logo-144.png}       |  Bin
 .../images/{guacamole-logo-64.png => logo-64.png}  |  Bin
 guacamole/src/main/webapp/images/magnifier.png     |  Bin 0 -> 1058 bytes
 guacamole/src/main/webapp/images/plus.png          |  Bin 0 -> 299 bytes
 .../main/webapp/images/settings/tablet-keys.png    |  Bin 0 -> 3175 bytes
 .../src/main/webapp/images/settings/touchpad.png   |  Bin 0 -> 38013 bytes
 .../main/webapp/images/settings/touchscreen.png    |  Bin 0 -> 24025 bytes
 .../src/main/webapp/images/settings/zoom-in.png    |  Bin 0 -> 1553 bytes
 .../src/main/webapp/images/settings/zoom-out.png   |  Bin 0 -> 1521 bytes
 guacamole/src/main/webapp/images/x.png             |  Bin 0 -> 591 bytes
 guacamole/src/main/webapp/index.html               |   60 +
 guacamole/src/main/webapp/index.xhtml              |  149 -
 .../src/main/webapp/layouts/de-de-qwertz.json      |  450 +
 .../main/webapp/layouts/en-us-qwerty-mobile.xml    |  312 -
 .../src/main/webapp/layouts/en-us-qwerty.json      |  399 +
 guacamole/src/main/webapp/layouts/en-us-qwerty.xml |  496 --
 .../src/main/webapp/layouts/fr-fr-azerty.json      |  399 +
 .../src/main/webapp/layouts/it-it-qwerty.json      |  453 +
 .../src/main/webapp/layouts/ru-ru-qwerty.json      |  411 +
 .../main/webapp/lib/angular-module-shim/LICENSE    |   21 +
 .../lib/angular-module-shim/angular-module-shim.js |   32 +
 .../src/main/webapp/lib/angular-translate/LICENSE  |   21 +
 ...ngular-translate-interpolation-messageformat.js |  157 +
 .../angular-translate-loader-static-files.js       |  114 +
 .../lib/angular-translate/angular-translate.js     | 2904 ++++++
 guacamole/src/main/webapp/lib/angular/LICENSE      |   22 +
 .../src/main/webapp/lib/angular/angular-cookies.js |  207 +
 .../src/main/webapp/lib/angular/angular-route.js   |  991 +++
 .../src/main/webapp/lib/angular/angular-touch.js   |  631 ++
 .../src/main/webapp/lib/angular/angular.min.js     |  252 +
 guacamole/src/main/webapp/lib/blob/LICENSE.md      |   25 +
 guacamole/src/main/webapp/lib/blob/blob.js         |  197 +
 .../{scripts/lib/blob => lib/filesaver}/LICENSE.md |    0
 .../{scripts => }/lib/filesaver/filesaver.js       |    0
 .../src/main/webapp/lib/jquery/MIT-LICENSE.txt     |   21 +
 guacamole/src/main/webapp/lib/jquery/jquery.js     | 9205 ++++++++++++++++++++
 guacamole/src/main/webapp/lib/lodash/LICENSE.txt   |   22 +
 guacamole/src/main/webapp/lib/lodash/lodash.js     |   56 +
 .../src/main/webapp/lib/messageformat/LICENSE      |   14 +
 .../src/main/webapp/lib/messageformat/locale/af.js |    6 +
 .../src/main/webapp/lib/messageformat/locale/am.js |    6 +
 .../src/main/webapp/lib/messageformat/locale/ar.js |   18 +
 .../src/main/webapp/lib/messageformat/locale/bg.js |    6 +
 .../src/main/webapp/lib/messageformat/locale/bn.js |    6 +
 .../src/main/webapp/lib/messageformat/locale/br.js |   18 +
 .../src/main/webapp/lib/messageformat/locale/ca.js |    6 +
 .../src/main/webapp/lib/messageformat/locale/cs.js |    9 +
 .../src/main/webapp/lib/messageformat/locale/cy.js |   18 +
 .../src/main/webapp/lib/messageformat/locale/da.js |    6 +
 .../src/main/webapp/lib/messageformat/locale/de.js |    6 +
 .../src/main/webapp/lib/messageformat/locale/el.js |    6 +
 .../src/main/webapp/lib/messageformat/locale/en.js |    6 +
 .../src/main/webapp/lib/messageformat/locale/es.js |    6 +
 .../src/main/webapp/lib/messageformat/locale/et.js |    6 +
 .../src/main/webapp/lib/messageformat/locale/eu.js |    6 +
 .../src/main/webapp/lib/messageformat/locale/fa.js |    3 +
 .../src/main/webapp/lib/messageformat/locale/fi.js |    6 +
 .../main/webapp/lib/messageformat/locale/fil.js    |    6 +
 .../src/main/webapp/lib/messageformat/locale/fr.js |    6 +
 .../src/main/webapp/lib/messageformat/locale/ga.js |    9 +
 .../src/main/webapp/lib/messageformat/locale/gl.js |    6 +
 .../main/webapp/lib/messageformat/locale/gsw.js    |    6 +
 .../src/main/webapp/lib/messageformat/locale/gu.js |    6 +
 .../src/main/webapp/lib/messageformat/locale/he.js |    6 +
 .../src/main/webapp/lib/messageformat/locale/hi.js |    6 +
 .../src/main/webapp/lib/messageformat/locale/hr.js |   14 +
 .../src/main/webapp/lib/messageformat/locale/hu.js |    3 +
 .../src/main/webapp/lib/messageformat/locale/id.js |    3 +
 .../src/main/webapp/lib/messageformat/locale/in.js |    3 +
 .../src/main/webapp/lib/messageformat/locale/is.js |    6 +
 .../src/main/webapp/lib/messageformat/locale/it.js |    6 +
 .../src/main/webapp/lib/messageformat/locale/iw.js |    6 +
 .../src/main/webapp/lib/messageformat/locale/ja.js |    3 +
 .../src/main/webapp/lib/messageformat/locale/kn.js |    3 +
 .../src/main/webapp/lib/messageformat/locale/ko.js |    3 +
 .../main/webapp/lib/messageformat/locale/lag.js    |    9 +
 .../src/main/webapp/lib/messageformat/locale/ln.js |    6 +
 .../src/main/webapp/lib/messageformat/locale/lt.js |   10 +
 .../src/main/webapp/lib/messageformat/locale/lv.js |    9 +
 .../src/main/webapp/lib/messageformat/locale/mk.js |    6 +
 .../src/main/webapp/lib/messageformat/locale/ml.js |    6 +
 .../src/main/webapp/lib/messageformat/locale/mo.js |   10 +
 .../src/main/webapp/lib/messageformat/locale/mr.js |    6 +
 .../src/main/webapp/lib/messageformat/locale/ms.js |    3 +
 .../src/main/webapp/lib/messageformat/locale/mt.js |   12 +
 .../src/main/webapp/lib/messageformat/locale/nl.js |    6 +
 .../src/main/webapp/lib/messageformat/locale/no.js |    6 +
 .../src/main/webapp/lib/messageformat/locale/or.js |    6 +
 .../src/main/webapp/lib/messageformat/locale/pl.js |   15 +
 .../src/main/webapp/lib/messageformat/locale/pt.js |    6 +
 .../src/main/webapp/lib/messageformat/locale/ro.js |   10 +
 .../src/main/webapp/lib/messageformat/locale/ru.js |   14 +
 .../main/webapp/lib/messageformat/locale/shi.js    |    9 +
 .../src/main/webapp/lib/messageformat/locale/sk.js |    9 +
 .../src/main/webapp/lib/messageformat/locale/sl.js |   12 +
 .../src/main/webapp/lib/messageformat/locale/sq.js |    6 +
 .../src/main/webapp/lib/messageformat/locale/sr.js |   14 +
 .../src/main/webapp/lib/messageformat/locale/sv.js |    6 +
 .../src/main/webapp/lib/messageformat/locale/sw.js |    6 +
 .../src/main/webapp/lib/messageformat/locale/ta.js |    6 +
 .../src/main/webapp/lib/messageformat/locale/te.js |    6 +
 .../src/main/webapp/lib/messageformat/locale/th.js |    3 +
 .../src/main/webapp/lib/messageformat/locale/tl.js |    6 +
 .../src/main/webapp/lib/messageformat/locale/tr.js |    3 +
 .../src/main/webapp/lib/messageformat/locale/uk.js |   14 +
 .../src/main/webapp/lib/messageformat/locale/ur.js |    6 +
 .../src/main/webapp/lib/messageformat/locale/vi.js |    3 +
 .../src/main/webapp/lib/messageformat/locale/zh.js |    3 +
 .../main/webapp/lib/messageformat/messageformat.js | 1593 ++++
 guacamole/src/main/webapp/license.txt              |   21 +
 guacamole/src/main/webapp/scripts/admin-ui.js      | 1472 ----
 guacamole/src/main/webapp/scripts/client-ui.js     | 1046 ---
 guacamole/src/main/webapp/scripts/guac-ui.js       | 1425 ---
 guacamole/src/main/webapp/scripts/history.js       |  175 -
 guacamole/src/main/webapp/scripts/lib/blob/blob.js |  178 -
 .../main/webapp/scripts/lib/filesaver/LICENSE.md   |   30 -
 guacamole/src/main/webapp/scripts/root-ui.js       |  516 --
 guacamole/src/main/webapp/scripts/service.js       | 1398 ---
 guacamole/src/main/webapp/scripts/session.js       |  107 -
 guacamole/src/main/webapp/styles/animation.css     |   35 -
 guacamole/src/main/webapp/styles/client.css        |  420 -
 guacamole/src/main/webapp/styles/keyboard.css      |  150 -
 guacamole/src/main/webapp/styles/login.css         |  350 -
 guacamole/src/main/webapp/styles/ui.css            |  611 --
 guacamole/src/main/webapp/translations/de.json     |  624 ++
 guacamole/src/main/webapp/translations/en.json     |  628 ++
 guacamole/src/main/webapp/translations/fr.json     |  582 ++
 guacamole/src/main/webapp/translations/it.json     |  577 ++
 guacamole/src/main/webapp/translations/nl.json     |  625 ++
 guacamole/src/main/webapp/translations/ru.json     |  550 ++
 pom.xml                                            |   10 +-
 project-assembly.xml                               |    1 -
 975 files changed, 108283 insertions(+), 34421 deletions(-)

diff --git a/CONTRIBUTING b/CONTRIBUTING
new file mode 100644
index 0000000..713a483
--- /dev/null
+++ b/CONTRIBUTING
@@ -0,0 +1,66 @@
+
+------------------------------------------------------------
+ Contributing to Guacamole
+------------------------------------------------------------
+
+Thank you for contributing to the Guacamole project!
+
+There are certain procedures that must be followed for all contributions. These
+procedures are necessary to allow us to allocate resources for reviewing and
+testing your contribution, as well as to ensure we have your legal
+authorization to include your contribution in Guacamole.
+
+1) Create an issue in our JIRA
+
+    All changes to Guacamole must have corresponding issues in JIRA so the
+    change can be properly tracked:
+
+        http://glyptodon.org/jira/
+
+    If you do not already have an account on our JIRA, you will need to create
+    one before creating your new issue.
+
+2) Make and test your changes locally
+
+    The Guacamole source is maintained in git repositories hosted on GitHub:
+
+        https://github.com/glyptodon/guacamole-client
+        https://github.com/glyptodon/guacamole-server
+
+    To make your changes, fork the applicable repositories and make commits
+    to a topic branch in your fork. Commits should be made in logical units
+    and must reference the JIRA issue number:
+
+    $ git commit -m "GUAC-123: Message describing the specific changes made."
+
+    Avoid commits which cover multiple, distinct goals that could (and should)
+    be handled separately.
+
+    If you do not already have an account on GitHub, you will need to create
+    one before making your changes.
+
+3) Sign our Contributor License Agreement (CLA)
+
+    All contributors to the Guacamole project must have signed CLAs on file
+    before we can merge their contributions:
+
+        http://glyptodon.org/cla.html
+
+    This is necessary to ensure we have the legal right to include your code
+    in our repositories, that we can continue to distribute that code under
+    the MIT license, and that you have the legal right to give us that code.
+
+    If you create a pull request without first signing the CLA, you will be
+    asked to do so before the pull request is reviewed.
+
+4) Submit your changes via a pull request on GitHub
+
+    Once your changes are ready, submit them by creating a pull request for
+    the corresponding topic branch you created when you began working on your
+    changes.
+
+    The Guacamole team will then review your changes and, if they pass review
+    and we have your CLA on file, your changes will be allocated to a sprint
+    for final testing and merge, and your name will be added to the list of
+    contributors for whichever repositories contain your contributions.
+
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..540cdcf
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,19 @@
+Copyright (C) 2013 Glyptodon LLC
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/README b/README
index 51f03d4..924105c 100644
--- a/README
+++ b/README
@@ -6,12 +6,12 @@
 This README is intended to provide quick and to-the-point documentation for
 technical users intending to compile parts of Guacamole themselves.
 
-Distribution-specific packages are available from the files section of the main
-project page:
+Source archives and pre-built .war files are available from the files section
+of the main project page:
  
     http://sourceforge.net/projects/guacamole/files/
 
-Distribution-specific documentation is provided on the Guacamole wiki:
+A full manual is provided on the Guacamole web site:
 
     http://guac-dev.org/
 
@@ -61,8 +61,8 @@ and deployed under servlet containers like Apache Tomcat or Jetty.
  Reporting problems
 ------------------------------------------------------------
 
-Please report any bugs encountered by opening a new ticket at the Trac system
+Please report any bugs encountered by opening a new issue in the JIRA system
 hosted at:
     
-    http://guac-dev.org/trac/
+    http://glyptodon.org/jira/
 
diff --git a/doc/guacamole-example/COPYING b/doc/guacamole-example/COPYING
deleted file mode 100644
index dba13ed..0000000
--- a/doc/guacamole-example/COPYING
+++ /dev/null
@@ -1,661 +0,0 @@
-                    GNU AFFERO GENERAL PUBLIC LICENSE
-                       Version 3, 19 November 2007
-
- Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
- Everyone is permitted to copy and distribute verbatim copies
- of this license document, but changing it is not allowed.
-
-                            Preamble
-
-  The GNU Affero General Public License is a free, copyleft license for
-software and other kinds of works, specifically designed to ensure
-cooperation with the community in the case of network server software.
-
-  The licenses for most software and other practical works are designed
-to take away your freedom to share and change the works.  By contrast,
-our General Public Licenses are intended to guarantee your freedom to
-share and change all versions of a program--to make sure it remains free
-software for all its users.
-
-  When we speak of free software, we are referring to freedom, not
-price.  Our General Public Licenses are designed to make sure that you
-have the freedom to distribute copies of free software (and charge for
-them if you wish), that you receive source code or can get it if you
-want it, that you can change the software or use pieces of it in new
-free programs, and that you know you can do these things.
-
-  Developers that use our General Public Licenses protect your rights
-with two steps: (1) assert copyright on the software, and (2) offer
-you this License which gives you legal permission to copy, distribute
-and/or modify the software.
-
-  A secondary benefit of defending all users' freedom is that
-improvements made in alternate versions of the program, if they
-receive widespread use, become available for other developers to
-incorporate.  Many developers of free software are heartened and
-encouraged by the resulting cooperation.  However, in the case of
-software used on network servers, this result may fail to come about.
-The GNU General Public License permits making a modified version and
-letting the public access it on a server without ever releasing its
-source code to the public.
-
-  The GNU Affero General Public License is designed specifically to
-ensure that, in such cases, the modified source code becomes available
-to the community.  It requires the operator of a network server to
-provide the source code of the modified version running there to the
-users of that server.  Therefore, public use of a modified version, on
-a publicly accessible server, gives the public access to the source
-code of the modified version.
-
-  An older license, called the Affero General Public License and
-published by Affero, was designed to accomplish similar goals.  This is
-a different license, not a version of the Affero GPL, but Affero has
-released a new version of the Affero GPL which permits relicensing under
-this license.
-
-  The precise terms and conditions for copying, distribution and
-modification follow.
-
-                       TERMS AND CONDITIONS
-
-  0. Definitions.
-
-  "This License" refers to version 3 of the GNU Affero General Public License.
-
-  "Copyright" also means copyright-like laws that apply to other kinds of
-works, such as semiconductor masks.
-
-  "The Program" refers to any copyrightable work licensed under this
-License.  Each licensee is addressed as "you".  "Licensees" and
-"recipients" may be individuals or organizations.
-
-  To "modify" a work means to copy from or adapt all or part of the work
-in a fashion requiring copyright permission, other than the making of an
-exact copy.  The resulting work is called a "modified version" of the
-earlier work or a work "based on" the earlier work.
-
-  A "covered work" means either the unmodified Program or a work based
-on the Program.
-
-  To "propagate" a work means to do anything with it that, without
-permission, would make you directly or secondarily liable for
-infringement under applicable copyright law, except executing it on a
-computer or modifying a private copy.  Propagation includes copying,
-distribution (with or without modification), making available to the
-public, and in some countries other activities as well.
-
-  To "convey" a work means any kind of propagation that enables other
-parties to make or receive copies.  Mere interaction with a user through
-a computer network, with no transfer of a copy, is not conveying.
-
-  An interactive user interface displays "Appropriate Legal Notices"
-to the extent that it includes a convenient and prominently visible
-feature that (1) displays an appropriate copyright notice, and (2)
-tells the user that there is no warranty for the work (except to the
-extent that warranties are provided), that licensees may convey the
-work under this License, and how to view a copy of this License.  If
-the interface presents a list of user commands or options, such as a
-menu, a prominent item in the list meets this criterion.
-
-  1. Source Code.
-
-  The "source code" for a work means the preferred form of the work
-for making modifications to it.  "Object code" means any non-source
-form of a work.
-
-  A "Standard Interface" means an interface that either is an official
-standard defined by a recognized standards body, or, in the case of
-interfaces specified for a particular programming language, one that
-is widely used among developers working in that language.
-
-  The "System Libraries" of an executable work include anything, other
-than the work as a whole, that (a) is included in the normal form of
-packaging a Major Component, but which is not part of that Major
-Component, and (b) serves only to enable use of the work with that
-Major Component, or to implement a Standard Interface for which an
-implementation is available to the public in source code form.  A
-"Major Component", in this context, means a major essential component
-(kernel, window system, and so on) of the specific operating system
-(if any) on which the executable work runs, or a compiler used to
-produce the work, or an object code interpreter used to run it.
-
-  The "Corresponding Source" for a work in object code form means all
-the source code needed to generate, install, and (for an executable
-work) run the object code and to modify the work, including scripts to
-control those activities.  However, it does not include the work's
-System Libraries, or general-purpose tools or generally available free
-programs which are used unmodified in performing those activities but
-which are not part of the work.  For example, Corresponding Source
-includes interface definition files associated with source files for
-the work, and the source code for shared libraries and dynamically
-linked subprograms that the work is specifically designed to require,
-such as by intimate data communication or control flow between those
-subprograms and other parts of the work.
-
-  The Corresponding Source need not include anything that users
-can regenerate automatically from other parts of the Corresponding
-Source.
-
-  The Corresponding Source for a work in source code form is that
-same work.
-
-  2. Basic Permissions.
-
-  All rights granted under this License are granted for the term of
-copyright on the Program, and are irrevocable provided the stated
-conditions are met.  This License explicitly affirms your unlimited
-permission to run the unmodified Program.  The output from running a
-covered work is covered by this License only if the output, given its
-content, constitutes a covered work.  This License acknowledges your
-rights of fair use or other equivalent, as provided by copyright law.
-
-  You may make, run and propagate covered works that you do not
-convey, without conditions so long as your license otherwise remains
-in force.  You may convey covered works to others for the sole purpose
-of having them make modifications exclusively for you, or provide you
-with facilities for running those works, provided that you comply with
-the terms of this License in conveying all material for which you do
-not control copyright.  Those thus making or running the covered works
-for you must do so exclusively on your behalf, under your direction
-and control, on terms that prohibit them from making any copies of
-your copyrighted material outside their relationship with you.
-
-  Conveying under any other circumstances is permitted solely under
-the conditions stated below.  Sublicensing is not allowed; section 10
-makes it unnecessary.
-
-  3. Protecting Users' Legal Rights From Anti-Circumvention Law.
-
-  No covered work shall be deemed part of an effective technological
-measure under any applicable law fulfilling obligations under article
-11 of the WIPO copyright treaty adopted on 20 December 1996, or
-similar laws prohibiting or restricting circumvention of such
-measures.
-
-  When you convey a covered work, you waive any legal power to forbid
-circumvention of technological measures to the extent such circumvention
-is effected by exercising rights under this License with respect to
-the covered work, and you disclaim any intention to limit operation or
-modification of the work as a means of enforcing, against the work's
-users, your or third parties' legal rights to forbid circumvention of
-technological measures.
-
-  4. Conveying Verbatim Copies.
-
-  You may convey verbatim copies of the Program's source code as you
-receive it, in any medium, provided that you conspicuously and
-appropriately publish on each copy an appropriate copyright notice;
-keep intact all notices stating that this License and any
-non-permissive terms added in accord with section 7 apply to the code;
-keep intact all notices of the absence of any warranty; and give all
-recipients a copy of this License along with the Program.
-
-  You may charge any price or no price for each copy that you convey,
-and you may offer support or warranty protection for a fee.
-
-  5. Conveying Modified Source Versions.
-
-  You may convey a work based on the Program, or the modifications to
-produce it from the Program, in the form of source code under the
-terms of section 4, provided that you also meet all of these conditions:
-
-    a) The work must carry prominent notices stating that you modified
-    it, and giving a relevant date.
-
-    b) The work must carry prominent notices stating that it is
-    released under this License and any conditions added under section
-    7.  This requirement modifies the requirement in section 4 to
-    "keep intact all notices".
-
-    c) You must license the entire work, as a whole, under this
-    License to anyone who comes into possession of a copy.  This
-    License will therefore apply, along with any applicable section 7
-    additional terms, to the whole of the work, and all its parts,
-    regardless of how they are packaged.  This License gives no
-    permission to license the work in any other way, but it does not
-    invalidate such permission if you have separately received it.
-
-    d) If the work has interactive user interfaces, each must display
-    Appropriate Legal Notices; however, if the Program has interactive
-    interfaces that do not display Appropriate Legal Notices, your
-    work need not make them do so.
-
-  A compilation of a covered work with other separate and independent
-works, which are not by their nature extensions of the covered work,
-and which are not combined with it such as to form a larger program,
-in or on a volume of a storage or distribution medium, is called an
-"aggregate" if the compilation and its resulting copyright are not
-used to limit the access or legal rights of the compilation's users
-beyond what the individual works permit.  Inclusion of a covered work
-in an aggregate does not cause this License to apply to the other
-parts of the aggregate.
-
-  6. Conveying Non-Source Forms.
-
-  You may convey a covered work in object code form under the terms
-of sections 4 and 5, provided that you also convey the
-machine-readable Corresponding Source under the terms of this License,
-in one of these ways:
-
-    a) Convey the object code in, or embodied in, a physical product
-    (including a physical distribution medium), accompanied by the
-    Corresponding Source fixed on a durable physical medium
-    customarily used for software interchange.
-
-    b) Convey the object code in, or embodied in, a physical product
-    (including a physical distribution medium), accompanied by a
-    written offer, valid for at least three years and valid for as
-    long as you offer spare parts or customer support for that product
-    model, to give anyone who possesses the object code either (1) a
-    copy of the Corresponding Source for all the software in the
-    product that is covered by this License, on a durable physical
-    medium customarily used for software interchange, for a price no
-    more than your reasonable cost of physically performing this
-    conveying of source, or (2) access to copy the
-    Corresponding Source from a network server at no charge.
-
-    c) Convey individual copies of the object code with a copy of the
-    written offer to provide the Corresponding Source.  This
-    alternative is allowed only occasionally and noncommercially, and
-    only if you received the object code with such an offer, in accord
-    with subsection 6b.
-
-    d) Convey the object code by offering access from a designated
-    place (gratis or for a charge), and offer equivalent access to the
-    Corresponding Source in the same way through the same place at no
-    further charge.  You need not require recipients to copy the
-    Corresponding Source along with the object code.  If the place to
-    copy the object code is a network server, the Corresponding Source
-    may be on a different server (operated by you or a third party)
-    that supports equivalent copying facilities, provided you maintain
-    clear directions next to the object code saying where to find the
-    Corresponding Source.  Regardless of what server hosts the
-    Corresponding Source, you remain obligated to ensure that it is
-    available for as long as needed to satisfy these requirements.
-
-    e) Convey the object code using peer-to-peer transmission, provided
-    you inform other peers where the object code and Corresponding
-    Source of the work are being offered to the general public at no
-    charge under subsection 6d.
-
-  A separable portion of the object code, whose source code is excluded
-from the Corresponding Source as a System Library, need not be
-included in conveying the object code work.
-
-  A "User Product" is either (1) a "consumer product", which means any
-tangible personal property which is normally used for personal, family,
-or household purposes, or (2) anything designed or sold for incorporation
-into a dwelling.  In determining whether a product is a consumer product,
-doubtful cases shall be resolved in favor of coverage.  For a particular
-product received by a particular user, "normally used" refers to a
-typical or common use of that class of product, regardless of the status
-of the particular user or of the way in which the particular user
-actually uses, or expects or is expected to use, the product.  A product
-is a consumer product regardless of whether the product has substantial
-commercial, industrial or non-consumer uses, unless such uses represent
-the only significant mode of use of the product.
-
-  "Installation Information" for a User Product means any methods,
-procedures, authorization keys, or other information required to install
-and execute modified versions of a covered work in that User Product from
-a modified version of its Corresponding Source.  The information must
-suffice to ensure that the continued functioning of the modified object
-code is in no case prevented or interfered with solely because
-modification has been made.
-
-  If you convey an object code work under this section in, or with, or
-specifically for use in, a User Product, and the conveying occurs as
-part of a transaction in which the right of possession and use of the
-User Product is transferred to the recipient in perpetuity or for a
-fixed term (regardless of how the transaction is characterized), the
-Corresponding Source conveyed under this section must be accompanied
-by the Installation Information.  But this requirement does not apply
-if neither you nor any third party retains the ability to install
-modified object code on the User Product (for example, the work has
-been installed in ROM).
-
-  The requirement to provide Installation Information does not include a
-requirement to continue to provide support service, warranty, or updates
-for a work that has been modified or installed by the recipient, or for
-the User Product in which it has been modified or installed.  Access to a
-network may be denied when the modification itself materially and
-adversely affects the operation of the network or violates the rules and
-protocols for communication across the network.
-
-  Corresponding Source conveyed, and Installation Information provided,
-in accord with this section must be in a format that is publicly
-documented (and with an implementation available to the public in
-source code form), and must require no special password or key for
-unpacking, reading or copying.
-
-  7. Additional Terms.
-
-  "Additional permissions" are terms that supplement the terms of this
-License by making exceptions from one or more of its conditions.
-Additional permissions that are applicable to the entire Program shall
-be treated as though they were included in this License, to the extent
-that they are valid under applicable law.  If additional permissions
-apply only to part of the Program, that part may be used separately
-under those permissions, but the entire Program remains governed by
-this License without regard to the additional permissions.
-
-  When you convey a copy of a covered work, you may at your option
-remove any additional permissions from that copy, or from any part of
-it.  (Additional permissions may be written to require their own
-removal in certain cases when you modify the work.)  You may place
-additional permissions on material, added by you to a covered work,
-for which you have or can give appropriate copyright permission.
-
-  Notwithstanding any other provision of this License, for material you
-add to a covered work, you may (if authorized by the copyright holders of
-that material) supplement the terms of this License with terms:
-
-    a) Disclaiming warranty or limiting liability differently from the
-    terms of sections 15 and 16 of this License; or
-
-    b) Requiring preservation of specified reasonable legal notices or
-    author attributions in that material or in the Appropriate Legal
-    Notices displayed by works containing it; or
-
-    c) Prohibiting misrepresentation of the origin of that material, or
-    requiring that modified versions of such material be marked in
-    reasonable ways as different from the original version; or
-
-    d) Limiting the use for publicity purposes of names of licensors or
-    authors of the material; or
-
-    e) Declining to grant rights under trademark law for use of some
-    trade names, trademarks, or service marks; or
-
-    f) Requiring indemnification of licensors and authors of that
-    material by anyone who conveys the material (or modified versions of
-    it) with contractual assumptions of liability to the recipient, for
-    any liability that these contractual assumptions directly impose on
-    those licensors and authors.
-
-  All other non-permissive additional terms are considered "further
-restrictions" within the meaning of section 10.  If the Program as you
-received it, or any part of it, contains a notice stating that it is
-governed by this License along with a term that is a further
-restriction, you may remove that term.  If a license document contains
-a further restriction but permits relicensing or conveying under this
-License, you may add to a covered work material governed by the terms
-of that license document, provided that the further restriction does
-not survive such relicensing or conveying.
-
-  If you add terms to a covered work in accord with this section, you
-must place, in the relevant source files, a statement of the
-additional terms that apply to those files, or a notice indicating
-where to find the applicable terms.
-
-  Additional terms, permissive or non-permissive, may be stated in the
-form of a separately written license, or stated as exceptions;
-the above requirements apply either way.
-
-  8. Termination.
-
-  You may not propagate or modify a covered work except as expressly
-provided under this License.  Any attempt otherwise to propagate or
-modify it is void, and will automatically terminate your rights under
-this License (including any patent licenses granted under the third
-paragraph of section 11).
-
-  However, if you cease all violation of this License, then your
-license from a particular copyright holder is reinstated (a)
-provisionally, unless and until the copyright holder explicitly and
-finally terminates your license, and (b) permanently, if the copyright
-holder fails to notify you of the violation by some reasonable means
-prior to 60 days after the cessation.
-
-  Moreover, your license from a particular copyright holder is
-reinstated permanently if the copyright holder notifies you of the
-violation by some reasonable means, this is the first time you have
-received notice of violation of this License (for any work) from that
-copyright holder, and you cure the violation prior to 30 days after
-your receipt of the notice.
-
-  Termination of your rights under this section does not terminate the
-licenses of parties who have received copies or rights from you under
-this License.  If your rights have been terminated and not permanently
-reinstated, you do not qualify to receive new licenses for the same
-material under section 10.
-
-  9. Acceptance Not Required for Having Copies.
-
-  You are not required to accept this License in order to receive or
-run a copy of the Program.  Ancillary propagation of a covered work
-occurring solely as a consequence of using peer-to-peer transmission
-to receive a copy likewise does not require acceptance.  However,
-nothing other than this License grants you permission to propagate or
-modify any covered work.  These actions infringe copyright if you do
-not accept this License.  Therefore, by modifying or propagating a
-covered work, you indicate your acceptance of this License to do so.
-
-  10. Automatic Licensing of Downstream Recipients.
-
-  Each time you convey a covered work, the recipient automatically
-receives a license from the original licensors, to run, modify and
-propagate that work, subject to this License.  You are not responsible
-for enforcing compliance by third parties with this License.
-
-  An "entity transaction" is a transaction transferring control of an
-organization, or substantially all assets of one, or subdividing an
-organization, or merging organizations.  If propagation of a covered
-work results from an entity transaction, each party to that
-transaction who receives a copy of the work also receives whatever
-licenses to the work the party's predecessor in interest had or could
-give under the previous paragraph, plus a right to possession of the
-Corresponding Source of the work from the predecessor in interest, if
-the predecessor has it or can get it with reasonable efforts.
-
-  You may not impose any further restrictions on the exercise of the
-rights granted or affirmed under this License.  For example, you may
-not impose a license fee, royalty, or other charge for exercise of
-rights granted under this License, and you may not initiate litigation
-(including a cross-claim or counterclaim in a lawsuit) alleging that
-any patent claim is infringed by making, using, selling, offering for
-sale, or importing the Program or any portion of it.
-
-  11. Patents.
-
-  A "contributor" is a copyright holder who authorizes use under this
-License of the Program or a work on which the Program is based.  The
-work thus licensed is called the contributor's "contributor version".
-
-  A contributor's "essential patent claims" are all patent claims
-owned or controlled by the contributor, whether already acquired or
-hereafter acquired, that would be infringed by some manner, permitted
-by this License, of making, using, or selling its contributor version,
-but do not include claims that would be infringed only as a
-consequence of further modification of the contributor version.  For
-purposes of this definition, "control" includes the right to grant
-patent sublicenses in a manner consistent with the requirements of
-this License.
-
-  Each contributor grants you a non-exclusive, worldwide, royalty-free
-patent license under the contributor's essential patent claims, to
-make, use, sell, offer for sale, import and otherwise run, modify and
-propagate the contents of its contributor version.
-
-  In the following three paragraphs, a "patent license" is any express
-agreement or commitment, however denominated, not to enforce a patent
-(such as an express permission to practice a patent or covenant not to
-sue for patent infringement).  To "grant" such a patent license to a
-party means to make such an agreement or commitment not to enforce a
-patent against the party.
-
-  If you convey a covered work, knowingly relying on a patent license,
-and the Corresponding Source of the work is not available for anyone
-to copy, free of charge and under the terms of this License, through a
-publicly available network server or other readily accessible means,
-then you must either (1) cause the Corresponding Source to be so
-available, or (2) arrange to deprive yourself of the benefit of the
-patent license for this particular work, or (3) arrange, in a manner
-consistent with the requirements of this License, to extend the patent
-license to downstream recipients.  "Knowingly relying" means you have
-actual knowledge that, but for the patent license, your conveying the
-covered work in a country, or your recipient's use of the covered work
-in a country, would infringe one or more identifiable patents in that
-country that you have reason to believe are valid.
-
-  If, pursuant to or in connection with a single transaction or
-arrangement, you convey, or propagate by procuring conveyance of, a
-covered work, and grant a patent license to some of the parties
-receiving the covered work authorizing them to use, propagate, modify
-or convey a specific copy of the covered work, then the patent license
-you grant is automatically extended to all recipients of the covered
-work and works based on it.
-
-  A patent license is "discriminatory" if it does not include within
-the scope of its coverage, prohibits the exercise of, or is
-conditioned on the non-exercise of one or more of the rights that are
-specifically granted under this License.  You may not convey a covered
-work if you are a party to an arrangement with a third party that is
-in the business of distributing software, under which you make payment
-to the third party based on the extent of your activity of conveying
-the work, and under which the third party grants, to any of the
-parties who would receive the covered work from you, a discriminatory
-patent license (a) in connection with copies of the covered work
-conveyed by you (or copies made from those copies), or (b) primarily
-for and in connection with specific products or compilations that
-contain the covered work, unless you entered into that arrangement,
-or that patent license was granted, prior to 28 March 2007.
-
-  Nothing in this License shall be construed as excluding or limiting
-any implied license or other defenses to infringement that may
-otherwise be available to you under applicable patent law.
-
-  12. No Surrender of Others' Freedom.
-
-  If conditions are imposed on you (whether by court order, agreement or
-otherwise) that contradict the conditions of this License, they do not
-excuse you from the conditions of this License.  If you cannot convey a
-covered work so as to satisfy simultaneously your obligations under this
-License and any other pertinent obligations, then as a consequence you may
-not convey it at all.  For example, if you agree to terms that obligate you
-to collect a royalty for further conveying from those to whom you convey
-the Program, the only way you could satisfy both those terms and this
-License would be to refrain entirely from conveying the Program.
-
-  13. Remote Network Interaction; Use with the GNU General Public License.
-
-  Notwithstanding any other provision of this License, if you modify the
-Program, your modified version must prominently offer all users
-interacting with it remotely through a computer network (if your version
-supports such interaction) an opportunity to receive the Corresponding
-Source of your version by providing access to the Corresponding Source
-from a network server at no charge, through some standard or customary
-means of facilitating copying of software.  This Corresponding Source
-shall include the Corresponding Source for any work covered by version 3
-of the GNU General Public License that is incorporated pursuant to the
-following paragraph.
-
-  Notwithstanding any other provision of this License, you have
-permission to link or combine any covered work with a work licensed
-under version 3 of the GNU General Public License into a single
-combined work, and to convey the resulting work.  The terms of this
-License will continue to apply to the part which is the covered work,
-but the work with which it is combined will remain governed by version
-3 of the GNU General Public License.
-
-  14. Revised Versions of this License.
-
-  The Free Software Foundation may publish revised and/or new versions of
-the GNU Affero General Public License from time to time.  Such new versions
-will be similar in spirit to the present version, but may differ in detail to
-address new problems or concerns.
-
-  Each version is given a distinguishing version number.  If the
-Program specifies that a certain numbered version of the GNU Affero General
-Public License "or any later version" applies to it, you have the
-option of following the terms and conditions either of that numbered
-version or of any later version published by the Free Software
-Foundation.  If the Program does not specify a version number of the
-GNU Affero General Public License, you may choose any version ever published
-by the Free Software Foundation.
-
-  If the Program specifies that a proxy can decide which future
-versions of the GNU Affero General Public License can be used, that proxy's
-public statement of acceptance of a version permanently authorizes you
-to choose that version for the Program.
-
-  Later license versions may give you additional or different
-permissions.  However, no additional obligations are imposed on any
-author or copyright holder as a result of your choosing to follow a
-later version.
-
-  15. Disclaimer of Warranty.
-
-  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
-APPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
-HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
-OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
-THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
-PURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
-IS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
-ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
-
-  16. Limitation of Liability.
-
-  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
-WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
-THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
-GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
-USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
-DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
-PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
-EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
-SUCH DAMAGES.
-
-  17. Interpretation of Sections 15 and 16.
-
-  If the disclaimer of warranty and limitation of liability provided
-above cannot be given local legal effect according to their terms,
-reviewing courts shall apply local law that most closely approximates
-an absolute waiver of all civil liability in connection with the
-Program, unless a warranty or assumption of liability accompanies a
-copy of the Program in return for a fee.
-
-                     END OF TERMS AND CONDITIONS
-
-            How to Apply These Terms to Your New Programs
-
-  If you develop a new program, and you want it to be of the greatest
-possible use to the public, the best way to achieve this is to make it
-free software which everyone can redistribute and change under these terms.
-
-  To do so, attach the following notices to the program.  It is safest
-to attach them to the start of each source file to most effectively
-state the exclusion of warranty; and each file should have at least
-the "copyright" line and a pointer to where the full notice is found.
-
-    <one line to give the program's name and a brief idea of what it does.>
-    Copyright (C) <year>  <name of author>
-
-    This program is free software: you can redistribute it and/or modify
-    it under the terms of the GNU Affero General Public License as published by
-    the Free Software Foundation, either version 3 of the License, or
-    (at your option) any later version.
-
-    This program is distributed in the hope that it will be useful,
-    but WITHOUT ANY WARRANTY; without even the implied warranty of
-    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-    GNU Affero General Public License for more details.
-
-    You should have received a copy of the GNU Affero General Public License
-    along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-Also add information on how to contact you by electronic and paper mail.
-
-  If your software can interact with users remotely through a computer
-network, you should also make sure that it provides a way for users to
-get its source.  For example, if your program is a web application, its
-interface could display a "Source" link that leads users to an archive
-of the code.  There are many ways you could offer source, and different
-solutions will be better for different programs; see section 13 for the
-specific requirements.
-
-  You should also get your employer (if you work as a programmer) or school,
-if any, to sign a "copyright disclaimer" for the program, if necessary.
-For more information on this, and how to apply and follow the GNU AGPL, see
-<http://www.gnu.org/licenses/>.
diff --git a/doc/guacamole-example/LICENSE b/doc/guacamole-example/LICENSE
new file mode 100644
index 0000000..540cdcf
--- /dev/null
+++ b/doc/guacamole-example/LICENSE
@@ -0,0 +1,19 @@
+Copyright (C) 2013 Glyptodon LLC
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/doc/guacamole-example/pom.xml b/doc/guacamole-example/pom.xml
index 53cefdf..71d9d41 100644
--- a/doc/guacamole-example/pom.xml
+++ b/doc/guacamole-example/pom.xml
@@ -5,7 +5,7 @@
     <groupId>org.glyptodon.guacamole</groupId>
     <artifactId>guacamole-example</artifactId>
     <packaging>war</packaging>
-    <version>0.8.0</version>
+    <version>0.9.9</version>
     <name>guacamole-example</name>
     <url>http://guac-dev.org/</url>
 
@@ -20,9 +20,15 @@
             <plugin>
                 <groupId>org.apache.maven.plugins</groupId>
                 <artifactId>maven-compiler-plugin</artifactId>
+                <version>3.3</version>
                 <configuration>
                     <source>1.6</source>
                     <target>1.6</target>
+                    <compilerArgs>
+                        <arg>-Xlint:all</arg>
+                        <arg>-Werror</arg>
+                    </compilerArgs>
+                    <fork>true</fork>
                 </configuration>
             </plugin>
 
@@ -30,6 +36,7 @@
             <plugin>
                 <groupId>org.apache.maven.plugins</groupId>
                 <artifactId>maven-war-plugin</artifactId>
+                <version>2.6</version>
                 <configuration>
                     <overlays>
                         <overlay>
@@ -59,7 +66,7 @@
         <dependency>
             <groupId>org.glyptodon.guacamole</groupId>
             <artifactId>guacamole-common</artifactId>
-            <version>0.8.0</version>
+            <version>0.9.9</version>
             <scope>compile</scope>
         </dependency>
 
@@ -67,11 +74,18 @@
         <dependency>
             <groupId>org.glyptodon.guacamole</groupId>
             <artifactId>guacamole-common-js</artifactId>
-            <version>0.7.4</version>
+            <version>0.9.9</version>
             <type>zip</type>
             <scope>runtime</scope>
         </dependency>
 
+	<dependency>
+		<groupId>org.slf4j</groupId>
+		<artifactId>slf4j-simple</artifactId>
+		<version>1.7.7</version>
+	</dependency>
+
+
     </dependencies>
 
 </project>
diff --git a/doc/guacamole-example/src/main/java/org/glyptodon/guacamole/net/example/DummyGuacamoleTunnelServlet.java b/doc/guacamole-example/src/main/java/org/glyptodon/guacamole/net/example/DummyGuacamoleTunnelServlet.java
index 8f2a1e4..58479ac 100644
--- a/doc/guacamole-example/src/main/java/org/glyptodon/guacamole/net/example/DummyGuacamoleTunnelServlet.java
+++ b/doc/guacamole-example/src/main/java/org/glyptodon/guacamole/net/example/DummyGuacamoleTunnelServlet.java
@@ -1,42 +1,47 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
 
 package org.glyptodon.guacamole.net.example;
 
 import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpSession;
 import org.glyptodon.guacamole.GuacamoleException;
 import org.glyptodon.guacamole.net.GuacamoleSocket;
 import org.glyptodon.guacamole.net.GuacamoleTunnel;
 import org.glyptodon.guacamole.net.InetGuacamoleSocket;
+import org.glyptodon.guacamole.net.SimpleGuacamoleTunnel;
 import org.glyptodon.guacamole.protocol.ConfiguredGuacamoleSocket;
 import org.glyptodon.guacamole.protocol.GuacamoleConfiguration;
 import org.glyptodon.guacamole.servlet.GuacamoleHTTPTunnelServlet;
-import org.glyptodon.guacamole.servlet.GuacamoleSession;
 
-/*
- *  Guacamole - Clientless Remote Desktop
- *  Copyright (C) 2010  Michael Jumper
+/**
+ * Simple tunnel example with hard-coded configuration parameters.
  *
- *  This program is free software: you can redistribute it and/or modify
- *  it under the terms of the GNU Affero General Public License as published by
- *  the Free Software Foundation, either version 3 of the License, or
- *  (at your option) any later version.
- *
- *  This program is distributed in the hope that it will be useful,
- *  but WITHOUT ANY WARRANTY; without even the implied warranty of
- *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- *  GNU Affero General Public License for more details.
- *
- *  You should have received a copy of the GNU Affero General Public License
- *  along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ * @author Michael Jumper
  */
-
 public class DummyGuacamoleTunnelServlet extends GuacamoleHTTPTunnelServlet {
 
     @Override
     protected GuacamoleTunnel doConnect(HttpServletRequest request) throws GuacamoleException {
 
-        HttpSession httpSession = request.getSession(true);
-
         // guacd connection information
         String hostname = "localhost";
         int port = 4822;
@@ -55,12 +60,7 @@ public class DummyGuacamoleTunnelServlet extends GuacamoleHTTPTunnelServlet {
         );
 
         // Create tunnel from now-configured socket
-        GuacamoleTunnel tunnel = new GuacamoleTunnel(socket);
-
-        // Attach tunnel
-        GuacamoleSession session = new GuacamoleSession(httpSession);
-        session.attachTunnel(tunnel);
-
+        GuacamoleTunnel tunnel = new SimpleGuacamoleTunnel(socket);
         return tunnel;
 
     }
diff --git a/doc/guacamole-example/src/main/webapp/WEB-INF/web.xml b/doc/guacamole-example/src/main/webapp/WEB-INF/web.xml
index 77d4324..3cece45 100644
--- a/doc/guacamole-example/src/main/webapp/WEB-INF/web.xml
+++ b/doc/guacamole-example/src/main/webapp/WEB-INF/web.xml
@@ -1,22 +1,28 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <!--
-    Guacamole - Clientless Remote Desktop
-    Copyright (C) 2010  Michael Jumper
+   Copyright (C) 2015 Glyptodon LLC
 
-    This program is free software: you can redistribute it and/or modify
-    it under the terms of the GNU Affero General Public License as published by
-    the Free Software Foundation, either version 3 of the License, or
-    (at your option) any later version.
+   Permission is hereby granted, free of charge, to any person obtaining a copy
+   of this software and associated documentation files (the "Software"), to deal
+   in the Software without restriction, including without limitation the rights
+   to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+   copies of the Software, and to permit persons to whom the Software is
+   furnished to do so, subject to the following conditions:
 
-    This program is distributed in the hope that it will be useful,
-    but WITHOUT ANY WARRANTY; without even the implied warranty of
-    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-    GNU Affero General Public License for more details.
+   The above copyright notice and this permission notice shall be included in
+   all copies or substantial portions of the Software.
 
-    You should have received a copy of the GNU Affero General Public License
-    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+   THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+   IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+   FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+   AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+   LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+   OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+   THE SOFTWARE.
 -->
-<web-app version="2.5" xmlns="http://java.sun.com/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_2_5.xsd">
+<web-app version="2.5" xmlns="http://java.sun.com/xml/ns/javaee"
+    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+    xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_2_5.xsd">
 
     <!-- Basic config -->
     <welcome-file-list>
diff --git a/doc/guacamole-example/src/main/webapp/guacamole.css b/doc/guacamole-example/src/main/webapp/guacamole.css
index 3be9d96..25bb4bf 100644
--- a/doc/guacamole-example/src/main/webapp/guacamole.css
+++ b/doc/guacamole-example/src/main/webapp/guacamole.css
@@ -1,20 +1,23 @@
-
 /*
- *  Guacamole - Clientless Remote Desktop
- *  Copyright (C) 2010  Michael Jumper
+ * Copyright (C) 2015 Glyptodon LLC
  *
- *  This program is free software: you can redistribute it and/or modify
- *  it under the terms of the GNU Affero General Public License as published by
- *  the Free Software Foundation, either version 3 of the License, or
- *  (at your option) any later version.
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
  *
- *  This program is distributed in the hope that it will be useful,
- *  but WITHOUT ANY WARRANTY; without even the implied warranty of
- *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- *  GNU Affero General Public License for more details.
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
  *
- *  You should have received a copy of the GNU Affero General Public License
- *  along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
  */
 
 .guac-hide-cursor {
diff --git a/doc/guacamole-example/src/main/webapp/index.html b/doc/guacamole-example/src/main/webapp/index.html
index 027de3e..901ec42 100644
--- a/doc/guacamole-example/src/main/webapp/index.html
+++ b/doc/guacamole-example/src/main/webapp/index.html
@@ -1,21 +1,24 @@
 <!DOCTYPE HTML>
-
 <!--
-    Guacamole - Clientless Remote Desktop
-    Copyright (C) 2010  Michael Jumper
-
-    This program is free software: you can redistribute it and/or modify
-    it under the terms of the GNU Affero General Public License as published by
-    the Free Software Foundation, either version 3 of the License, or
-    (at your option) any later version.
-
-    This program is distributed in the hope that it will be useful,
-    but WITHOUT ANY WARRANTY; without even the implied warranty of
-    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-    GNU Affero General Public License for more details.
-
-    You should have received a copy of the GNU Affero General Public License
-    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+   Copyright (C) 2015 Glyptodon LLC
+
+   Permission is hereby granted, free of charge, to any person obtaining a copy
+   of this software and associated documentation files (the "Software"), to deal
+   in the Software without restriction, including without limitation the rights
+   to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+   copies of the Software, and to permit persons to whom the Software is
+   furnished to do so, subject to the following conditions:
+
+   The above copyright notice and this permission notice shall be included in
+   all copies or substantial portions of the Software.
+
+   THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+   IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+   FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+   AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+   LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+   OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+   THE SOFTWARE.
 -->
 
 <html>
@@ -30,19 +33,9 @@
         <!-- Display -->
         <div id="display"></div>
 
-        <!-- Input abstractions -->
-        <script type="text/javascript"
-            src="guacamole-common-js/keyboard.js"></script>
-        <script type="text/javascript"
-            src="guacamole-common-js/mouse.js"></script>
-
-        <!-- Client core scripts -->
-        <script type="text/javascript"
-            src="guacamole-common-js/layer.js"></script>
-        <script type="text/javascript"
-            src="guacamole-common-js/tunnel.js"></script>
+        <!-- Guacamole JavaScript API -->
         <script type="text/javascript"
-            src="guacamole-common-js/guacamole.js"></script>
+            src="guacamole-common-js/all.min.js"></script>
 
         <!-- Init -->
         <script type="text/javascript"> /* <![CDATA[ */
@@ -56,7 +49,7 @@
             );
 
             // Add client to display div
-            display.appendChild(guac.getDisplay());
+            display.appendChild(guac.getDisplay().getElement());
             
             // Error handler
             guac.onerror = function(error) {
@@ -72,7 +65,7 @@
             }
 
             // Mouse
-            var mouse = new Guacamole.Mouse(guac.getDisplay());
+            var mouse = new Guacamole.Mouse(guac.getDisplay().getElement());
 
             mouse.onmousedown = 
             mouse.onmouseup   =
diff --git a/extensions/guacamole-auth-jdbc/LICENSE b/extensions/guacamole-auth-jdbc/LICENSE
new file mode 100644
index 0000000..540cdcf
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/LICENSE
@@ -0,0 +1,19 @@
+Copyright (C) 2013 Glyptodon LLC
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/extensions/guacamole-auth-jdbc/README b/extensions/guacamole-auth-jdbc/README
new file mode 100644
index 0000000..0fee7bc
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/README
@@ -0,0 +1,113 @@
+
+------------------------------------------------------------
+ About this README
+------------------------------------------------------------
+
+This README is intended to provide quick and to-the-point documentation for
+technical users intending to compile parts of Guacamole themselves.
+
+Distribution-specific packages are available from the files section of the main
+project page:
+ 
+    http://sourceforge.net/projects/guacamole/files/
+
+Distribution-specific documentation is provided on the Guacamole wiki:
+
+    http://guac-dev.org/
+
+
+------------------------------------------------------------
+ What is guacamole-auth-jdbc?
+------------------------------------------------------------
+
+guacamole-auth-jdbc is a Java library for use with the Guacamole web
+application to provide database-driven authentication.
+
+guacamole-auth-jdbc provides multiple authentication provider implementations
+which each provide a support for a different database. These authentication
+providers can be set in guacamole.properties to allow authentication of
+Guacamole users through that type of database.
+
+Schema files are provided to create the required tables in your database of
+choice.
+
+
+------------------------------------------------------------
+ Compiling and installing guacamole-auth-jdbc
+------------------------------------------------------------
+
+guacamole-auth-jdbc is built using Maven. Building guacamole-auth-jdbc compiles
+all classes and packages them into a redistributable .tar.gz archive.  This
+archive contains multiple .jar files, each of this corresponds to a
+database-specific authentication provider implementation that can be installed
+in the library directory configured in guacamole.properties.
+
+1) Run mvn package
+
+    $ mvn package
+
+    Maven will download any needed dependencies for building the .jar file.
+    Once all dependencies have been downloaded, the .jar file will be
+    created in the target/ subdirectory of the current directory.
+
+4) Extract the .tar.gz file now present in the target/ directory, and
+   place the .jar files from the extracted database-specific subdirectory in
+   the library directory specified in guacamole.properties.
+
+    You will likely need to do this as root.
+
+    If you do not have a library directory configured in your
+    guacamole.properties, you will need to specify one. The directory
+    is specified using the "lib-directory" property.
+
+5) Set up your database to authenticate Guacamole users
+
+    A schema file is provided in the schema directory for creating
+    the guacamole authentication tables in your database of choice.
+
+    Additionally, a script is provided to create a default admin user
+    with username 'guacadmin' and password 'guacadmin'. This user can 
+    be used to set up any other connections and users.
+
+6) Configure guacamole.properties for your database
+
+    There are additional properties required by JDBC drivers which must
+    be added/changed in your guacamole.properties. These parameters are
+    specific to the database being used.
+
+    For MySQL, the following properties are available:
+
+    # Database connection configuration
+    mysql-hostname:           database.host.name
+    mysql-port:               3306
+    mysql-database:           guacamole.database.name
+    mysql-username:           user
+    mysql-password:           pass
+
+    Optionally, the authentication provider can be configured
+    not to allow multiple users to use the same connection
+    at the same time:
+
+    mysql-disallow-simultaneous-connections: true
+
+    For PostgreSQL, the properties are the same, but have different prefixes:
+
+    # Database connection configuration
+    postgresql-hostname:      database.host.name
+    postgresql-port:          5432
+    postgresql-database:      guacamole.database.name
+    postgresql-username:      user
+    postgresql-password:      pass
+
+    postgresql-disallow-simultaneous-connections: true
+
+
+------------------------------------------------------------
+ Reporting problems
+------------------------------------------------------------
+
+Please report any bugs encountered by opening a new issue in the JIRA system
+hosted at:
+    
+    http://glyptodon.org/jira/
+
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/pom.xml b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/pom.xml
new file mode 100644
index 0000000..8493790
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/pom.xml
@@ -0,0 +1,103 @@
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
+
+    <modelVersion>4.0.0</modelVersion>
+    <groupId>org.glyptodon.guacamole</groupId>
+    <artifactId>guacamole-auth-jdbc-base</artifactId>
+    <packaging>jar</packaging>
+    <name>guacamole-auth-jdbc-base</name>
+    <url>http://guac-dev.org/</url>
+
+    <properties>
+        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+    </properties>
+
+    <parent>
+        <groupId>org.glyptodon.guacamole</groupId>
+        <artifactId>guacamole-auth-jdbc</artifactId>
+        <version>0.9.9</version>
+        <relativePath>../../</relativePath>
+    </parent>
+
+    <build>
+        <plugins>
+
+            <!-- Written for 1.6 -->
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-compiler-plugin</artifactId>
+                <version>3.3</version>
+                <configuration>
+                    <source>1.6</source>
+                    <target>1.6</target>
+                    <compilerArgs>
+                        <arg>-Xlint:all</arg>
+                        <arg>-Werror</arg>
+                    </compilerArgs>
+                    <fork>true</fork>
+                </configuration>
+            </plugin>
+
+        </plugins>
+    </build>
+
+    <dependencies>
+
+        <!-- Java servlet API -->
+        <dependency>
+            <groupId>javax.servlet</groupId>
+            <artifactId>servlet-api</artifactId>
+            <version>2.5</version>
+            <scope>provided</scope>
+        </dependency>
+
+        <!-- Guacamole Extension API -->
+        <dependency>
+            <groupId>org.glyptodon.guacamole</groupId>
+            <artifactId>guacamole-ext</artifactId>
+            <scope>provided</scope>
+        </dependency>
+
+        <!-- SLF4J - logging -->
+        <dependency>
+            <groupId>org.slf4j</groupId>
+            <artifactId>slf4j-api</artifactId>
+            <version>1.7.7</version>
+        </dependency>
+
+        <!-- MyBatis -->
+        <dependency>
+            <groupId>org.mybatis</groupId>
+            <artifactId>mybatis</artifactId>
+            <version>3.2.8</version>
+        </dependency>
+        
+        <!-- MyBatis Guice -->
+        <dependency>
+            <groupId>org.mybatis</groupId>
+            <artifactId>mybatis-guice</artifactId>
+            <version>3.6</version>
+        </dependency>
+
+        <!-- Guice -->
+        <dependency>
+            <groupId>com.google.inject</groupId>
+            <artifactId>guice</artifactId>
+            <version>3.0</version>
+        </dependency>
+        <dependency>
+            <groupId>com.google.inject.extensions</groupId>
+            <artifactId>guice-multibindings</artifactId>
+            <version>3.0</version>
+        </dependency>
+
+        <!-- Guava - Utility Library -->
+        <dependency>
+            <groupId>com.google.guava</groupId>
+            <artifactId>guava</artifactId>
+            <version>18.0</version>
+        </dependency>
+
+    </dependencies>
+
+</project>
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/JDBCAuthenticationProviderModule.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/JDBCAuthenticationProviderModule.java
new file mode 100644
index 0000000..7a570e0
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/JDBCAuthenticationProviderModule.java
@@ -0,0 +1,165 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.jdbc;
+
+import org.glyptodon.guacamole.auth.jdbc.user.UserContext;
+import org.glyptodon.guacamole.auth.jdbc.connectiongroup.RootConnectionGroup;
+import org.glyptodon.guacamole.auth.jdbc.connectiongroup.ModeledConnectionGroup;
+import org.glyptodon.guacamole.auth.jdbc.connectiongroup.ConnectionGroupDirectory;
+import org.glyptodon.guacamole.auth.jdbc.connection.ConnectionDirectory;
+import org.glyptodon.guacamole.auth.jdbc.connection.ModeledGuacamoleConfiguration;
+import org.glyptodon.guacamole.auth.jdbc.connection.ModeledConnection;
+import org.glyptodon.guacamole.auth.jdbc.permission.SystemPermissionSet;
+import org.glyptodon.guacamole.auth.jdbc.user.ModeledUser;
+import org.glyptodon.guacamole.auth.jdbc.user.UserDirectory;
+import org.glyptodon.guacamole.auth.jdbc.connectiongroup.ConnectionGroupMapper;
+import org.glyptodon.guacamole.auth.jdbc.connection.ConnectionMapper;
+import org.glyptodon.guacamole.auth.jdbc.connection.ConnectionRecordMapper;
+import org.glyptodon.guacamole.auth.jdbc.connection.ParameterMapper;
+import org.glyptodon.guacamole.auth.jdbc.permission.SystemPermissionMapper;
+import org.glyptodon.guacamole.auth.jdbc.user.UserMapper;
+import org.glyptodon.guacamole.auth.jdbc.connectiongroup.ConnectionGroupService;
+import org.glyptodon.guacamole.auth.jdbc.connection.ConnectionService;
+import org.glyptodon.guacamole.auth.jdbc.tunnel.GuacamoleTunnelService;
+import org.glyptodon.guacamole.auth.jdbc.security.PasswordEncryptionService;
+import org.glyptodon.guacamole.auth.jdbc.security.SHA256PasswordEncryptionService;
+import org.glyptodon.guacamole.auth.jdbc.security.SaltService;
+import org.glyptodon.guacamole.auth.jdbc.security.SecureRandomSaltService;
+import org.glyptodon.guacamole.auth.jdbc.permission.SystemPermissionService;
+import org.glyptodon.guacamole.auth.jdbc.user.UserService;
+import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
+import org.glyptodon.guacamole.auth.jdbc.permission.ConnectionGroupPermissionMapper;
+import org.glyptodon.guacamole.auth.jdbc.permission.ConnectionGroupPermissionService;
+import org.glyptodon.guacamole.auth.jdbc.permission.ConnectionGroupPermissionSet;
+import org.glyptodon.guacamole.auth.jdbc.permission.ConnectionPermissionMapper;
+import org.glyptodon.guacamole.auth.jdbc.permission.ConnectionPermissionService;
+import org.glyptodon.guacamole.auth.jdbc.permission.ConnectionPermissionSet;
+import org.glyptodon.guacamole.auth.jdbc.permission.UserPermissionMapper;
+import org.glyptodon.guacamole.auth.jdbc.permission.UserPermissionService;
+import org.glyptodon.guacamole.auth.jdbc.permission.UserPermissionSet;
+import org.glyptodon.guacamole.auth.jdbc.activeconnection.ActiveConnectionDirectory;
+import org.glyptodon.guacamole.auth.jdbc.activeconnection.ActiveConnectionPermissionService;
+import org.glyptodon.guacamole.auth.jdbc.activeconnection.ActiveConnectionPermissionSet;
+import org.glyptodon.guacamole.auth.jdbc.activeconnection.ActiveConnectionService;
+import org.glyptodon.guacamole.auth.jdbc.activeconnection.TrackedActiveConnection;
+import org.glyptodon.guacamole.auth.jdbc.tunnel.RestrictedGuacamoleTunnelService;
+import org.glyptodon.guacamole.net.auth.AuthenticationProvider;
+import org.mybatis.guice.MyBatisModule;
+import org.mybatis.guice.datasource.builtin.PooledDataSourceProvider;
+
+/**
+ * Guice module which configures the injections used by the JDBC authentication
+ * provider base. This module MUST be included in the Guice injector, or
+ * authentication providers based on JDBC will not function.
+ *
+ * @author Michael Jumper
+ * @author James Muehlner
+ */
+public class JDBCAuthenticationProviderModule extends MyBatisModule {
+
+    /**
+     * The environment of the Guacamole server.
+     */
+    private final JDBCEnvironment environment;
+
+    /**
+     * The AuthenticationProvider which is using this module to configure
+     * injection.
+     */
+    private final AuthenticationProvider authProvider;
+
+    /**
+     * Creates a new JDBC authentication provider module that configures the
+     * various injected base classes using the given environment, and provides
+     * connections using the given socket service.
+     *
+     * @param authProvider
+     *     The AuthenticationProvider which is using this module to configure
+     *     injection.
+     *
+     * @param environment
+     *     The environment to use to configure injected classes.
+     */
+    public JDBCAuthenticationProviderModule(AuthenticationProvider authProvider,
+            JDBCEnvironment environment) {
+        this.authProvider = authProvider;
+        this.environment = environment;
+    }
+
+    @Override
+    protected void initialize() {
+        
+        // Datasource
+        bindDataSourceProviderType(PooledDataSourceProvider.class);
+        
+        // Transaction factory
+        bindTransactionFactoryType(JdbcTransactionFactory.class);
+        
+        // Add MyBatis mappers
+        addMapperClass(ConnectionMapper.class);
+        addMapperClass(ConnectionGroupMapper.class);
+        addMapperClass(ConnectionGroupPermissionMapper.class);
+        addMapperClass(ConnectionPermissionMapper.class);
+        addMapperClass(ConnectionRecordMapper.class);
+        addMapperClass(ParameterMapper.class);
+        addMapperClass(SystemPermissionMapper.class);
+        addMapperClass(UserMapper.class);
+        addMapperClass(UserPermissionMapper.class);
+        
+        // Bind core implementations of guacamole-ext classes
+        bind(ActiveConnectionDirectory.class);
+        bind(ActiveConnectionPermissionSet.class);
+        bind(AuthenticationProvider.class).toInstance(authProvider);
+        bind(JDBCEnvironment.class).toInstance(environment);
+        bind(ConnectionDirectory.class);
+        bind(ConnectionGroupDirectory.class);
+        bind(ConnectionGroupPermissionSet.class);
+        bind(ConnectionPermissionSet.class);
+        bind(ModeledConnection.class);
+        bind(ModeledConnectionGroup.class);
+        bind(ModeledGuacamoleConfiguration.class);
+        bind(ModeledUser.class);
+        bind(RootConnectionGroup.class);
+        bind(SystemPermissionSet.class);
+        bind(TrackedActiveConnection.class);
+        bind(UserContext.class);
+        bind(UserDirectory.class);
+        bind(UserPermissionSet.class);
+        
+        // Bind services
+        bind(ActiveConnectionService.class);
+        bind(ActiveConnectionPermissionService.class);
+        bind(ConnectionGroupPermissionService.class);
+        bind(ConnectionGroupService.class);
+        bind(ConnectionPermissionService.class);
+        bind(ConnectionService.class);
+        bind(GuacamoleTunnelService.class).to(RestrictedGuacamoleTunnelService.class);
+        bind(PasswordEncryptionService.class).to(SHA256PasswordEncryptionService.class);
+        bind(SaltService.class).to(SecureRandomSaltService.class);
+        bind(SystemPermissionService.class);
+        bind(UserPermissionService.class);
+        bind(UserService.class);
+        
+    }
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/JDBCEnvironment.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/JDBCEnvironment.java
new file mode 100644
index 0000000..f7a3a6f
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/JDBCEnvironment.java
@@ -0,0 +1,106 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.jdbc;
+
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.environment.LocalEnvironment;
+
+/**
+ * A JDBC-specific implementation of Environment that defines generic properties
+ * intended for use within JDBC based authentication providers.
+ *
+ * @author James Muehlner
+ */
+public abstract class JDBCEnvironment extends LocalEnvironment {
+    
+    /**
+     * Constructs a new JDBCEnvironment using an underlying LocalEnviroment to
+     * read properties from the file system.
+     * 
+     * @throws GuacamoleException
+     *     If an error occurs while setting up the underlying LocalEnvironment.
+     */
+    public JDBCEnvironment() throws GuacamoleException {
+        super();
+    }
+
+    /**
+     * Returns the default maximum number of concurrent connections to allow to 
+     * any one connection, unless specified differently on an individual 
+     * connection. Zero denotes unlimited.
+     * 
+     * @return
+     *     The default maximum allowable number of concurrent connections 
+     *     to any connection.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while retrieving the property.
+     */
+    public abstract int getDefaultMaxConnections() throws GuacamoleException;
+    
+    /**
+     * Returns the default maximum number of concurrent connections to allow to 
+     * any one connection group, unless specified differently on an individual 
+     * connection group. Zero denotes unlimited.
+     * 
+     * @return
+     *     The default maximum allowable number of concurrent connections
+     *     to any connection group.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while retrieving the property.
+     */
+    public abstract int getDefaultMaxGroupConnections()
+            throws GuacamoleException;
+    
+    /**
+     * Returns the default maximum number of concurrent connections to allow to 
+     * any one connection by an individual user, unless specified differently on
+     * an individual connection. Zero denotes unlimited.
+     * 
+     * @return
+     *     The default maximum allowable number of concurrent connections to
+     *     any connection by an individual user.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while retrieving the property.
+     */
+    public abstract int getDefaultMaxConnectionsPerUser()
+            throws GuacamoleException;
+    
+    /**
+     * Returns the default maximum number of concurrent connections to allow to 
+     * any one connection group by an individual user, unless specified 
+     * differently on an individual connection group. Zero denotes unlimited.
+     * 
+     * @return
+     *     The default maximum allowable number of concurrent connections to
+     *     any connection group by an individual user.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while retrieving the property.
+     */
+    public abstract int getDefaultMaxGroupConnectionsPerUser()
+            throws GuacamoleException;
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/activeconnection/ActiveConnectionDirectory.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/activeconnection/ActiveConnectionDirectory.java
new file mode 100644
index 0000000..72d0368
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/activeconnection/ActiveConnectionDirectory.java
@@ -0,0 +1,83 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.jdbc.activeconnection;
+
+
+import com.google.inject.Inject;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Set;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.auth.jdbc.base.RestrictedObject;
+import org.glyptodon.guacamole.net.auth.ActiveConnection;
+import org.glyptodon.guacamole.net.auth.Directory;
+
+/**
+ * Implementation of a Directory which contains all currently-active
+ * connections.
+ *
+ * @author Michael Jumper
+ */
+public class ActiveConnectionDirectory extends RestrictedObject
+    implements Directory<ActiveConnection> {
+
+    /**
+     * Service for retrieving and manipulating active connections.
+     */
+    @Inject
+    private ActiveConnectionService activeConnectionService;
+
+    @Override
+    public ActiveConnection get(String identifier) throws GuacamoleException {
+        return activeConnectionService.retrieveObject(getCurrentUser(), identifier);
+    }
+
+    @Override
+    public Collection<ActiveConnection> getAll(Collection<String> identifiers)
+            throws GuacamoleException {
+        Collection<TrackedActiveConnection> objects = activeConnectionService.retrieveObjects(getCurrentUser(), identifiers);
+        return Collections.<ActiveConnection>unmodifiableCollection(objects);
+    }
+
+    @Override
+    public Set<String> getIdentifiers() throws GuacamoleException {
+        return activeConnectionService.getIdentifiers(getCurrentUser());
+    }
+
+    @Override
+    public void add(ActiveConnection object) throws GuacamoleException {
+        activeConnectionService.createObject(getCurrentUser(), object);
+    }
+
+    @Override
+    public void update(ActiveConnection object) throws GuacamoleException {
+        TrackedActiveConnection connection = (TrackedActiveConnection) object;
+        activeConnectionService.updateObject(getCurrentUser(), connection);
+    }
+
+    @Override
+    public void remove(String identifier) throws GuacamoleException {
+        activeConnectionService.deleteObject(getCurrentUser(), identifier);
+    }
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/activeconnection/ActiveConnectionPermissionService.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/activeconnection/ActiveConnectionPermissionService.java
new file mode 100644
index 0000000..386df9b
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/activeconnection/ActiveConnectionPermissionService.java
@@ -0,0 +1,176 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.jdbc.activeconnection;
+
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Set;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.GuacamoleSecurityException;
+import org.glyptodon.guacamole.auth.jdbc.permission.AbstractPermissionService;
+import org.glyptodon.guacamole.auth.jdbc.permission.ObjectPermissionService;
+import org.glyptodon.guacamole.auth.jdbc.tunnel.ActiveConnectionRecord;
+import org.glyptodon.guacamole.auth.jdbc.tunnel.GuacamoleTunnelService;
+import org.glyptodon.guacamole.auth.jdbc.user.AuthenticatedUser;
+import org.glyptodon.guacamole.auth.jdbc.user.ModeledUser;
+import org.glyptodon.guacamole.net.auth.permission.ObjectPermission;
+import org.glyptodon.guacamole.net.auth.permission.ObjectPermissionSet;
+
+/**
+ * Service which provides convenience methods for creating, retrieving, and
+ * manipulating active connections.
+ *
+ * @author Michael Jumper
+ */
+public class ActiveConnectionPermissionService
+    extends AbstractPermissionService<ObjectPermissionSet, ObjectPermission>
+    implements ObjectPermissionService {
+
+    /**
+     * Service for creating and tracking tunnels.
+     */
+    @Inject
+    private GuacamoleTunnelService tunnelService;
+
+    /**
+     * Provider for active connection permission sets.
+     */
+    @Inject
+    private Provider<ActiveConnectionPermissionSet> activeConnectionPermissionSetProvider;
+
+    @Override
+    public ObjectPermission retrievePermission(AuthenticatedUser user,
+            ModeledUser targetUser, ObjectPermission.Type type,
+            String identifier) throws GuacamoleException {
+
+        // Retrieve permissions
+        Set<ObjectPermission> permissions = retrievePermissions(user, targetUser);
+
+        // If retrieved permissions contains the requested permission, return it
+        ObjectPermission permission = new ObjectPermission(type, identifier); 
+        if (permissions.contains(permission))
+            return permission;
+
+        // Otherwise, no such permission
+        return null;
+
+    }
+
+    @Override
+    public Set<ObjectPermission> retrievePermissions(AuthenticatedUser user,
+            ModeledUser targetUser) throws GuacamoleException {
+
+        // Retrieve permissions only if allowed
+        if (canReadPermissions(user, targetUser)) {
+
+            // Only administrators may access active connections
+            boolean isAdmin = targetUser.isAdministrator();
+
+            // Get all active connections
+            Collection<ActiveConnectionRecord> records = tunnelService.getActiveConnections(user);
+
+            // We have READ, and possibly DELETE, on all active connections
+            Set<ObjectPermission> permissions = new HashSet<ObjectPermission>();
+            for (ActiveConnectionRecord record : records) {
+
+                // Add implicit READ
+                String identifier = record.getUUID().toString();
+                permissions.add(new ObjectPermission(ObjectPermission.Type.READ, identifier));
+
+                // If we're and admin, then we also have DELETE
+                if (isAdmin)
+                    permissions.add(new ObjectPermission(ObjectPermission.Type.DELETE, identifier));
+
+            }
+
+            return permissions;
+            
+        }
+
+        throw new GuacamoleSecurityException("Permission denied.");
+        
+    }
+
+    @Override
+    public Collection<String> retrieveAccessibleIdentifiers(AuthenticatedUser user,
+            ModeledUser targetUser, Collection<ObjectPermission.Type> permissionTypes,
+            Collection<String> identifiers) throws GuacamoleException {
+
+        Set<ObjectPermission> permissions = retrievePermissions(user, targetUser);
+        Collection<String> accessibleObjects = new ArrayList<String>(permissions.size());
+
+        // For each identifier/permission combination
+        for (String identifier : identifiers) {
+            for (ObjectPermission.Type permissionType : permissionTypes) {
+
+                // Add identifier if at least one requested permission is granted
+                ObjectPermission permission = new ObjectPermission(permissionType, identifier);
+                if (permissions.contains(permission)) {
+                    accessibleObjects.add(identifier);
+                    break;
+                }
+
+            }
+        }
+
+        return accessibleObjects;
+
+    }
+
+    @Override
+    public ObjectPermissionSet getPermissionSet(AuthenticatedUser user,
+            ModeledUser targetUser) throws GuacamoleException {
+    
+        // Create permission set for requested user
+        ActiveConnectionPermissionSet permissionSet = activeConnectionPermissionSetProvider.get();
+        permissionSet.init(user, targetUser);
+
+        return permissionSet;
+ 
+    }
+
+    @Override
+    public void createPermissions(AuthenticatedUser user,
+            ModeledUser targetUser, Collection<ObjectPermission> permissions)
+            throws GuacamoleException {
+
+        // Creating active connection permissions is not implemented
+        throw new GuacamoleSecurityException("Permission denied.");
+
+    }
+
+    @Override
+    public void deletePermissions(AuthenticatedUser user,
+            ModeledUser targetUser, Collection<ObjectPermission> permissions)
+            throws GuacamoleException {
+
+        // Deleting active connection permissions is not implemented
+        throw new GuacamoleSecurityException("Permission denied.");
+
+    }
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/activeconnection/ActiveConnectionPermissionSet.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/activeconnection/ActiveConnectionPermissionSet.java
new file mode 100644
index 0000000..d60350a
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/activeconnection/ActiveConnectionPermissionSet.java
@@ -0,0 +1,48 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.jdbc.activeconnection;
+
+import com.google.inject.Inject;
+import org.glyptodon.guacamole.auth.jdbc.permission.ObjectPermissionService;
+import org.glyptodon.guacamole.auth.jdbc.permission.ObjectPermissionSet;
+
+/**
+ * An implementation of ObjectPermissionSet which uses an injected service to
+ * query and manipulate the permissions associated with active connections.
+ *
+ * @author Michael Jumper
+ */
+public class ActiveConnectionPermissionSet extends ObjectPermissionSet {
+
+    /**
+     * Service for querying and manipulating active connection permissions.
+     */
+    @Inject
+    private ActiveConnectionPermissionService activeConnectionPermissionService;
+    
+    @Override
+    protected ObjectPermissionService getObjectPermissionService() {
+        return activeConnectionPermissionService;
+    }
+ 
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/activeconnection/ActiveConnectionService.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/activeconnection/ActiveConnectionService.java
new file mode 100644
index 0000000..c423885
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/activeconnection/ActiveConnectionService.java
@@ -0,0 +1,164 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.jdbc.activeconnection;
+
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Set;
+import org.glyptodon.guacamole.auth.jdbc.user.AuthenticatedUser;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.GuacamoleSecurityException;
+import org.glyptodon.guacamole.auth.jdbc.base.DirectoryObjectService;
+import org.glyptodon.guacamole.auth.jdbc.tunnel.ActiveConnectionRecord;
+import org.glyptodon.guacamole.auth.jdbc.tunnel.GuacamoleTunnelService;
+import org.glyptodon.guacamole.net.GuacamoleTunnel;
+import org.glyptodon.guacamole.net.auth.ActiveConnection;
+
+/**
+ * Service which provides convenience methods for creating, retrieving, and
+ * manipulating active connections.
+ *
+ * @author Michael Jumper
+ */
+public class ActiveConnectionService
+    implements DirectoryObjectService<TrackedActiveConnection, ActiveConnection> { 
+
+    /**
+     * Service for creating and tracking tunnels.
+     */
+    @Inject
+    private GuacamoleTunnelService tunnelService;
+
+    /**
+     * Provider for active connections.
+     */
+    @Inject
+    private Provider<TrackedActiveConnection> trackedActiveConnectionProvider;
+    
+    @Override
+    public TrackedActiveConnection retrieveObject(AuthenticatedUser user,
+            String identifier) throws GuacamoleException {
+
+        // Pull objects having given identifier
+        Collection<TrackedActiveConnection> objects = retrieveObjects(user, Collections.singleton(identifier));
+
+        // If no such object, return null
+        if (objects.isEmpty())
+            return null;
+
+        // The object collection will have exactly one element unless the
+        // database has seriously lost integrity
+        assert(objects.size() == 1);
+
+        // Return first and only object
+        return objects.iterator().next();
+
+    }
+    
+    @Override
+    public Collection<TrackedActiveConnection> retrieveObjects(AuthenticatedUser user,
+            Collection<String> identifiers) throws GuacamoleException {
+
+        boolean isAdmin = user.getUser().isAdministrator();
+        Set<String> identifierSet = new HashSet<String>(identifiers);
+
+        // Retrieve all visible connections (permissions enforced by tunnel service)
+        Collection<ActiveConnectionRecord> records = tunnelService.getActiveConnections(user);
+
+        // Restrict to subset of records which match given identifiers
+        Collection<TrackedActiveConnection> activeConnections = new ArrayList<TrackedActiveConnection>(identifiers.size());
+        for (ActiveConnectionRecord record : records) {
+
+            // Add connection if within requested identifiers
+            if (identifierSet.contains(record.getUUID().toString())) {
+                TrackedActiveConnection activeConnection = trackedActiveConnectionProvider.get();
+                activeConnection.init(user, record, isAdmin);
+                activeConnections.add(activeConnection);
+            }
+
+        }
+
+        return activeConnections;
+        
+    }
+
+    @Override
+    public void deleteObject(AuthenticatedUser user, String identifier)
+        throws GuacamoleException {
+
+        // Only administrators may delete active connections
+        if (!user.getUser().isAdministrator())
+            throw new GuacamoleSecurityException("Permission denied.");
+
+        // Close connection, if it exists (and we have permission)
+        ActiveConnection activeConnection = retrieveObject(user, identifier);
+        if (activeConnection != null) {
+
+            // Close connection if not already closed
+            GuacamoleTunnel tunnel = activeConnection.getTunnel();
+            if (tunnel != null && tunnel.isOpen())
+                tunnel.close();
+
+        }
+        
+    }
+
+    @Override
+    public Set<String> getIdentifiers(AuthenticatedUser user)
+        throws GuacamoleException {
+
+        // Retrieve all visible connections (permissions enforced by tunnel service)
+        Collection<ActiveConnectionRecord> records = tunnelService.getActiveConnections(user);
+
+        // Build list of identifiers
+        Set<String> identifiers = new HashSet<String>(records.size());
+        for (ActiveConnectionRecord record : records)
+            identifiers.add(record.getUUID().toString());
+
+        return identifiers;
+        
+    }
+
+    @Override
+    public TrackedActiveConnection createObject(AuthenticatedUser user,
+            ActiveConnection object) throws GuacamoleException {
+
+        // Updating active connections is not implemented
+        throw new GuacamoleSecurityException("Permission denied.");
+
+    }
+
+    @Override
+    public void updateObject(AuthenticatedUser user, TrackedActiveConnection object)
+            throws GuacamoleException {
+
+        // Updating active connections is not implemented
+        throw new GuacamoleSecurityException("Permission denied.");
+
+    }
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/activeconnection/TrackedActiveConnection.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/activeconnection/TrackedActiveConnection.java
new file mode 100644
index 0000000..f9e6ed3
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/activeconnection/TrackedActiveConnection.java
@@ -0,0 +1,169 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.jdbc.activeconnection;
+
+import java.util.Date;
+import org.glyptodon.guacamole.auth.jdbc.base.RestrictedObject;
+import org.glyptodon.guacamole.auth.jdbc.tunnel.ActiveConnectionRecord;
+import org.glyptodon.guacamole.auth.jdbc.user.AuthenticatedUser;
+import org.glyptodon.guacamole.net.GuacamoleTunnel;
+import org.glyptodon.guacamole.net.auth.ActiveConnection;
+
+/**
+ * An implementation of the ActiveConnection object which has an associated
+ * ActiveConnectionRecord.
+ *
+ * @author Michael Jumper
+ */
+public class TrackedActiveConnection extends RestrictedObject implements ActiveConnection {
+
+    /**
+     * The identifier of this active connection.
+     */
+    private String identifier;
+
+    /**
+     * The identifier of the associated connection.
+     */
+    private String connectionIdentifier;
+
+    /**
+     * The date and time this active connection began.
+     */
+    private Date startDate;
+
+    /**
+     * The remote host that initiated this connection.
+     */
+    private String remoteHost;
+
+    /**
+     * The username of the user that initiated this connection.
+     */
+    private String username;
+
+    /**
+     * The underlying GuacamoleTunnel.
+     */
+    private GuacamoleTunnel tunnel;
+
+    /**
+     * Initializes this TrackedActiveConnection, copying the data associated
+     * with the given active connection record. At a minimum, the identifier
+     * of this active connection will be set, the start date, and the
+     * identifier of the associated connection will be copied. If requested,
+     * sensitive information like the associated username will be copied, as
+     * well.
+     *
+     * @param currentUser
+     *     The user that created or retrieved this object.
+     *
+     * @param activeConnectionRecord
+     *     The active connection record to copy.
+     *
+     * @param includeSensitiveInformation
+     *     Whether sensitive data should be copied from the connection record
+     *     as well. This includes the remote host, associated tunnel, and
+     *     username.
+     */
+    public void init(AuthenticatedUser currentUser,
+            ActiveConnectionRecord activeConnectionRecord,
+            boolean includeSensitiveInformation) {
+
+        super.init(currentUser);
+        
+        // Copy all non-sensitive data from given record
+        this.connectionIdentifier = activeConnectionRecord.getConnection().getIdentifier();
+        this.identifier           = activeConnectionRecord.getUUID().toString();
+        this.startDate            = activeConnectionRecord.getStartDate();
+
+        // Include sensitive data, too, if requested
+        if (includeSensitiveInformation) {
+            this.remoteHost = activeConnectionRecord.getRemoteHost();
+            this.tunnel     = activeConnectionRecord.getTunnel();
+            this.username   = activeConnectionRecord.getUsername();
+        }
+
+    }
+
+    @Override
+    public String getIdentifier() {
+        return identifier;
+    }
+
+    @Override
+    public void setIdentifier(String identifier) {
+        this.identifier = identifier;
+    }
+ 
+    @Override
+    public String getConnectionIdentifier() {
+        return connectionIdentifier;
+    }
+
+    @Override
+    public void setConnectionIdentifier(String connnectionIdentifier) {
+        this.connectionIdentifier = connnectionIdentifier;
+    }
+
+    @Override
+    public Date getStartDate() {
+        return startDate;
+    }
+
+    @Override
+    public void setStartDate(Date startDate) {
+        this.startDate = startDate;
+    }
+
+    @Override
+    public String getRemoteHost() {
+        return remoteHost;
+    }
+
+    @Override
+    public void setRemoteHost(String remoteHost) {
+        this.remoteHost = remoteHost;
+    }
+
+    @Override
+    public String getUsername() {
+        return username;
+    }
+
+    @Override
+    public void setUsername(String username) {
+        this.username = username;
+    }
+
+    @Override
+    public GuacamoleTunnel getTunnel() {
+        return tunnel;
+    }
+
+    @Override
+    public void setTunnel(GuacamoleTunnel tunnel) {
+        this.tunnel = tunnel;
+    }
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/activeconnection/package-info.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/activeconnection/package-info.java
new file mode 100644
index 0000000..018e630
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/activeconnection/package-info.java
@@ -0,0 +1,26 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Classes related to currently-active connections.
+ */
+package org.glyptodon.guacamole.auth.jdbc.activeconnection;
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/base/DirectoryObjectService.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/base/DirectoryObjectService.java
new file mode 100644
index 0000000..7d755a6
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/base/DirectoryObjectService.java
@@ -0,0 +1,155 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.jdbc.base;
+
+import java.util.Collection;
+import java.util.Set;
+import org.glyptodon.guacamole.auth.jdbc.user.AuthenticatedUser;
+import org.glyptodon.guacamole.GuacamoleException;
+
+/**
+ * Service which provides convenience methods for creating, retrieving, and
+ * manipulating objects that have unique identifiers, such as the objects
+ * within directories. This service will automatically enforce the permissions
+ * of the current user.
+ *
+ * @author Michael Jumper
+ * @param <InternalType>
+ *     The specific internal implementation of the type of object this service
+ *     provides access to.
+ *
+ * @param <ExternalType>
+ *     The external interface or implementation of the type of object this
+ *     service provides access to, as defined by the guacamole-ext API.
+ */
+public interface DirectoryObjectService<InternalType, ExternalType> {
+
+    /**
+     * Retrieves the single object that has the given identifier, if it exists
+     * and the user has permission to read it.
+     *
+     * @param user
+     *     The user retrieving the object.
+     *
+     * @param identifier
+     *     The identifier of the object to retrieve.
+     *
+     * @return
+     *     The object having the given identifier, or null if no such object
+     *     exists.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while retrieving the requested object.
+     */
+    InternalType retrieveObject(AuthenticatedUser user, String identifier)
+            throws GuacamoleException;
+    
+    /**
+     * Retrieves all objects that have the identifiers in the given collection.
+     * Only objects that the user has permission to read will be returned.
+     *
+     * @param user
+     *     The user retrieving the objects.
+     *
+     * @param identifiers
+     *     The identifiers of the objects to retrieve.
+     *
+     * @return
+     *     The objects having the given identifiers.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while retrieving the requested objects.
+     */
+    Collection<InternalType> retrieveObjects(AuthenticatedUser user,
+            Collection<String> identifiers) throws GuacamoleException;
+
+    /**
+     * Creates the given object. If the object already exists, an error will be
+     * thrown.
+     *
+     * @param user
+     *     The user creating the object.
+     *
+     * @param object
+     *     The object to create.
+     *
+     * @return
+     *     The newly-created object.
+     *
+     * @throws GuacamoleException
+     *     If the user lacks permission to create the object, or an error
+     *     occurs while creating the object.
+     */
+    InternalType createObject(AuthenticatedUser user, ExternalType object)
+            throws GuacamoleException;
+
+    /**
+     * Deletes the object having the given identifier. If no such object
+     * exists, this function has no effect.
+     *
+     * @param user
+     *     The user deleting the object.
+     *
+     * @param identifier
+     *     The identifier of the object to delete.
+     *
+     * @throws GuacamoleException
+     *     If the user lacks permission to delete the object, or an error
+     *     occurs while deleting the object.
+     */
+    void deleteObject(AuthenticatedUser user, String identifier)
+        throws GuacamoleException;
+
+    /**
+     * Updates the given object, applying any changes that have been made. If
+     * no such object exists, this function has no effect.
+     *
+     * @param user
+     *     The user updating the object.
+     *
+     * @param object
+     *     The object to update.
+     *
+     * @throws GuacamoleException
+     *     If the user lacks permission to update the object, or an error
+     *     occurs while updating the object.
+     */
+    void updateObject(AuthenticatedUser user, InternalType object)
+            throws GuacamoleException;
+
+    /**
+     * Returns the set of all identifiers for all objects that the user has
+     * read access to.
+     *
+     * @param user
+     *     The user retrieving the identifiers.
+     *
+     * @return
+     *     The set of all identifiers for all objects.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while reading identifiers.
+     */
+    Set<String> getIdentifiers(AuthenticatedUser user) throws GuacamoleException;
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/base/GroupedObjectModel.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/base/GroupedObjectModel.java
new file mode 100644
index 0000000..45b2559
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/base/GroupedObjectModel.java
@@ -0,0 +1,67 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.jdbc.base;
+
+/**
+ * Object representation of a Guacamole object, such as a user or connection,
+ * as represented in the database.
+ *
+ * @author Michael Jumper
+ */
+public abstract class GroupedObjectModel extends ObjectModel {
+
+    /**
+     * The unique identifier which identifies the parent of this object.
+     */
+    private String parentIdentifier;
+    
+    /**
+     * Creates a new, empty object.
+     */
+    public GroupedObjectModel() {
+    }
+
+    /**
+     * Returns the identifier of the parent connection group, or null if the
+     * parent connection group is the root connection group.
+     *
+     * @return 
+     *     The identifier of the parent connection group, or null if the parent
+     *     connection group is the root connection group.
+     */
+    public String getParentIdentifier() {
+        return parentIdentifier;
+    }
+
+    /**
+     * Sets the identifier of the parent connection group.
+     *
+     * @param parentIdentifier
+     *     The identifier of the parent connection group, or null if the parent
+     *     connection group is the root connection group.
+     */
+    public void setParentIdentifier(String parentIdentifier) {
+        this.parentIdentifier = parentIdentifier;
+    }
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/base/ModeledDirectoryObject.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/base/ModeledDirectoryObject.java
new file mode 100644
index 0000000..8986f4f
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/base/ModeledDirectoryObject.java
@@ -0,0 +1,50 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.jdbc.base;
+
+import org.glyptodon.guacamole.net.auth.Identifiable;
+
+/**
+ * Common base class for objects that will ultimately be made available through
+ * the Directory class and are persisted to an underlying database model. All
+ * such objects will need the same base set of queries to fulfill the needs of
+ * the Directory class.
+ *
+ * @author Michael Jumper
+ * @param <ModelType>
+ *     The type of model object that corresponds to this object.
+ */
+public abstract class ModeledDirectoryObject<ModelType extends ObjectModel>
+    extends ModeledObject<ModelType> implements Identifiable {
+
+    @Override
+    public String getIdentifier() {
+        return getModel().getIdentifier();
+    }
+
+    @Override
+    public void setIdentifier(String identifier) {
+        getModel().setIdentifier(identifier);
+    }
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/base/ModeledDirectoryObjectMapper.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/base/ModeledDirectoryObjectMapper.java
new file mode 100644
index 0000000..f3dc985
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/base/ModeledDirectoryObjectMapper.java
@@ -0,0 +1,140 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.jdbc.base;
+
+import java.util.Collection;
+import java.util.Set;
+import org.glyptodon.guacamole.auth.jdbc.user.UserModel;
+import org.apache.ibatis.annotations.Param;
+
+/**
+ * Common interface for objects that will ultimately be made available through
+ * the Directory class. All such objects will need the same base set of queries
+ * to fulfill the needs of the Directory class.
+ *
+ * @author Michael Jumper
+ * @param <ModelType>
+ *     The type of object contained within the directory whose objects are
+ *     mapped by this mapper.
+ */
+public interface ModeledDirectoryObjectMapper<ModelType> {
+
+    /**
+     * Selects the identifiers of all objects, regardless of whether they
+     * are readable by any particular user. This should only be called on
+     * behalf of a system administrator. If identifiers are needed by a non-
+     * administrative user who must have explicit read rights, use
+     * selectReadableIdentifiers() instead.
+     *
+     * @return
+     *     A Set containing all identifiers of all objects.
+     */
+    Set<String> selectIdentifiers();
+    
+    /**
+     * Selects the identifiers of all objects that are explicitly readable by
+     * the given user. If identifiers are needed by a system administrator
+     * (who, by definition, does not need explicit read rights), use
+     * selectIdentifiers() instead.
+     *
+     * @param user
+     *    The user whose permissions should determine whether an identifier
+     *    is returned.
+     *
+     * @return
+     *     A Set containing all identifiers of all readable objects.
+     */
+    Set<String> selectReadableIdentifiers(@Param("user") UserModel user);
+    
+    /**
+     * Selects all objects which have the given identifiers. If an identifier
+     * has no corresponding object, it will be ignored. This should only be
+     * called on behalf of a system administrator. If objects are needed by a
+     * non-administrative user who must have explicit read rights, use
+     * selectReadable() instead.
+     *
+     * @param identifiers
+     *     The identifiers of the objects to return.
+     *
+     * @return 
+     *     A Collection of all objects having the given identifiers.
+     */
+    Collection<ModelType> select(@Param("identifiers") Collection<String> identifiers);
+
+    /**
+     * Selects all objects which have the given identifiers and are explicitly
+     * readably by the given user. If an identifier has no corresponding
+     * object, or the corresponding object is unreadable, it will be ignored.
+     * If objects are needed by a system administrator (who, by definition,
+     * does not need explicit read rights), use select() instead.
+     *
+     * @param user
+     *    The user whose permissions should determine whether an object 
+     *    is returned.
+     *
+     * @param identifiers
+     *     The identifiers of the objects to return.
+     *
+     * @return 
+     *     A Collection of all objects having the given identifiers.
+     */
+    Collection<ModelType> selectReadable(@Param("user") UserModel user,
+            @Param("identifiers") Collection<String> identifiers);
+
+    /**
+     * Inserts the given object into the database. If the object already
+     * exists, this will result in an error.
+     *
+     * @param object
+     *     The object to insert.
+     *
+     * @return
+     *     The number of rows inserted.
+     */
+    int insert(@Param("object") ModelType object);
+
+    /**
+     * Deletes the given object into the database. If the object does not 
+     * exist, this operation has no effect.
+     *
+     * @param identifier
+     *     The identifier of the object to delete.
+     *
+     * @return
+     *     The number of rows deleted.
+     */
+    int delete(@Param("identifier") String identifier);
+
+    /**
+     * Updates the given existing object in the database. If the object does 
+     * not actually exist, this operation has no effect.
+     *
+     * @param object
+     *     The object to update.
+     *
+     * @return
+     *     The number of rows updated.
+     */
+    int update(@Param("object") ModelType object);
+    
+}
\ No newline at end of file
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/base/ModeledDirectoryObjectService.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/base/ModeledDirectoryObjectService.java
new file mode 100644
index 0000000..0347843
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/base/ModeledDirectoryObjectService.java
@@ -0,0 +1,435 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.jdbc.base;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Set;
+import org.glyptodon.guacamole.auth.jdbc.user.AuthenticatedUser;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.GuacamoleSecurityException;
+import org.glyptodon.guacamole.auth.jdbc.permission.ObjectPermissionMapper;
+import org.glyptodon.guacamole.auth.jdbc.permission.ObjectPermissionModel;
+import org.glyptodon.guacamole.auth.jdbc.user.UserModel;
+import org.glyptodon.guacamole.net.auth.Identifiable;
+import org.glyptodon.guacamole.net.auth.permission.ObjectPermission;
+import org.glyptodon.guacamole.net.auth.permission.ObjectPermissionSet;
+
+/**
+ * Service which provides convenience methods for creating, retrieving, and
+ * manipulating objects within directories. This service will automatically
+ * enforce the permissions of the current user.
+ *
+ * @author Michael Jumper
+ * @param <InternalType>
+ *     The specific internal implementation of the type of object this service
+ *     provides access to.
+ *
+ * @param <ExternalType>
+ *     The external interface or implementation of the type of object this
+ *     service provides access to, as defined by the guacamole-ext API.
+ *
+ * @param <ModelType>
+ *     The underlying model object used to represent InternalType in the
+ *     database.
+ */
+public abstract class ModeledDirectoryObjectService<InternalType extends ModeledDirectoryObject<ModelType>,
+        ExternalType extends Identifiable, ModelType extends ObjectModel>
+    implements DirectoryObjectService<InternalType, ExternalType> {
+
+    /**
+     * All object permissions which are implicitly granted upon creation to the
+     * creator of the object.
+     */
+    private static final ObjectPermission.Type[] IMPLICIT_OBJECT_PERMISSIONS = {
+        ObjectPermission.Type.READ,
+        ObjectPermission.Type.UPDATE,
+        ObjectPermission.Type.DELETE,
+        ObjectPermission.Type.ADMINISTER
+    };
+    
+    /**
+     * Returns an instance of a mapper for the type of object used by this
+     * service.
+     *
+     * @return
+     *     A mapper which provides access to the model objects associated with
+     *     the objects used by this service.
+     */
+    protected abstract ModeledDirectoryObjectMapper<ModelType> getObjectMapper();
+
+    /**
+     * Returns an instance of a mapper for the type of permissions that affect
+     * the type of object used by this service.
+     *
+     * @return
+     *     A mapper which provides access to the model objects associated with
+     *     the permissions that affect the objects used by this service.
+     */
+    protected abstract ObjectPermissionMapper getPermissionMapper();
+
+    /**
+     * Returns an instance of an object which is backed by the given model
+     * object.
+     *
+     * @param currentUser
+     *     The user for whom this object is being created.
+     *
+     * @param model
+     *     The model object to use to back the returned object.
+     *
+     * @return
+     *     An object which is backed by the given model object.
+     */
+    protected abstract InternalType getObjectInstance(AuthenticatedUser currentUser,
+            ModelType model);
+
+    /**
+     * Returns an instance of a model object which is based on the given
+     * object.
+     *
+     * @param currentUser
+     *     The user for whom this model object is being created.
+     *
+     * @param object 
+     *     The object to use to produce the returned model object.
+     *
+     * @return
+     *     A model object which is based on the given object.
+     */
+    protected abstract ModelType getModelInstance(AuthenticatedUser currentUser,
+            ExternalType object);
+
+    /**
+     * Returns whether the given user has permission to create the type of
+     * objects that this directory object service manages.
+     *
+     * @param user
+     *     The user being checked.
+     *
+     * @return
+     *     true if the user has object creation permission relevant to this
+     *     directory object service, false otherwise.
+     * 
+     * @throws GuacamoleException
+     *     If permission to read the user's permissions is denied.
+     */
+    protected abstract boolean hasCreatePermission(AuthenticatedUser user)
+            throws GuacamoleException;
+
+    /**
+     * Returns whether the given user has permission to perform a certain
+     * action on a specific object managed by this directory object service.
+     *
+     * @param user
+     *     The user being checked.
+     *
+     * @param identifier
+     *     The identifier of the object to check.
+     *
+     * @param type
+     *     The type of action that will be performed.
+     *
+     * @return
+     *     true if the user has object permission relevant described, false
+     *     otherwise.
+     * 
+     * @throws GuacamoleException
+     *     If permission to read the user's permissions is denied.
+     */
+    protected boolean hasObjectPermission(AuthenticatedUser user,
+            String identifier, ObjectPermission.Type type)
+            throws GuacamoleException {
+
+        // Get object permissions
+        ObjectPermissionSet permissionSet = getPermissionSet(user);
+        
+        // Return whether permission is granted
+        return user.getUser().isAdministrator()
+            || permissionSet.hasPermission(type, identifier);
+
+    }
+ 
+    /**
+     * Returns the permission set associated with the given user and related
+     * to the type of objects handled by this directory object service.
+     *
+     * @param user
+     *     The user whose permissions are being retrieved.
+     *
+     * @return
+     *     A permission set which contains the permissions associated with the
+     *     given user and related to the type of objects handled by this
+     *     directory object service.
+     * 
+     * @throws GuacamoleException
+     *     If permission to read the user's permissions is denied.
+     */
+    protected abstract ObjectPermissionSet getPermissionSet(AuthenticatedUser user)
+            throws GuacamoleException;
+
+    /**
+     * Returns a collection of objects which are backed by the models in the
+     * given collection.
+     *
+     * @param currentUser
+     *     The user for whom these objects are being created.
+     *
+     * @param models
+     *     The model objects to use to back the objects within the returned
+     *     collection.
+     *
+     * @return
+     *     A collection of objects which are backed by the models in the given
+     *     collection.
+     */
+    protected Collection<InternalType> getObjectInstances(AuthenticatedUser currentUser,
+            Collection<ModelType> models) {
+
+        // Create new collection of objects by manually converting each model
+        Collection<InternalType> objects = new ArrayList<InternalType>(models.size());
+        for (ModelType model : models)
+            objects.add(getObjectInstance(currentUser, model));
+
+        return objects;
+        
+    }
+
+    /**
+     * Called before any object is created through this directory object
+     * service. This function serves as a final point of validation before
+     * the create operation occurs. In its default implementation,
+     * beforeCreate() performs basic permissions checks.
+     *
+     * @param user
+     *     The user creating the object.
+     *
+     * @param model
+     *     The model of the object being created.
+     *
+     * @throws GuacamoleException
+     *     If the object is invalid, or an error prevents validating the given
+     *     object.
+     */
+    protected void beforeCreate(AuthenticatedUser user,
+            ModelType model ) throws GuacamoleException {
+
+        // Verify permission to create objects
+        if (!user.getUser().isAdministrator() && !hasCreatePermission(user))
+            throw new GuacamoleSecurityException("Permission denied.");
+
+    }
+
+    /**
+     * Called before any object is updated through this directory object
+     * service. This function serves as a final point of validation before
+     * the update operation occurs. In its default implementation,
+     * beforeUpdate() performs basic permissions checks.
+     *
+     * @param user
+     *     The user updating the existing object.
+     *
+     * @param model
+     *     The model of the object being updated.
+     *
+     * @throws GuacamoleException
+     *     If the object is invalid, or an error prevents validating the given
+     *     object.
+     */
+    protected void beforeUpdate(AuthenticatedUser user,
+            ModelType model) throws GuacamoleException {
+
+        // By default, do nothing.
+        if (!hasObjectPermission(user, model.getIdentifier(), ObjectPermission.Type.UPDATE))
+            throw new GuacamoleSecurityException("Permission denied.");
+
+    }
+
+    /**
+     * Called before any object is deleted through this directory object
+     * service. This function serves as a final point of validation before
+     * the delete operation occurs. In its default implementation,
+     * beforeDelete() performs basic permissions checks.
+     *
+     * @param user
+     *     The user deleting the existing object.
+     *
+     * @param identifier
+     *     The identifier of the object being deleted.
+     *
+     * @throws GuacamoleException
+     *     If the object is invalid, or an error prevents validating the given
+     *     object.
+     */
+    protected void beforeDelete(AuthenticatedUser user,
+            String identifier) throws GuacamoleException {
+
+        // Verify permission to delete objects
+        if (!hasObjectPermission(user, identifier, ObjectPermission.Type.DELETE))
+            throw new GuacamoleSecurityException("Permission denied.");
+
+    }
+
+    @Override
+    public InternalType retrieveObject(AuthenticatedUser user,
+            String identifier) throws GuacamoleException {
+
+        // Pull objects having given identifier
+        Collection<InternalType> objects = retrieveObjects(user, Collections.singleton(identifier));
+
+        // If no such object, return null
+        if (objects.isEmpty())
+            return null;
+
+        // The object collection will have exactly one element unless the
+        // database has seriously lost integrity
+        assert(objects.size() == 1);
+
+        // Return first and only object 
+        return objects.iterator().next();
+
+    }
+
+    @Override
+    public Collection<InternalType> retrieveObjects(AuthenticatedUser user,
+            Collection<String> identifiers) throws GuacamoleException {
+
+        // Do not query if no identifiers given
+        if (identifiers.isEmpty())
+            return Collections.<InternalType>emptyList();
+
+        Collection<ModelType> objects;
+
+        // Bypass permission checks if the user is a system admin
+        if (user.getUser().isAdministrator())
+            objects = getObjectMapper().select(identifiers);
+
+        // Otherwise only return explicitly readable identifiers
+        else
+            objects = getObjectMapper().selectReadable(user.getUser().getModel(), identifiers);
+        
+        // Return collection of requested objects
+        return getObjectInstances(user, objects);
+        
+    }
+
+    /**
+     * Returns a collection of permissions that should be granted due to the
+     * creation of the given object. These permissions need not be granted
+     * solely to the user creating the object.
+     * 
+     * @param user
+     *     The user creating the object.
+     * 
+     * @param model
+     *     The object being created.
+     * 
+     * @return
+     *     The collection of implicit permissions that should be granted due to
+     *     the creation of the given object.
+     */
+    protected Collection<ObjectPermissionModel> getImplicitPermissions(AuthenticatedUser user,
+            ModelType model) {
+        
+        // Build list of implicit permissions
+        Collection<ObjectPermissionModel> implicitPermissions =
+                new ArrayList<ObjectPermissionModel>(IMPLICIT_OBJECT_PERMISSIONS.length);
+
+        UserModel userModel = user.getUser().getModel();
+        for (ObjectPermission.Type permission : IMPLICIT_OBJECT_PERMISSIONS) {
+
+            // Create model which grants this permission to the current user
+            ObjectPermissionModel permissionModel = new ObjectPermissionModel();
+            permissionModel.setUserID(userModel.getObjectID());
+            permissionModel.setUsername(userModel.getIdentifier());
+            permissionModel.setType(permission);
+            permissionModel.setObjectIdentifier(model.getIdentifier());
+
+            // Add permission
+            implicitPermissions.add(permissionModel);
+
+        }
+        
+        return implicitPermissions;
+
+    }
+
+    @Override
+    public InternalType createObject(AuthenticatedUser user, ExternalType object)
+        throws GuacamoleException {
+
+        ModelType model = getModelInstance(user, object);
+        beforeCreate(user, model);
+        
+        // Create object
+        getObjectMapper().insert(model);
+
+        // Set identifier on original object
+        object.setIdentifier(model.getIdentifier());
+
+        // Add implicit permissions
+        getPermissionMapper().insert(getImplicitPermissions(user, model));
+
+        return getObjectInstance(user, model);
+
+    }
+
+    @Override
+    public void deleteObject(AuthenticatedUser user, String identifier)
+        throws GuacamoleException {
+
+        beforeDelete(user, identifier);
+        
+        // Delete object
+        getObjectMapper().delete(identifier);
+
+    }
+
+    @Override
+    public void updateObject(AuthenticatedUser user, InternalType object)
+        throws GuacamoleException {
+
+        ModelType model = object.getModel();
+        beforeUpdate(user, model);
+        
+        // Update object
+        getObjectMapper().update(model);
+
+    }
+
+    @Override
+    public Set<String> getIdentifiers(AuthenticatedUser user)
+        throws GuacamoleException {
+
+        // Bypass permission checks if the user is a system admin
+        if (user.getUser().isAdministrator())
+            return getObjectMapper().selectIdentifiers();
+
+        // Otherwise only return explicitly readable identifiers
+        else
+            return getObjectMapper().selectReadableIdentifiers(user.getUser().getModel());
+
+    }
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/base/ModeledGroupedDirectoryObject.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/base/ModeledGroupedDirectoryObject.java
new file mode 100644
index 0000000..50c527f
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/base/ModeledGroupedDirectoryObject.java
@@ -0,0 +1,78 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.jdbc.base;
+
+import org.glyptodon.guacamole.auth.jdbc.connectiongroup.RootConnectionGroup;
+
+/**
+ * Common base class for objects that will ultimately be made available through
+ * the Directory class. All such objects will need the same base set of queries
+ * to fulfill the needs of the Directory class.
+ *
+ * @author Michael Jumper
+ * @param <ModelType>
+ *     The type of model object that corresponds to this object.
+ */
+public abstract class ModeledGroupedDirectoryObject<ModelType extends GroupedObjectModel>
+    extends ModeledDirectoryObject<ModelType> {
+
+    /**
+     * Returns the identifier of the parent connection group, which cannot be
+     * null. If the parent is the root connection group, this will be
+     * RootConnectionGroup.IDENTIFIER.
+     *
+     * @return
+     *     The identifier of the parent connection group.
+     */
+    public String getParentIdentifier() {
+
+        // Translate null parent to proper identifier
+        String parentIdentifier = getModel().getParentIdentifier();
+        if (parentIdentifier == null)
+            return RootConnectionGroup.IDENTIFIER;
+
+        return parentIdentifier;
+        
+    }
+
+    /**
+     * Sets the identifier of the associated parent connection group. If the
+     * parent is the root connection group, this should be
+     * RootConnectionGroup.IDENTIFIER.
+     * 
+     * @param parentIdentifier
+     *     The identifier of the connection group to associate as this object's
+     *     parent.
+     */
+    public void setParentIdentifier(String parentIdentifier) {
+
+        // Translate root identifier back into null
+        if (parentIdentifier != null
+                && parentIdentifier.equals(RootConnectionGroup.IDENTIFIER))
+            parentIdentifier = null;
+
+        getModel().setParentIdentifier(parentIdentifier);
+
+    }
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/base/ModeledGroupedDirectoryObjectService.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/base/ModeledGroupedDirectoryObjectService.java
new file mode 100644
index 0000000..9a9aa44
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/base/ModeledGroupedDirectoryObjectService.java
@@ -0,0 +1,197 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.jdbc.base;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.GuacamoleSecurityException;
+import org.glyptodon.guacamole.auth.jdbc.user.AuthenticatedUser;
+import org.glyptodon.guacamole.net.auth.Identifiable;
+import org.glyptodon.guacamole.net.auth.permission.ObjectPermission;
+import org.glyptodon.guacamole.net.auth.permission.ObjectPermissionSet;
+
+/**
+ * Service which provides convenience methods for creating, retrieving, and
+ * manipulating objects that can be within connection groups. This service will
+ * automatically enforce the permissions of the current user.
+ *
+ * @author Michael Jumper
+ * @param <InternalType>
+ *     The specific internal implementation of the type of object this service
+ *     provides access to.
+ *
+ * @param <ExternalType>
+ *     The external interface or implementation of the type of object this
+ *     service provides access to, as defined by the guacamole-ext API.
+ *
+ * @param <ModelType>
+ *     The underlying model object used to represent InternalType in the
+ *     database.
+ */
+public abstract class ModeledGroupedDirectoryObjectService<InternalType extends ModeledGroupedDirectoryObject<ModelType>,
+        ExternalType extends Identifiable, ModelType extends GroupedObjectModel>
+        extends ModeledDirectoryObjectService<InternalType, ExternalType, ModelType> {
+
+    /**
+     * Returns the set of parent connection groups that are modified by the
+     * given model object (by virtue of the object changing parent groups). If
+     * the model is not changing parents, the resulting collection will be
+     * empty.
+     *
+     * @param user
+     *     The user making the given changes to the model.
+     *
+     * @param identifier
+     *     The identifier of the object that has been modified, if it exists.
+     *     If the object is being created, this will be null.
+     *
+     * @param model
+     *     The model that has been modified, if any. If the object is being
+     *     deleted, this will be null.
+     *
+     * @return
+     *     A collection of the identifiers of all parent connection groups
+     *     that will be affected (updated) by the change.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while determining which parent connection groups
+     *     are affected.
+     */
+    protected Collection<String> getModifiedGroups(AuthenticatedUser user,
+            String identifier, ModelType model) throws GuacamoleException {
+
+        // Get old parent identifier
+        String oldParentIdentifier = null;
+        if (identifier != null) {
+            ModelType current = retrieveObject(user, identifier).getModel();
+            oldParentIdentifier = current.getParentIdentifier();
+        }
+
+        // Get new parent identifier
+        String parentIdentifier = null;
+        if (model != null) {
+
+            parentIdentifier = model.getParentIdentifier();
+
+            // If both parents have the same identifier, nothing has changed
+            if (parentIdentifier != null && parentIdentifier.equals(oldParentIdentifier))
+                return Collections.<String>emptyList();
+
+        }
+
+        // Return collection of all non-root groups involved
+        Collection<String> groups = new ArrayList<String>(2);
+        if (oldParentIdentifier != null) groups.add(oldParentIdentifier);
+        if (parentIdentifier    != null) groups.add(parentIdentifier);
+        return groups;
+
+    }
+
+    /**
+     * Returns whether the given user has permission to modify the parent
+     * connection groups affected by the modifications made to the given model
+     * object.
+     *
+     * @param user
+     *     The user who changed the model object.
+     *
+     * @param identifier
+     *     The identifier of the object that has been modified, if it exists.
+     *     If the object is being created, this will be null.
+     *
+     * @param model
+     *     The model that has been modified, if any. If the object is being
+     *     deleted, this will be null.
+     *
+     * @return
+     *     true if the user has update permission for all modified groups,
+     *     false otherwise.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while determining which parent connection groups
+     *     are affected.
+     */
+    protected boolean canUpdateModifiedGroups(AuthenticatedUser user,
+            String identifier, ModelType model) throws GuacamoleException {
+
+        // If user is an administrator, no need to check
+        if (user.getUser().isAdministrator())
+            return true;
+        
+        // Verify that we have permission to modify any modified groups
+        Collection<String> modifiedGroups = getModifiedGroups(user, identifier, model);
+        if (!modifiedGroups.isEmpty()) {
+
+            ObjectPermissionSet permissionSet = user.getUser().getConnectionGroupPermissions();
+            Collection<String> updateableGroups = permissionSet.getAccessibleObjects(
+                Collections.singleton(ObjectPermission.Type.UPDATE),
+                modifiedGroups
+            );
+
+            return updateableGroups.size() == modifiedGroups.size();
+            
+        }
+
+        return true;
+
+    }
+
+    @Override
+    protected void beforeCreate(AuthenticatedUser user,
+            ModelType model) throws GuacamoleException {
+
+        super.beforeCreate(user, model);
+        
+        // Validate that we can update all applicable parent groups
+        if (!canUpdateModifiedGroups(user, null, model))
+            throw new GuacamoleSecurityException("Permission denied.");
+        
+    }
+
+    @Override
+    protected void beforeUpdate(AuthenticatedUser user,
+            ModelType model) throws GuacamoleException {
+
+        super.beforeUpdate(user, model);
+
+        // Validate that we can update all applicable parent groups
+        if (!canUpdateModifiedGroups(user, model.getIdentifier(), model))
+            throw new GuacamoleSecurityException("Permission denied.");
+        
+    }
+
+    @Override
+    protected void beforeDelete(AuthenticatedUser user,
+            String identifier) throws GuacamoleException {
+
+        super.beforeDelete(user, identifier);
+
+        // Validate that we can update all applicable parent groups
+        if (!canUpdateModifiedGroups(user, identifier, null))
+            throw new GuacamoleSecurityException("Permission denied.");
+        
+    }
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/base/ModeledObject.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/base/ModeledObject.java
new file mode 100644
index 0000000..276b090
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/base/ModeledObject.java
@@ -0,0 +1,82 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.jdbc.base;
+
+import org.glyptodon.guacamole.auth.jdbc.user.AuthenticatedUser;
+
+/**
+ * Common base class for objects have an underlying model. For the purposes of
+ * JDBC-driven authentication providers, all modeled objects are also
+ * restricted.
+ *
+ * @author Michael Jumper
+ * @param <ModelType>
+ *     The type of model object which corresponds to this object.
+ */
+public abstract class ModeledObject<ModelType> extends RestrictedObject {
+
+    /**
+     * The internal model object containing the values which represent this
+     * object in the database.
+     */
+    private ModelType model;
+
+    /**
+     * Initializes this object, associating it with the current authenticated
+     * user and populating it with data from the given model object
+     *
+     * @param currentUser
+     *     The user that created or retrieved this object.
+     *
+     * @param model 
+     *     The backing model object.
+     */
+    public void init(AuthenticatedUser currentUser, ModelType model) {
+        super.init(currentUser);
+        setModel(model);
+    }
+
+    /**
+     * Returns the backing model object. Changes to the model object will
+     * affect this object, and changes to this object will affect the model
+     * object.
+     *
+     * @return
+     *     The backing model object.
+     */
+    public ModelType getModel() {
+        return model;
+    }
+
+    /**
+     * Sets the backing model object. This will effectively replace all data
+     * contained within this object.
+     *
+     * @param model
+     *     The backing model object.
+     */
+    public void setModel(ModelType model) {
+        this.model = model;
+    }
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/base/ObjectModel.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/base/ObjectModel.java
new file mode 100644
index 0000000..e936686
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/base/ObjectModel.java
@@ -0,0 +1,90 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.jdbc.base;
+
+/**
+ * Object representation of a Guacamole object, such as a user or connection,
+ * as represented in the database.
+ *
+ * @author Michael Jumper
+ */
+public abstract class ObjectModel {
+
+    /**
+     * The ID of this object in the database, if any.
+     */
+    private Integer objectID;
+
+    /**
+     * The unique identifier which identifies this object.
+     */
+    private String identifier;
+    
+    /**
+     * Creates a new, empty object.
+     */
+    public ObjectModel() {
+    }
+
+    /**
+     * Returns the identifier that uniquely identifies this object.
+     *
+     * @return
+     *     The identifier that uniquely identifies this object.
+     */
+    public String getIdentifier() {
+        return identifier;
+    }
+
+    /**
+     * Sets the identifier that uniquely identifies this object.
+     *
+     * @param identifier
+     *     The identifier that uniquely identifies this object.
+     */
+    public void setIdentifier(String identifier) {
+        this.identifier = identifier;
+    }
+
+    /**
+     * Returns the ID of this object in the database, if it exists.
+     *
+     * @return
+     *     The ID of this object in the database, or null if this object was
+     *     not retrieved from the database.
+     */
+    public Integer getObjectID() {
+        return objectID;
+    }
+
+    /**
+     * Sets the ID of this object to the given value.
+     *
+     * @param objectID
+     *     The ID to assign to this object.
+     */
+    public void setObjectID(Integer objectID) {
+        this.objectID = objectID;
+    }
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/base/RestrictedObject.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/base/RestrictedObject.java
new file mode 100644
index 0000000..d8828c4
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/base/RestrictedObject.java
@@ -0,0 +1,76 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.jdbc.base;
+
+import org.glyptodon.guacamole.auth.jdbc.user.AuthenticatedUser;
+
+/**
+ * Common base class for objects that are associated with the users that
+ * obtain them.
+ *
+ * @author Michael Jumper
+ */
+public abstract class RestrictedObject {
+
+    /**
+     * The user this object belongs to. Access is based on his/her permission
+     * settings.
+     */
+    private AuthenticatedUser currentUser;
+
+    /**
+     * Initializes this object, associating it with the current authenticated
+     * user and populating it with data from the given model object
+     *
+     * @param currentUser
+     *     The user that created or retrieved this object.
+     */
+    public void init(AuthenticatedUser currentUser) {
+        setCurrentUser(currentUser);
+    }
+
+    /**
+     * Returns the user that created or queried this object. This user's
+     * permissions dictate what operations can be performed on or through this
+     * object.
+     *
+     * @return
+     *     The user that created or queried this object.
+     */
+    public AuthenticatedUser getCurrentUser() {
+        return currentUser;
+    }
+
+    /**
+     * Sets the user that created or queried this object. This user's
+     * permissions dictate what operations can be performed on or through this
+     * object.
+     *
+     * @param currentUser 
+     *     The user that created or queried this object.
+     */
+    public void setCurrentUser(AuthenticatedUser currentUser) {
+        this.currentUser = currentUser;
+    }
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/base/package-info.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/base/package-info.java
new file mode 100644
index 0000000..122dc3e
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/base/package-info.java
@@ -0,0 +1,28 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Base classes supporting JDBC-driven authentication providers and defining
+ * the relationships between the model and the implementations of guacamole-ext
+ * classes.
+ */
+package org.glyptodon.guacamole.auth.jdbc.base;
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/connection/ConnectionDirectory.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/connection/ConnectionDirectory.java
new file mode 100644
index 0000000..2afc98b
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/connection/ConnectionDirectory.java
@@ -0,0 +1,89 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.jdbc.connection;
+
+
+import com.google.inject.Inject;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Set;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.auth.jdbc.base.RestrictedObject;
+import org.glyptodon.guacamole.net.auth.Connection;
+import org.glyptodon.guacamole.net.auth.Directory;
+import org.mybatis.guice.transactional.Transactional;
+
+/**
+ * Implementation of the Connection Directory which is driven by an underlying,
+ * arbitrary database.
+ *
+ * @author James Muehlner
+ * @author Michael Jumper
+ */
+public class ConnectionDirectory extends RestrictedObject
+    implements Directory<Connection> {
+
+    /**
+     * Service for managing connection objects.
+     */
+    @Inject
+    private ConnectionService connectionService;
+
+    @Override
+    public Connection get(String identifier) throws GuacamoleException {
+        return connectionService.retrieveObject(getCurrentUser(), identifier);
+    }
+
+    @Override
+    @Transactional
+    public Collection<Connection> getAll(Collection<String> identifiers) throws GuacamoleException {
+        Collection<ModeledConnection> objects = connectionService.retrieveObjects(getCurrentUser(), identifiers);
+        return Collections.<Connection>unmodifiableCollection(objects);
+    }
+
+    @Override
+    @Transactional
+    public Set<String> getIdentifiers() throws GuacamoleException {
+        return connectionService.getIdentifiers(getCurrentUser());
+    }
+
+    @Override
+    @Transactional
+    public void add(Connection object) throws GuacamoleException {
+        connectionService.createObject(getCurrentUser(), object);
+    }
+
+    @Override
+    @Transactional
+    public void update(Connection object) throws GuacamoleException {
+        ModeledConnection connection = (ModeledConnection) object;
+        connectionService.updateObject(getCurrentUser(), connection);
+    }
+
+    @Override
+    @Transactional
+    public void remove(String identifier) throws GuacamoleException {
+        connectionService.deleteObject(getCurrentUser(), identifier);
+    }
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/connection/ConnectionMapper.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/connection/ConnectionMapper.java
new file mode 100644
index 0000000..77c2904
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/connection/ConnectionMapper.java
@@ -0,0 +1,92 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.jdbc.connection;
+
+import java.util.Set;
+import org.glyptodon.guacamole.auth.jdbc.base.ModeledDirectoryObjectMapper;
+import org.glyptodon.guacamole.auth.jdbc.user.UserModel;
+import org.apache.ibatis.annotations.Param;
+
+/**
+ * Mapper for connection objects.
+ *
+ * @author Michael Jumper
+ */
+public interface ConnectionMapper extends ModeledDirectoryObjectMapper<ConnectionModel> {
+
+    /**
+     * Selects the identifiers of all connections within the given parent
+     * connection group, regardless of whether they are readable by any
+     * particular user. This should only be called on behalf of a system
+     * administrator. If identifiers are needed by a non-administrative user
+     * who must have explicit read rights, use
+     * selectReadableIdentifiersWithin() instead.
+     *
+     * @param parentIdentifier
+     *     The identifier of the parent connection group, or null if the root
+     *     connection group is to be queried.
+     *
+     * @return
+     *     A Set containing all identifiers of all objects.
+     */
+    Set<String> selectIdentifiersWithin(@Param("parentIdentifier") String parentIdentifier);
+    
+    /**
+     * Selects the identifiers of all connections within the given parent
+     * connection group that are explicitly readable by the given user. If
+     * identifiers are needed by a system administrator (who, by definition,
+     * does not need explicit read rights), use selectIdentifiersWithin()
+     * instead.
+     *
+     * @param user
+     *    The user whose permissions should determine whether an identifier
+     *    is returned.
+     *
+     * @param parentIdentifier
+     *     The identifier of the parent connection group, or null if the root
+     *     connection group is to be queried.
+     *
+     * @return
+     *     A Set containing all identifiers of all readable objects.
+     */
+    Set<String> selectReadableIdentifiersWithin(@Param("user") UserModel user,
+            @Param("parentIdentifier") String parentIdentifier);
+
+    /**
+     * Selects the connection within the given parent group and having the
+     * given name. If no such connection exists, null is returned.
+     *
+     * @param parentIdentifier
+     *     The identifier of the parent group to search within.
+     *
+     * @param name
+     *     The name of the connection to find.
+     *
+     * @return
+     *     The connection having the given name within the given parent group,
+     *     or null if no such connection exists.
+     */
+    ConnectionModel selectOneByName(@Param("parentIdentifier") String parentIdentifier,
+            @Param("name") String name);
+    
+}
\ No newline at end of file
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/connection/ConnectionModel.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/connection/ConnectionModel.java
new file mode 100644
index 0000000..f272f43
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/connection/ConnectionModel.java
@@ -0,0 +1,176 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.jdbc.connection;
+
+import org.glyptodon.guacamole.auth.jdbc.base.GroupedObjectModel;
+
+/**
+ * Object representation of a Guacamole connection, as represented in the
+ * database.
+ *
+ * @author Michael Jumper
+ */
+public class ConnectionModel extends GroupedObjectModel {
+
+    /**
+     * The human-readable name associated with this connection.
+     */
+    private String name;
+
+    /**
+     * The name of the protocol to use when connecting to this connection.
+     */
+    private String protocol;
+
+    /**
+     * The maximum number of connections that can be established to this
+     * connection concurrently, zero if no restriction applies, or null if the
+     * default restrictions should be applied.
+     */
+    private Integer maxConnections;
+
+    /**
+     * The maximum number of connections that can be established to this
+     * connection concurrently by any one user, zero if no restriction applies,
+     * or null if the default restrictions should be applied.
+     */
+    private Integer maxConnectionsPerUser;
+
+    /**
+     * Creates a new, empty connection.
+     */
+    public ConnectionModel() {
+    }
+
+    /**
+     * Returns the name associated with this connection.
+     *
+     * @return
+     *     The name associated with this connection.
+     */
+    public String getName() {
+        return name;
+    }
+
+    /**
+     * Sets the name associated with this connection.
+     *
+     * @param name
+     *     The name to associate with this connection.
+     */
+    public void setName(String name) {
+        this.name = name;
+    }
+
+    /**
+     * Returns the name of the protocol to use when connecting to this
+     * connection.
+     *
+     * @return
+     *     The name of the protocol to use when connecting to this connection.
+     */
+    public String getProtocol() {
+        return protocol;
+    }
+
+    /**
+     * Sets the name of the protocol to use when connecting to this connection.
+     *
+     * @param protocol
+     *     The name of the protocol to use when connecting to this connection.
+     */
+    public void setProtocol(String protocol) {
+        this.protocol = protocol;
+    }
+
+    /**
+     * Returns the maximum number of connections that can be established to
+     * this connection concurrently.
+     *
+     * @return
+     *     The maximum number of connections that can be established to this
+     *     connection concurrently, zero if no restriction applies, or null if
+     *     the default restrictions should be applied.
+     */
+    public Integer getMaxConnections() {
+        return maxConnections;
+    }
+
+    /**
+     * Sets the maximum number of connections that can be established to this
+     * connection concurrently.
+     *
+     * @param maxConnections
+     *     The maximum number of connections that can be established to this
+     *     connection concurrently, zero if no restriction applies, or null if
+     *     the default restrictions should be applied.
+     */
+    public void setMaxConnections(Integer maxConnections) {
+        this.maxConnections = maxConnections;
+    }
+
+    /**
+     * Returns the maximum number of connections that can be established to
+     * this connection concurrently by any one user.
+     *
+     * @return
+     *     The maximum number of connections that can be established to this
+     *     connection concurrently by any one user, zero if no restriction
+     *     applies, or null if the default restrictions should be applied.
+     */
+    public Integer getMaxConnectionsPerUser() {
+        return maxConnectionsPerUser;
+    }
+
+    /**
+     * Sets the maximum number of connections that can be established to this
+     * connection concurrently by any one user.
+     *
+     * @param maxConnectionsPerUser
+     *     The maximum number of connections that can be established to this
+     *     connection concurrently by any one user, zero if no restriction
+     *     applies, or null if the default restrictions should be applied.
+     */
+    public void setMaxConnectionsPerUser(Integer maxConnectionsPerUser) {
+        this.maxConnectionsPerUser = maxConnectionsPerUser;
+    }
+
+    @Override
+    public String getIdentifier() {
+
+        // If no associated ID, then no associated identifier
+        Integer id = getObjectID();
+        if (id == null)
+            return null;
+
+        // Otherwise, the identifier is the ID as a string
+        return id.toString();
+
+    }
+
+    @Override
+    public void setIdentifier(String identifier) {
+        throw new UnsupportedOperationException("Connection identifiers are derived from IDs. They cannot be set.");
+    }
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/connection/ConnectionRecordMapper.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/connection/ConnectionRecordMapper.java
new file mode 100644
index 0000000..e829e7a
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/connection/ConnectionRecordMapper.java
@@ -0,0 +1,116 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.jdbc.connection;
+
+import java.util.Collection;
+import java.util.List;
+import org.apache.ibatis.annotations.Param;
+import org.glyptodon.guacamole.auth.jdbc.user.UserModel;
+
+/**
+ * Mapper for connection record objects.
+ *
+ * @author Michael Jumper
+ */
+public interface ConnectionRecordMapper {
+
+    /**
+     * Returns a collection of all connection records associated with the
+     * connection having the given identifier.
+     *
+     * @param identifier
+     *     The identifier of the connection whose records are to be retrieved.
+     *
+     * @return
+     *     A collection of all connection records associated with the
+     *     connection having the given identifier. This collection will be
+     *     empty if no such connection exists.
+     */
+    List<ConnectionRecordModel> select(@Param("identifier") String identifier);
+
+    /**
+     * Inserts the given connection record.
+     *
+     * @param record
+     *     The connection record to insert.
+     *
+     * @return
+     *     The number of rows inserted.
+     */
+    int insert(@Param("record") ConnectionRecordModel record);
+
+    /**
+     * Searches for up to <code>limit</code> connection records that contain
+     * the given terms, sorted by the given predicates, regardless of whether
+     * the data they are associated with is is readable by any particular user.
+     * This should only be called on behalf of a system administrator. If
+     * records are needed by a non-administrative user who must have explicit
+     * read rights, use searchReadable() instead.
+     *
+     * @param terms
+     *     The search terms that must match the returned records.
+     *
+     * @param sortPredicates
+     *     A list of predicates to sort the returned records by, in order of
+     *     priority.
+     *
+     * @param limit
+     *     The maximum number of records that should be returned.
+     *
+     * @return
+     *     The results of the search performed with the given parameters.
+     */
+    List<ConnectionRecordModel> search(@Param("terms") Collection<ConnectionRecordSearchTerm> terms,
+            @Param("sortPredicates") List<ConnectionRecordSortPredicate> sortPredicates,
+            @Param("limit") int limit);
+
+    /**
+     * Searches for up to <code>limit</code> connection records that contain
+     * the given terms, sorted by the given predicates. Only records that are
+     * associated with data explicitly readable by the given user will be
+     * returned. If records are needed by a system administrator (who, by
+     * definition, does not need explicit read rights), use search() instead.
+     *
+     * @param user
+     *    The user whose permissions should determine whether a record is
+     *    returned.
+     *
+     * @param terms
+     *     The search terms that must match the returned records.
+     *
+     * @param sortPredicates
+     *     A list of predicates to sort the returned records by, in order of
+     *     priority.
+     *
+     * @param limit
+     *     The maximum number of records that should be returned.
+     *
+     * @return
+     *     The results of the search performed with the given parameters.
+     */
+    List<ConnectionRecordModel> searchReadable(@Param("user") UserModel user,
+            @Param("terms") Collection<ConnectionRecordSearchTerm> terms,
+            @Param("sortPredicates") List<ConnectionRecordSortPredicate> sortPredicates,
+            @Param("limit") int limit);
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/connection/ConnectionRecordModel.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/connection/ConnectionRecordModel.java
new file mode 100644
index 0000000..9ac157d
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/connection/ConnectionRecordModel.java
@@ -0,0 +1,201 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.jdbc.connection;
+
+import java.util.Date;
+
+/**
+ * A single connection record representing a past usage of a particular
+ * connection.
+ *
+ * @author Michael Jumper
+ */
+public class ConnectionRecordModel {
+
+    /**
+     * The identifier of the connection associated with this connection record.
+     */
+    private String connectionIdentifier;
+
+    /**
+     * The name of the connection associated with this connection record.
+     */
+    private String connectionName;
+
+    /**
+     * The database ID of the user associated with this connection record.
+     */
+    private Integer userID;
+
+    /**
+     * The username of the user associated with this connection record.
+     */
+    private String username;
+
+    /**
+     * The time the connection was initiated by the associated user.
+     */
+    private Date startDate;
+
+    /**
+     * The time the connection ended, or null if the end time is not known or
+     * the connection is still running.
+     */
+    private Date endDate;
+
+    /**
+     * Returns the identifier of the connection associated with this connection
+     * record.
+     *
+     * @return
+     *     The identifier of the connection associated with this connection
+     *     record.
+     */
+    public String getConnectionIdentifier() {
+        return connectionIdentifier;
+    }
+
+    /**
+     * Sets the identifier of the connection associated with this connection
+     * record.
+     *
+     * @param connectionIdentifier
+     *     The identifier of the connection to associate with this connection
+     *     record.
+     */
+    public void setConnectionIdentifier(String connectionIdentifier) {
+        this.connectionIdentifier = connectionIdentifier;
+    }
+
+
+    /**
+     * Returns the name of the connection associated with this connection
+     * record.
+     *
+     * @return
+     *     The name of the connection associated with this connection
+     *     record.
+     */
+    public String getConnectionName() {
+        return connectionName;
+    }
+
+
+    /**
+     * Sets the name of the connection associated with this connection
+     * record.
+     *
+     * @param connectionName
+     *     The name of the connection to associate with this connection
+     *     record.
+     */
+    public void setConnectionName(String connectionName) {
+        this.connectionName = connectionName;
+    }
+
+    /**
+     * Returns the database ID of the user associated with this connection
+     * record.
+     * 
+     * @return
+     *     The database ID of the user associated with this connection record.
+     */
+    public Integer getUserID() {
+        return userID;
+    }
+
+    /**
+     * Sets the database ID of the user associated with this connection record.
+     *
+     * @param userID
+     *     The database ID of the user to associate with this connection
+     *     record.
+     */
+    public void setUserID(Integer userID) {
+        this.userID = userID;
+    }
+
+    /**
+     * Returns the username of the user associated with this connection record.
+     * 
+     * @return
+     *     The username of the user associated with this connection record.
+     */
+    public String getUsername() {
+        return username;
+    }
+
+    /**
+     * Sets the username of the user associated with this connection record.
+     *
+     * @param username
+     *     The username of the user to associate with this connection record.
+     */
+    public void setUsername(String username) {
+        this.username = username;
+    }
+
+    /**
+     * Returns the date that the associated connection was established.
+     *
+     * @return
+     *     The date the associated connection was established.
+     */
+    public Date getStartDate() {
+        return startDate;
+    }
+
+    /**
+     * Sets the date that the associated connection was established.
+     *
+     * @param startDate
+     *     The date that the associated connection was established.
+     */
+    public void setStartDate(Date startDate) {
+        this.startDate = startDate;
+    }
+
+    /**
+     * Returns the date that the associated connection ended, or null if no
+     * end date was recorded. The lack of an end date does not necessarily
+     * mean that the connection is still active.
+     *
+     * @return
+     *     The date the associated connection ended, or null if no end date was
+     *     recorded.
+     */
+    public Date getEndDate() {
+        return endDate;
+    }
+
+    /**
+     * Sets the date that the associated connection ended.
+     *
+     * @param endDate
+     *     The date that the associated connection ended.
+     */
+    public void setEndDate(Date endDate) {
+        this.endDate = endDate;
+    }
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/connection/ConnectionRecordSearchTerm.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/connection/ConnectionRecordSearchTerm.java
new file mode 100644
index 0000000..a03d478
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/connection/ConnectionRecordSearchTerm.java
@@ -0,0 +1,296 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.jdbc.connection;
+
+import java.util.Calendar;
+import java.util.Date;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * A search term for querying historical connection records. This will contain
+ * a the search term in string form and, if that string appears to be a date. a
+ * corresponding date range.
+ *
+ * @author James Muehlner
+ */
+public class ConnectionRecordSearchTerm {
+    
+    /**
+     * A pattern that can match a year, year and month, or year and month and
+     * day.
+     */
+    private static final Pattern DATE_PATTERN = 
+            Pattern.compile("(\\d+)(?:-(\\d+)?(?:-(\\d+)?)?)?");
+
+    /**
+     * The index of the group within <code>DATE_PATTERN</code> containing the
+     * year number.
+     */
+    private static final int YEAR_GROUP = 1;
+
+    /**
+     * The index of the group within <code>DATE_PATTERN</code> containing the
+     * month number, if any.
+     */
+    private static final int MONTH_GROUP = 2;
+
+    /**
+     * The index of the group within <code>DATE_PATTERN</code> containing the
+     * day number, if any.
+     */
+    private static final int DAY_GROUP = 3;
+
+    /**
+     * The start of the date range for records that should be retrieved, if the
+     * provided search term appears to be a date.
+     */
+    private final Date startDate;
+    
+    /**
+     * The end of the date range for records that should be retrieved, if the
+     * provided search term appears to be a date.
+     */
+    private final Date endDate;
+    
+    /**
+     * The string that should be searched for.
+     */
+    private final String term;
+    
+    /**
+     * Parse the given string as an integer, returning the provided default
+     * value if the string is null.
+     * 
+     * @param str
+     *     The string to parse as an integer.
+     *
+     * @param defaultValue
+     *     The value to return if <code>str</code> is null.
+     * 
+     * @return
+     *     The parsed value, or the provided default value if <code>str</code>
+     *     is null.
+     */
+    private static int parseInt(String str, int defaultValue) {
+        
+        if (str == null)
+            return defaultValue;
+        
+        return Integer.parseInt(str);
+
+    }
+    
+    /**
+     * Returns a new calendar representing the last millisecond of the same
+     * year as <code>calendar</code>.
+     * 
+     * @param calendar
+     *     The calendar defining the year whose end (last millisecond) is to be
+     *     returned.
+     *
+     * @return
+     *     A new calendar representing the last millisecond of the same year as
+     *     <code>calendar</code>.
+     */
+    private static Calendar getEndOfYear(Calendar calendar) {
+
+        // Get first day of next year
+        Calendar endOfYear = Calendar.getInstance();
+        endOfYear.clear();
+        endOfYear.set(Calendar.YEAR, calendar.get(Calendar.YEAR) + 1);
+
+        // Transform into the last millisecond of the given year
+        endOfYear.add(Calendar.MILLISECOND, -1);
+
+        return endOfYear;
+
+    }
+    
+    /**
+     * Returns a new calendar representing the last millisecond of the same
+     * month and year as <code>calendar</code>.
+     * 
+     * @param calendar
+     *     The calendar defining the month and year whose end (last millisecond) 
+     *     is to be returned.
+     *
+     * @return
+     *     A new calendar representing the last millisecond of the same month 
+     *     and year as <code>calendar</code>.
+     */
+    private static Calendar getEndOfMonth(Calendar calendar) {
+
+        // Copy given calender only up to given month
+        Calendar endOfMonth = Calendar.getInstance();
+        endOfMonth.clear();
+        endOfMonth.set(Calendar.YEAR,  calendar.get(Calendar.YEAR));
+        endOfMonth.set(Calendar.MONTH, calendar.get(Calendar.MONTH));
+
+        // Advance to the last millisecond of the given month
+        endOfMonth.add(Calendar.MONTH,        1);
+        endOfMonth.add(Calendar.MILLISECOND, -1);
+
+        return endOfMonth;
+
+    }
+    
+    /**
+     * Returns a new calendar representing the last millisecond of the same
+     * year, month, and day as <code>calendar</code>.
+     * 
+     * @param calendar
+     *     The calendar defining the year, month, and day whose end 
+     *     (last millisecond) is to be returned.
+     *
+     * @return
+     *     A new calendar representing the last millisecond of the same year, 
+     *     month, and day as <code>calendar</code>.
+     */
+    private static Calendar getEndOfDay(Calendar calendar) {
+
+        // Copy given calender only up to given month
+        Calendar endOfMonth = Calendar.getInstance();
+        endOfMonth.clear();
+        endOfMonth.set(Calendar.YEAR,         calendar.get(Calendar.YEAR));
+        endOfMonth.set(Calendar.MONTH,        calendar.get(Calendar.MONTH));
+        endOfMonth.set(Calendar.DAY_OF_MONTH, calendar.get(Calendar.DAY_OF_MONTH));
+
+        // Advance to the last millisecond of the given day
+        endOfMonth.add(Calendar.DAY_OF_MONTH, 1);
+        endOfMonth.add(Calendar.MILLISECOND, -1);
+
+        return endOfMonth;
+
+    }
+
+    /**
+     * Creates a new ConnectionRecordSearchTerm representing the given string.
+     * If the given string appears to be a date, the start and end dates of the
+     * implied date range will be automatically determined and made available
+     * via getStartDate() and getEndDate() respectively.
+     *
+     * @param term
+     *     The string that should be searched for.
+     */
+    public ConnectionRecordSearchTerm(String term) {
+
+        // Search terms absolutely must not be null
+        if (term == null)
+            throw new NullPointerException("Search terms may not be null");
+
+        this.term = term;
+
+        // Parse start/end of date range if term appears to be a date
+        Matcher matcher = DATE_PATTERN.matcher(term);
+        if (matcher.matches()) {
+
+            // Retrieve date components from term
+            String year  = matcher.group(YEAR_GROUP);
+            String month = matcher.group(MONTH_GROUP);
+            String day   = matcher.group(DAY_GROUP);
+            
+            // Parse start date from term
+            Calendar startCalendar = Calendar.getInstance();
+            startCalendar.clear();
+            startCalendar.set(
+                Integer.parseInt(year),
+                parseInt(month, 1) - 1,
+                parseInt(day,   1)
+            );
+
+            Calendar endCalendar;
+
+            // Derive end date from start date
+            if (month == null) {
+                endCalendar = getEndOfYear(startCalendar);
+            }
+            else if (day == null) {
+                endCalendar = getEndOfMonth(startCalendar);
+            }
+            else {
+                endCalendar = getEndOfDay(startCalendar);
+            }
+
+            // Convert results back into dates
+            this.startDate = startCalendar.getTime();
+            this.endDate   = endCalendar.getTime();
+            
+        }
+
+        // The search term doesn't look like a date
+        else {
+            this.startDate = null;
+            this.endDate   = null;
+        }
+
+    }
+
+    /**
+     * Returns the start of the date range for records that should be retrieved, 
+     * if the provided search term appears to be a date.
+     * 
+     * @return
+     *     The start of the date range.
+     */
+    public Date getStartDate() {
+        return startDate;
+    }
+
+    /**
+     * Returns the end of the date range for records that should be retrieved, 
+     * if the provided search term appears to be a date.
+     * 
+     * @return
+     *     The end of the date range.
+     */
+    public Date getEndDate() {
+        return endDate;
+    }
+
+    /**
+     * Returns the string that should be searched for.
+     * 
+     * @return
+     *     The search term.
+     */
+    public String getTerm() {
+        return term;
+    }
+
+    @Override
+    public int hashCode() {
+        return term.hashCode();
+    }
+
+    @Override
+    public boolean equals(Object obj) {
+
+        if (obj == null || !(obj instanceof ConnectionRecordSearchTerm))
+            return false;
+
+        return ((ConnectionRecordSearchTerm) obj).getTerm().equals(getTerm());
+
+    }
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/connection/ConnectionRecordSet.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/connection/ConnectionRecordSet.java
new file mode 100644
index 0000000..f9d00a1
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/connection/ConnectionRecordSet.java
@@ -0,0 +1,108 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.jdbc.connection;
+
+import com.google.inject.Inject;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.auth.jdbc.base.RestrictedObject;
+import org.glyptodon.guacamole.net.auth.ConnectionRecord;
+
+/**
+ * A JDBC implementation of ConnectionRecordSet. Calls to asCollection() will 
+ * query connection history records from the database. Which records are
+ * returned will be determined by the values passed in earlier.
+ * 
+ * @author James Muehlner
+ */
+public class ConnectionRecordSet extends RestrictedObject
+        implements org.glyptodon.guacamole.net.auth.ConnectionRecordSet {
+
+    /**
+     * Service for managing connection objects.
+     */
+    @Inject
+    private ConnectionService connectionService;
+    
+    /**
+     * The set of strings that each must occur somewhere within the returned 
+     * connection records, whether within the associated username, the name of 
+     * the associated connection, or any associated date. If non-empty, any 
+     * connection record not matching each of the strings within the collection 
+     * will be excluded from the results.
+     */
+    private final Set<ConnectionRecordSearchTerm> requiredContents = 
+            new HashSet<ConnectionRecordSearchTerm>();
+    
+    /**
+     * The maximum number of connection history records that should be returned
+     * by a call to asCollection().
+     */
+    private int limit = Integer.MAX_VALUE;
+    
+    /**
+     * A list of predicates to apply while sorting the resulting connection
+     * records, describing the properties involved and the sort order for those 
+     * properties.
+     */
+    private final List<ConnectionRecordSortPredicate> connectionRecordSortPredicates =
+            new ArrayList<ConnectionRecordSortPredicate>();
+    
+    @Override
+    public Collection<ConnectionRecord> asCollection()
+            throws GuacamoleException {
+        return connectionService.retrieveHistory(getCurrentUser(),
+                requiredContents, connectionRecordSortPredicates, limit);
+    }
+
+    @Override
+    public ConnectionRecordSet contains(String value)
+            throws GuacamoleException {
+        requiredContents.add(new ConnectionRecordSearchTerm(value));
+        return this;
+    }
+
+    @Override
+    public ConnectionRecordSet limit(int limit) throws GuacamoleException {
+        this.limit = Math.min(this.limit, limit);
+        return this;
+    }
+
+    @Override
+    public ConnectionRecordSet sort(SortableProperty property, boolean desc)
+            throws GuacamoleException {
+        
+        connectionRecordSortPredicates.add(new ConnectionRecordSortPredicate(
+            property,
+            desc
+        ));
+        
+        return this;
+
+    }
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/connection/ConnectionRecordSortPredicate.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/connection/ConnectionRecordSortPredicate.java
new file mode 100644
index 0000000..279321b
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/connection/ConnectionRecordSortPredicate.java
@@ -0,0 +1,82 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.jdbc.connection;
+
+import org.glyptodon.guacamole.net.auth.ConnectionRecordSet;
+
+/**
+ * A sort predicate which species the property to use when sorting connection
+ * records, along with the sort order.
+ *
+ * @author James Muehlner
+ */
+public class ConnectionRecordSortPredicate {
+
+    /**
+     * The property to use when sorting ConnectionRecords.
+     */
+    private final ConnectionRecordSet.SortableProperty property;
+
+    /**
+     * Whether the sort order is descending (true) or ascending (false).
+     */
+    private final boolean descending;
+    
+    /**
+     * Creates a new ConnectionRecordSortPredicate with the given sort property 
+     * and sort order.
+     * 
+     * @param property 
+     *     The property to use when sorting ConnectionRecords.
+     * 
+     * @param descending 
+     *     Whether the sort order is descending (true) or ascending (false).
+     */
+    public ConnectionRecordSortPredicate(ConnectionRecordSet.SortableProperty property, 
+            boolean descending) {
+        this.property   = property;
+        this.descending = descending;
+    }
+    
+    /**
+     * Returns the property that should be used when sorting ConnectionRecords.
+     *
+     * @return
+     *     The property that should be used when sorting ConnectionRecords.
+     */
+    public ConnectionRecordSet.SortableProperty getProperty() {
+        return property;
+    }
+
+    /**
+     * Returns whether the sort order is descending.
+     *
+     * @return
+     *     true if the sort order is descending, false if the sort order is
+     *     ascending.
+     */
+    public boolean isDescending() {
+        return descending;
+    }
+    
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/connection/ConnectionService.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/connection/ConnectionService.java
new file mode 100644
index 0000000..5d8d82a
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/connection/ConnectionService.java
@@ -0,0 +1,511 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.jdbc.connection;
+
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.glyptodon.guacamole.auth.jdbc.user.AuthenticatedUser;
+import org.glyptodon.guacamole.auth.jdbc.base.ModeledDirectoryObjectMapper;
+import org.glyptodon.guacamole.auth.jdbc.tunnel.GuacamoleTunnelService;
+import org.glyptodon.guacamole.GuacamoleClientException;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.GuacamoleSecurityException;
+import org.glyptodon.guacamole.auth.jdbc.base.ModeledGroupedDirectoryObjectService;
+import org.glyptodon.guacamole.auth.jdbc.permission.ConnectionPermissionMapper;
+import org.glyptodon.guacamole.auth.jdbc.permission.ObjectPermissionMapper;
+import org.glyptodon.guacamole.net.GuacamoleTunnel;
+import org.glyptodon.guacamole.net.auth.Connection;
+import org.glyptodon.guacamole.net.auth.ConnectionRecord;
+import org.glyptodon.guacamole.net.auth.permission.ObjectPermission;
+import org.glyptodon.guacamole.net.auth.permission.ObjectPermissionSet;
+import org.glyptodon.guacamole.net.auth.permission.SystemPermission;
+import org.glyptodon.guacamole.net.auth.permission.SystemPermissionSet;
+import org.glyptodon.guacamole.protocol.GuacamoleClientInformation;
+
+/**
+ * Service which provides convenience methods for creating, retrieving, and
+ * manipulating connections.
+ *
+ * @author Michael Jumper, James Muehlner
+ */
+public class ConnectionService extends ModeledGroupedDirectoryObjectService<ModeledConnection, Connection, ConnectionModel> {
+
+    /**
+     * Mapper for accessing connections.
+     */
+    @Inject
+    private ConnectionMapper connectionMapper;
+
+    /**
+     * Mapper for manipulating connection permissions.
+     */
+    @Inject
+    private ConnectionPermissionMapper connectionPermissionMapper;
+    
+    /**
+     * Mapper for accessing connection parameters.
+     */
+    @Inject
+    private ParameterMapper parameterMapper;
+
+    /**
+     * Mapper for accessing connection history.
+     */
+    @Inject
+    private ConnectionRecordMapper connectionRecordMapper;
+
+    /**
+     * Provider for creating connections.
+     */
+    @Inject
+    private Provider<ModeledConnection> connectionProvider;
+
+    /**
+     * Service for creating and tracking tunnels.
+     */
+    @Inject
+    private GuacamoleTunnelService tunnelService;
+    
+    @Override
+    protected ModeledDirectoryObjectMapper<ConnectionModel> getObjectMapper() {
+        return connectionMapper;
+    }
+
+    @Override
+    protected ObjectPermissionMapper getPermissionMapper() {
+        return connectionPermissionMapper;
+    }
+
+    @Override
+    protected ModeledConnection getObjectInstance(AuthenticatedUser currentUser,
+            ConnectionModel model) {
+        ModeledConnection connection = connectionProvider.get();
+        connection.init(currentUser, model);
+        return connection;
+    }
+
+    @Override
+    protected ConnectionModel getModelInstance(AuthenticatedUser currentUser,
+            final Connection object) {
+
+        // Create new ModeledConnection backed by blank model
+        ConnectionModel model = new ConnectionModel();
+        ModeledConnection connection = getObjectInstance(currentUser, model);
+
+        // Set model contents through ModeledConnection, copying the provided connection
+        connection.setParentIdentifier(object.getParentIdentifier());
+        connection.setName(object.getName());
+        connection.setConfiguration(object.getConfiguration());
+        connection.setAttributes(object.getAttributes());
+
+        return model;
+        
+    }
+
+    @Override
+    protected boolean hasCreatePermission(AuthenticatedUser user)
+            throws GuacamoleException {
+
+        // Return whether user has explicit connection creation permission
+        SystemPermissionSet permissionSet = user.getUser().getSystemPermissions();
+        return permissionSet.hasPermission(SystemPermission.Type.CREATE_CONNECTION);
+
+    }
+
+    @Override
+    protected ObjectPermissionSet getPermissionSet(AuthenticatedUser user)
+            throws GuacamoleException {
+
+        // Return permissions related to connections 
+        return user.getUser().getConnectionPermissions();
+
+    }
+
+    @Override
+    protected void beforeCreate(AuthenticatedUser user,
+            ConnectionModel model) throws GuacamoleException {
+
+        super.beforeCreate(user, model);
+        
+        // Name must not be blank
+        if (model.getName() == null || model.getName().trim().isEmpty())
+            throw new GuacamoleClientException("Connection names must not be blank.");
+
+        // Do not attempt to create duplicate connections
+        ConnectionModel existing = connectionMapper.selectOneByName(model.getParentIdentifier(), model.getName());
+        if (existing != null)
+            throw new GuacamoleClientException("The connection \"" + model.getName() + "\" already exists.");
+
+    }
+
+    @Override
+    protected void beforeUpdate(AuthenticatedUser user,
+            ConnectionModel model) throws GuacamoleException {
+
+        super.beforeUpdate(user, model);
+        
+        // Name must not be blank
+        if (model.getName() == null || model.getName().trim().isEmpty())
+            throw new GuacamoleClientException("Connection names must not be blank.");
+        
+        // Check whether such a connection is already present
+        ConnectionModel existing = connectionMapper.selectOneByName(model.getParentIdentifier(), model.getName());
+        if (existing != null) {
+
+            // If the specified name matches a DIFFERENT existing connection, the update cannot continue
+            if (!existing.getObjectID().equals(model.getObjectID()))
+                throw new GuacamoleClientException("The connection \"" + model.getName() + "\" already exists.");
+
+        }
+
+    }
+
+    /**
+     * Given an arbitrary Guacamole connection, produces a collection of
+     * parameter model objects containing the name/value pairs of that
+     * connection's parameters.
+     *
+     * @param connection
+     *     The connection whose configuration should be used to produce the
+     *     collection of parameter models.
+     *
+     * @return
+     *     A collection of parameter models containing the name/value pairs
+     *     of the given connection's parameters.
+     */
+    private Collection<ParameterModel> getParameterModels(ModeledConnection connection) {
+
+        Map<String, String> parameters = connection.getConfiguration().getParameters();
+        
+        // Convert parameters to model objects
+        Collection<ParameterModel> parameterModels = new ArrayList<ParameterModel>(parameters.size());
+        for (Map.Entry<String, String> parameterEntry : parameters.entrySet()) {
+
+            // Get parameter name and value
+            String name = parameterEntry.getKey();
+            String value = parameterEntry.getValue();
+
+            // There is no need to insert empty parameters
+            if (value == null || value.isEmpty())
+                continue;
+            
+            // Produce model object from parameter
+            ParameterModel model = new ParameterModel();
+            model.setConnectionIdentifier(connection.getIdentifier());
+            model.setName(name);
+            model.setValue(value);
+
+            // Add model to list
+            parameterModels.add(model);
+            
+        }
+
+        return parameterModels;
+
+    }
+
+    @Override
+    public ModeledConnection createObject(AuthenticatedUser user, Connection object)
+            throws GuacamoleException {
+
+        // Create connection
+        ModeledConnection connection = super.createObject(user, object);
+        connection.setConfiguration(object.getConfiguration());
+
+        // Insert new parameters, if any
+        Collection<ParameterModel> parameterModels = getParameterModels(connection);
+        if (!parameterModels.isEmpty())
+            parameterMapper.insert(parameterModels);
+
+        return connection;
+
+    }
+    
+    @Override
+    public void updateObject(AuthenticatedUser user, ModeledConnection object)
+            throws GuacamoleException {
+
+        // Update connection
+        super.updateObject(user, object);
+
+        // Replace existing parameters with new parameters, if any
+        Collection<ParameterModel> parameterModels = getParameterModels(object);
+        parameterMapper.delete(object.getIdentifier());
+        if (!parameterModels.isEmpty())
+            parameterMapper.insert(parameterModels);
+        
+    }
+
+    /**
+     * Returns the set of all identifiers for all connections within the
+     * connection group having the given identifier. Only connections that the
+     * user has read access to will be returned.
+     * 
+     * Permission to read the connection group having the given identifier is
+     * NOT checked.
+     *
+     * @param user
+     *     The user retrieving the identifiers.
+     * 
+     * @param identifier
+     *     The identifier of the parent connection group, or null to check the
+     *     root connection group.
+     *
+     * @return
+     *     The set of all identifiers for all connections in the connection
+     *     group having the given identifier that the user has read access to.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while reading identifiers.
+     */
+    public Set<String> getIdentifiersWithin(AuthenticatedUser user,
+            String identifier)
+            throws GuacamoleException {
+
+        // Bypass permission checks if the user is a system admin
+        if (user.getUser().isAdministrator())
+            return connectionMapper.selectIdentifiersWithin(identifier);
+
+        // Otherwise only return explicitly readable identifiers
+        else
+            return connectionMapper.selectReadableIdentifiersWithin(user.getUser().getModel(), identifier);
+
+    }
+
+    /**
+     * Retrieves all parameters visible to the given user and associated with
+     * the connection having the given identifier. If the given user has no
+     * access to such parameters, or no such connection exists, the returned
+     * map will be empty.
+     *
+     * @param user
+     *     The user retrieving connection parameters.
+     *
+     * @param identifier
+     *     The identifier of the connection whose parameters are being
+     *     retrieved.
+     *
+     * @return
+     *     A new map of all parameter name/value pairs that the given user has
+     *     access to.
+     */
+    public Map<String, String> retrieveParameters(AuthenticatedUser user,
+            String identifier) {
+
+        Map<String, String> parameterMap = new HashMap<String, String>();
+
+        // Determine whether we have permission to read parameters
+        boolean canRetrieveParameters;
+        try {
+            canRetrieveParameters = hasObjectPermission(user, identifier,
+                    ObjectPermission.Type.UPDATE);
+        }
+
+        // Provide empty (but mutable) map if unable to check permissions
+        catch (GuacamoleException e) {
+            return parameterMap;
+        }
+
+        // Populate parameter map if we have permission to do so
+        if (canRetrieveParameters) {
+            for (ParameterModel parameter : parameterMapper.select(identifier))
+                parameterMap.put(parameter.getName(), parameter.getValue());
+        }
+
+        return parameterMap;
+
+    }
+
+    /**
+     * Returns a connection records object which is backed by the given model.
+     *
+     * @param model
+     *     The model object to use to back the returned connection record
+     *     object.
+     *
+     * @return
+     *     A connection record object which is backed by the given model.
+     */
+    protected ConnectionRecord getObjectInstance(ConnectionRecordModel model) {
+        return new ModeledConnectionRecord(model);
+    }
+
+    /**
+     * Returns a list of connection records objects which are backed by the
+     * models in the given list.
+     *
+     * @param models
+     *     The model objects to use to back the connection record objects
+     *     within the returned list.
+     *
+     * @return
+     *     A list of connection record objects which are backed by the models
+     *     in the given list.
+     */
+    protected List<ConnectionRecord> getObjectInstances(List<ConnectionRecordModel> models) {
+
+        // Create new list of records by manually converting each model
+        List<ConnectionRecord> objects = new ArrayList<ConnectionRecord>(models.size());
+        for (ConnectionRecordModel model : models)
+            objects.add(getObjectInstance(model));
+
+        return objects;
+ 
+    }
+
+    /**
+     * Retrieves the connection history of the given connection, including any
+     * active connections.
+     *
+     * @param user
+     *     The user retrieving the connection history.
+     *
+     * @param connection
+     *     The connection whose history is being retrieved.
+     *
+     * @return
+     *     The connection history of the given connection, including any
+     *     active connections.
+     *
+     * @throws GuacamoleException
+     *     If permission to read the connection history is denied.
+     */
+    public List<ConnectionRecord> retrieveHistory(AuthenticatedUser user,
+            ModeledConnection connection) throws GuacamoleException {
+
+        String identifier = connection.getIdentifier();
+
+        // Retrieve history only if READ permission is granted
+        if (hasObjectPermission(user, identifier, ObjectPermission.Type.READ)) {
+
+            // Retrieve history
+            List<ConnectionRecordModel> models = connectionRecordMapper.select(identifier);
+
+            // Get currently-active connections
+            List<ConnectionRecord> records = new ArrayList<ConnectionRecord>(tunnelService.getActiveConnections(connection));
+            Collections.reverse(records);
+
+            // Add past connections from model objects
+            for (ConnectionRecordModel model : models)
+                records.add(getObjectInstance(model));
+
+            // Return converted history list
+            return records;
+
+        }
+
+        // The user does not have permission to read the history
+        throw new GuacamoleSecurityException("Permission denied.");
+
+    }
+
+    /**
+     * Retrieves the connection history records matching the given criteria.
+     * Retrieves up to <code>limit</code> connection history records matching
+     * the given terms and sorted by the given predicates. Only history records
+     * associated with data that the given user can read are returned.
+     *
+     * @param user
+     *     The user retrieving the connection history.
+     *
+     * @param requiredContents
+     *     The search terms that must be contained somewhere within each of the
+     *     returned records.
+     *
+     * @param sortPredicates
+     *     A list of predicates to sort the returned records by, in order of
+     *     priority.
+     *
+     * @param limit
+     *     The maximum number of records that should be returned.
+     *
+     * @return
+     *     The connection history of the given connection, including any
+     *     active connections.
+     *
+     * @throws GuacamoleException
+     *     If permission to read the connection history is denied.
+     */
+    public List<ConnectionRecord> retrieveHistory(AuthenticatedUser user,
+            Collection<ConnectionRecordSearchTerm> requiredContents,
+            List<ConnectionRecordSortPredicate> sortPredicates, int limit)
+            throws GuacamoleException {
+
+        List<ConnectionRecordModel> searchResults;
+
+        // Bypass permission checks if the user is a system admin
+        if (user.getUser().isAdministrator())
+            searchResults = connectionRecordMapper.search(requiredContents,
+                    sortPredicates, limit);
+
+        // Otherwise only return explicitly readable history records
+        else
+            searchResults = connectionRecordMapper.searchReadable(user.getUser().getModel(),
+                    requiredContents, sortPredicates, limit);
+
+        return getObjectInstances(searchResults);
+
+    }
+
+    /**
+     * Connects to the given connection as the given user, using the given
+     * client information. If the user does not have permission to read the
+     * connection, permission will be denied.
+     *
+     * @param user
+     *     The user connecting to the connection.
+     *
+     * @param connection
+     *     The connection being connected to.
+     *
+     * @param info
+     *     Information associated with the connecting client.
+     *
+     * @return
+     *     A connected GuacamoleTunnel associated with a newly-established
+     *     connection.
+     *
+     * @throws GuacamoleException
+     *     If permission to connect to this connection is denied.
+     */
+    public GuacamoleTunnel connect(AuthenticatedUser user,
+            ModeledConnection connection, GuacamoleClientInformation info)
+            throws GuacamoleException {
+
+        // Connect only if READ permission is granted
+        if (hasObjectPermission(user, connection.getIdentifier(), ObjectPermission.Type.READ))
+            return tunnelService.getGuacamoleTunnel(user, connection, info);
+
+        // The user does not have permission to connect
+        throw new GuacamoleSecurityException("Permission denied.");
+
+    }
+    
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/connection/ModeledConnection.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/connection/ModeledConnection.java
new file mode 100644
index 0000000..bb5e020
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/connection/ModeledConnection.java
@@ -0,0 +1,259 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.jdbc.connection;
+
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.glyptodon.guacamole.auth.jdbc.tunnel.GuacamoleTunnelService;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.auth.jdbc.JDBCEnvironment;
+import org.glyptodon.guacamole.auth.jdbc.base.ModeledGroupedDirectoryObject;
+import org.glyptodon.guacamole.form.Field;
+import org.glyptodon.guacamole.form.Form;
+import org.glyptodon.guacamole.form.NumericField;
+import org.glyptodon.guacamole.net.GuacamoleTunnel;
+import org.glyptodon.guacamole.net.auth.Connection;
+import org.glyptodon.guacamole.net.auth.ConnectionRecord;
+import org.glyptodon.guacamole.protocol.GuacamoleClientInformation;
+import org.glyptodon.guacamole.protocol.GuacamoleConfiguration;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * An implementation of the Connection object which is backed by a database
+ * model.
+ *
+ * @author James Muehlner
+ * @author Michael Jumper
+ */
+public class ModeledConnection extends ModeledGroupedDirectoryObject<ConnectionModel>
+    implements Connection {
+
+    /**
+     * Logger for this class.
+     */
+    private static final Logger logger = LoggerFactory.getLogger(ModeledConnection.class);
+
+    /**
+     * The name of the attribute which controls the maximum number of
+     * concurrent connections.
+     */
+    public static final String MAX_CONNECTIONS_NAME = "max-connections";
+
+    /**
+     * The name of the attribute which controls the maximum number of
+     * concurrent connections per user.
+     */
+    public static final String MAX_CONNECTIONS_PER_USER_NAME = "max-connections-per-user";
+
+    /**
+     * All attributes related to restricting user accounts, within a logical
+     * form.
+     */
+    public static final Form CONCURRENCY_LIMITS = new Form("concurrency", Arrays.<Field>asList(
+        new NumericField(MAX_CONNECTIONS_NAME),
+        new NumericField(MAX_CONNECTIONS_PER_USER_NAME)
+    ));
+
+    /**
+     * All possible attributes of connection objects organized as individual,
+     * logical forms.
+     */
+    public static final Collection<Form> ATTRIBUTES = Collections.unmodifiableCollection(Arrays.asList(
+        CONCURRENCY_LIMITS
+    ));
+
+    /**
+     * The environment of the Guacamole server.
+     */
+    @Inject
+    private JDBCEnvironment environment;
+
+    /**
+     * Service for managing connections.
+     */
+    @Inject
+    private ConnectionService connectionService;
+
+    /**
+     * Service for creating and tracking tunnels.
+     */
+    @Inject
+    private GuacamoleTunnelService tunnelService;
+
+    /**
+     * Provider for lazy-loaded, permission-controlled configurations.
+     */
+    @Inject
+    private Provider<ModeledGuacamoleConfiguration> configProvider;
+    
+    /**
+     * The manually-set GuacamoleConfiguration, if any.
+     */
+    private GuacamoleConfiguration config = null;
+
+    /**
+     * Creates a new, empty ModeledConnection.
+     */
+    public ModeledConnection() {
+    }
+
+    @Override
+    public String getName() {
+        return getModel().getName();
+    }
+
+    @Override
+    public void setName(String name) {
+        getModel().setName(name);
+    }
+
+    @Override
+    public GuacamoleConfiguration getConfiguration() {
+
+        // If configuration has been manually set, return that
+        if (config != null)
+            return config;
+
+        // Otherwise, return permission-controlled configuration
+        ModeledGuacamoleConfiguration restrictedConfig = configProvider.get();
+        restrictedConfig.init(getCurrentUser(), getModel());
+        return restrictedConfig;
+
+    }
+
+    @Override
+    public void setConfiguration(GuacamoleConfiguration config) {
+
+        // Store manually-set configuration internally
+        this.config = config;
+
+        // Update model
+        getModel().setProtocol(config.getProtocol());
+        
+    }
+
+    @Override
+    public List<? extends ConnectionRecord> getHistory() throws GuacamoleException {
+        return connectionService.retrieveHistory(getCurrentUser(), this);
+    }
+
+    @Override
+    public GuacamoleTunnel connect(GuacamoleClientInformation info) throws GuacamoleException {
+        return connectionService.connect(getCurrentUser(), this, info);
+    }
+
+    @Override
+    public int getActiveConnections() {
+        return tunnelService.getActiveConnections(this).size();
+    }
+
+    @Override
+    public Map<String, String> getAttributes() {
+
+        Map<String, String> attributes = new HashMap<String, String>();
+
+        // Set connection limit attribute
+        attributes.put(MAX_CONNECTIONS_NAME, NumericField.format(getModel().getMaxConnections()));
+
+        // Set per-user connection limit attribute
+        attributes.put(MAX_CONNECTIONS_PER_USER_NAME, NumericField.format(getModel().getMaxConnectionsPerUser()));
+
+        return attributes;
+    }
+
+    @Override
+    public void setAttributes(Map<String, String> attributes) {
+
+        // Translate connection limit attribute
+        try { getModel().setMaxConnections(NumericField.parse(attributes.get(MAX_CONNECTIONS_NAME))); }
+        catch (NumberFormatException e) {
+            logger.warn("Not setting maximum connections: {}", e.getMessage());
+            logger.debug("Unable to parse numeric attribute.", e);
+        }
+
+        // Translate per-user connection limit attribute
+        try { getModel().setMaxConnectionsPerUser(NumericField.parse(attributes.get(MAX_CONNECTIONS_PER_USER_NAME))); }
+        catch (NumberFormatException e) {
+            logger.warn("Not setting maximum connections per user: {}", e.getMessage());
+            logger.debug("Unable to parse numeric attribute.", e);
+        }
+
+    }
+
+    /**
+     * Returns the maximum number of connections that should be allowed to this
+     * connection overall. If no limit applies, zero is returned.
+     *
+     * @return
+     *     The maximum number of connections that should be allowed to this
+     *     connection overall, or zero if no limit applies.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while parsing the concurrency limit properties
+     *     specified within guacamole.properties.
+     */
+    public int getMaxConnections() throws GuacamoleException {
+
+        // Pull default from environment if connection limit is unset
+        Integer value = getModel().getMaxConnections();
+        if (value == null)
+            return environment.getDefaultMaxConnections();
+
+        // Otherwise use defined value
+        return value;
+
+    }
+
+    /**
+     * Returns the maximum number of connections that should be allowed to this
+     * connection for any individual user. If no limit applies, zero is
+     * returned.
+     *
+     * @return
+     *     The maximum number of connections that should be allowed to this
+     *     connection for any individual user, or zero if no limit applies.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while parsing the concurrency limit properties
+     *     specified within guacamole.properties.
+     */
+    public int getMaxConnectionsPerUser() throws GuacamoleException {
+
+        // Pull default from environment if per-user connection limit is unset
+        Integer value = getModel().getMaxConnectionsPerUser();
+        if (value == null)
+            return environment.getDefaultMaxConnectionsPerUser();
+
+        // Otherwise use defined value
+        return value;
+
+    }
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/connection/ModeledConnectionRecord.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/connection/ModeledConnectionRecord.java
new file mode 100644
index 0000000..dbd9b0b
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/connection/ModeledConnectionRecord.java
@@ -0,0 +1,89 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.jdbc.connection;
+
+
+import java.util.Date;
+import org.glyptodon.guacamole.net.auth.ConnectionRecord;
+
+/**
+ * A ConnectionRecord which is backed by a database model.
+ *
+ * @author James Muehlner
+ * @author Michael Jumper
+ */
+public class ModeledConnectionRecord implements ConnectionRecord {
+
+    /**
+     * The model object backing this connection record.
+     */
+    private final ConnectionRecordModel model;
+
+    /**
+     * Creates a new ModeledConnectionRecord backed by the given model object.
+     * Changes to this record will affect the backing model object, and changes
+     * to the backing model object will affect this record.
+     * 
+     * @param model
+     *     The model object to use to back this connection record.
+     */
+    public ModeledConnectionRecord(ConnectionRecordModel model) {
+        this.model = model;
+    }
+
+    @Override
+    public String getConnectionIdentifier() {
+        return model.getConnectionIdentifier();
+    }
+
+    @Override
+    public String getConnectionName() {
+        return model.getConnectionName();
+    }
+
+    @Override
+    public Date getStartDate() {
+        return model.getStartDate();
+    }
+
+    @Override
+    public Date getEndDate() {
+        return model.getEndDate();
+    }
+
+    @Override
+    public String getRemoteHost() {
+        return null;
+    }
+
+    @Override
+    public String getUsername() {
+        return model.getUsername();
+    }
+
+    @Override
+    public boolean isActive() {
+        return false;
+    }
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/connection/ModeledGuacamoleConfiguration.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/connection/ModeledGuacamoleConfiguration.java
new file mode 100644
index 0000000..b88a936
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/connection/ModeledGuacamoleConfiguration.java
@@ -0,0 +1,120 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.jdbc.connection;
+
+import com.google.inject.Inject;
+import java.util.Map;
+import org.glyptodon.guacamole.auth.jdbc.user.AuthenticatedUser;
+import org.glyptodon.guacamole.protocol.GuacamoleConfiguration;
+
+/**
+ * Implementation of GuacamoleConfiguration which loads parameter values only
+ * if necessary, and only if allowed.
+ *
+ * @author Michael Jumper
+ */
+public class ModeledGuacamoleConfiguration extends GuacamoleConfiguration {
+
+    /**
+     * The user this configuration belongs to. Access is based on his/her
+     * permission settings.
+     */
+    private AuthenticatedUser currentUser;
+
+    /**
+     * The internal model object containing the values which represent the
+     * connection associated with this configuration.
+     */
+    private ConnectionModel connectionModel;
+
+    /**
+     * Service for managing connection parameters.
+     */
+    @Inject
+    private ConnectionService connectionService;
+
+    /**
+     * The manually-set parameter map, if any.
+     */
+    private Map<String, String> parameters = null;
+    
+    /**
+     * Creates a new, empty ModelGuacamoleConfiguration.
+     */
+    public ModeledGuacamoleConfiguration() {
+    }
+
+    /**
+     * Initializes this configuration, associating it with the current
+     * authenticated user and populating it with data from the given model
+     * object.
+     *
+     * @param currentUser
+     *     The user that created or retrieved this configuration.
+     *
+     * @param connectionModel 
+     *     The model object backing this configuration.
+     */
+    public void init(AuthenticatedUser currentUser, ConnectionModel connectionModel) {
+        this.currentUser = currentUser;
+        this.connectionModel = connectionModel;
+    }
+
+    @Override
+    public String getProtocol() {
+        return connectionModel.getProtocol();
+    }
+
+    @Override
+    public void setProtocol(String protocol) {
+        super.setProtocol(protocol);
+        connectionModel.setProtocol(protocol);
+    }
+
+
+    @Override
+    public void setParameters(Map<String, String> parameters) {
+        this.parameters = parameters;
+        super.setParameters(parameters);
+    }
+
+    @Override
+    public Map<String, String> getParameters() {
+
+        // Retrieve visible parameters, if not overridden by setParameters()
+        if (parameters == null) {
+
+            // Retrieve all visible parameters
+            Map<String, String> visibleParameters =
+                    connectionService.retrieveParameters(currentUser, connectionModel.getIdentifier());
+
+            // Use retrieved parameters to back future operations
+            super.setParameters(visibleParameters);
+
+        }
+
+        return super.getParameters();
+
+    }
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/connection/ParameterMapper.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/connection/ParameterMapper.java
new file mode 100644
index 0000000..b8bb26f
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/connection/ParameterMapper.java
@@ -0,0 +1,75 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.jdbc.connection;
+
+import java.util.Collection;
+import org.apache.ibatis.annotations.Param;
+
+/**
+ * Mapper for connection parameter objects.
+ *
+ * @author Michael Jumper
+ */
+public interface ParameterMapper {
+
+    /**
+     * Returns a collection of all parameters associated with the connection
+     * having the given identifier.
+     *
+     * @param identifier
+     *     The identifier of the connection whose parameters are to be
+     *     retrieved.
+     *
+     * @return
+     *     A collection of all parameters associated with the connection
+     *     having the given identifier. This collection will be empty if no
+     *     such connection exists.
+     */
+    Collection<ParameterModel> select(@Param("identifier") String identifier);
+
+    /**
+     * Inserts each of the parameter model objects in the given collection as
+     * new connection parameters.
+     *
+     * @param parameters
+     *     The connection parameters to insert.
+     *
+     * @return
+     *     The number of rows inserted.
+     */
+    int insert(@Param("parameters") Collection<ParameterModel> parameters);
+
+    /**
+     * Deletes all parameters associated with the connection having the given
+     * identifier.
+     *
+     * @param identifier
+     *     The identifier of the connection whose parameters should be
+     *     deleted.
+     *
+     * @return
+     *     The number of rows deleted.
+     */
+    int delete(@Param("identifier") String identifier);
+    
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/connection/ParameterModel.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/connection/ParameterModel.java
new file mode 100644
index 0000000..103bdae
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/connection/ParameterModel.java
@@ -0,0 +1,107 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.jdbc.connection;
+
+/**
+ * A single parameter name/value pair belonging to a connection.
+ *
+ * @author Michael Jumper
+ */
+public class ParameterModel {
+
+    /**
+     * The identifier of the connection associated with this parameter.
+     */
+    private String connectionIdentifier;
+
+    /**
+     * The name of the parameter.
+     */
+    private String name;
+
+    /**
+     * The value the parameter is set to.
+     */
+    private String value;
+
+    /**
+     * Returns the identifier of the connection associated with this parameter.
+     *
+     * @return
+     *     The identifier of the connection associated with this parameter.
+     */
+    public String getConnectionIdentifier() {
+        return connectionIdentifier;
+    }
+
+    /**
+     * Sets the identifier of the connection associated with this parameter.
+     *
+     * @param connectionIdentifier
+     *     The identifier of the connection to associate with this parameter.
+     */
+    public void setConnectionIdentifier(String connectionIdentifier) {
+        this.connectionIdentifier = connectionIdentifier;
+    }
+
+    /**
+     * Returns the name of this parameter.
+     *
+     * @return
+     *     The name of this parameter.
+     */
+    public String getName() {
+        return name;
+    }
+
+    /**
+     * Sets the name of this parameter.
+     *
+     * @param name
+     *     The name of this parameter.
+     */
+    public void setName(String name) {
+        this.name = name;
+    }
+
+    /**
+     * Returns the value of this parameter.
+     *
+     * @return
+     *     The value of this parameter.
+     */
+    public String getValue() {
+        return value;
+    }
+
+    /**
+     * Sets the value of this parameter.
+     *
+     * @param value
+     *     The value of this parameter.
+     */
+    public void setValue(String value) {
+        this.value = value;
+    }
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/connection/package-info.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/connection/package-info.java
new file mode 100644
index 0000000..6507c59
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/connection/package-info.java
@@ -0,0 +1,26 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Classes related to connections and their parameters and history.
+ */
+package org.glyptodon.guacamole.auth.jdbc.connection;
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/connectiongroup/ConnectionGroupDirectory.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/connectiongroup/ConnectionGroupDirectory.java
new file mode 100644
index 0000000..6f76dd7
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/connectiongroup/ConnectionGroupDirectory.java
@@ -0,0 +1,89 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.jdbc.connectiongroup;
+
+
+import com.google.inject.Inject;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Set;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.auth.jdbc.base.RestrictedObject;
+import org.glyptodon.guacamole.net.auth.ConnectionGroup;
+import org.glyptodon.guacamole.net.auth.Directory;
+import org.mybatis.guice.transactional.Transactional;
+
+/**
+ * Implementation of the ConnectionGroup Directory which is driven by an
+ * underlying, arbitrary database.
+ *
+ * @author James Muehlner
+ * @author Michael Jumper
+ */
+public class ConnectionGroupDirectory extends RestrictedObject
+    implements Directory<ConnectionGroup> {
+
+    /**
+     * Service for managing connection group objects.
+     */
+    @Inject
+    private ConnectionGroupService connectionGroupService;
+
+    @Override
+    public ConnectionGroup get(String identifier) throws GuacamoleException {
+        return connectionGroupService.retrieveObject(getCurrentUser(), identifier);
+    }
+
+    @Override
+    @Transactional
+    public Collection<ConnectionGroup> getAll(Collection<String> identifiers) throws GuacamoleException {
+        Collection<ModeledConnectionGroup> objects = connectionGroupService.retrieveObjects(getCurrentUser(), identifiers);
+        return Collections.<ConnectionGroup>unmodifiableCollection(objects);
+    }
+
+    @Override
+    @Transactional
+    public Set<String> getIdentifiers() throws GuacamoleException {
+        return connectionGroupService.getIdentifiers(getCurrentUser());
+    }
+
+    @Override
+    @Transactional
+    public void add(ConnectionGroup object) throws GuacamoleException {
+        connectionGroupService.createObject(getCurrentUser(), object);
+    }
+
+    @Override
+    @Transactional
+    public void update(ConnectionGroup object) throws GuacamoleException {
+        ModeledConnectionGroup connectionGroup = (ModeledConnectionGroup) object;
+        connectionGroupService.updateObject(getCurrentUser(), connectionGroup);
+    }
+
+    @Override
+    @Transactional
+    public void remove(String identifier) throws GuacamoleException {
+        connectionGroupService.deleteObject(getCurrentUser(), identifier);
+    }
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/connectiongroup/ConnectionGroupMapper.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/connectiongroup/ConnectionGroupMapper.java
new file mode 100644
index 0000000..ee4df9b
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/connectiongroup/ConnectionGroupMapper.java
@@ -0,0 +1,92 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.jdbc.connectiongroup;
+
+import java.util.Set;
+import org.glyptodon.guacamole.auth.jdbc.base.ModeledDirectoryObjectMapper;
+import org.glyptodon.guacamole.auth.jdbc.user.UserModel;
+import org.apache.ibatis.annotations.Param;
+
+/**
+ * Mapper for connection group objects.
+ *
+ * @author Michael Jumper
+ */
+public interface ConnectionGroupMapper extends ModeledDirectoryObjectMapper<ConnectionGroupModel> {
+
+    /**
+     * Selects the identifiers of all connection groups within the given parent
+     * connection group, regardless of whether they are readable by any
+     * particular user. This should only be called on behalf of a system
+     * administrator. If identifiers are needed by a non-administrative user
+     * who must have explicit read rights, use
+     * selectReadableIdentifiersWithin() instead.
+     *
+     * @param parentIdentifier
+     *     The identifier of the parent connection group, or null if the root
+     *     connection group is to be queried.
+     *
+     * @return
+     *     A Set containing all identifiers of all objects.
+     */
+    Set<String> selectIdentifiersWithin(@Param("parentIdentifier") String parentIdentifier);
+    
+    /**
+     * Selects the identifiers of all connection groups within the given parent
+     * connection group that are explicitly readable by the given user. If
+     * identifiers are needed by a system administrator (who, by definition,
+     * does not need explicit read rights), use selectIdentifiersWithin()
+     * instead.
+     *
+     * @param user
+     *    The user whose permissions should determine whether an identifier
+     *    is returned.
+     *
+     * @param parentIdentifier
+     *     The identifier of the parent connection group, or null if the root
+     *     connection group is to be queried.
+     *
+     * @return
+     *     A Set containing all identifiers of all readable objects.
+     */
+    Set<String> selectReadableIdentifiersWithin(@Param("user") UserModel user,
+            @Param("parentIdentifier") String parentIdentifier);
+
+    /**
+     * Selects the connection group within the given parent group and having
+     * the given name. If no such connection group exists, null is returned.
+     *
+     * @param parentIdentifier
+     *     The identifier of the parent group to search within.
+     *
+     * @param name
+     *     The name of the connection group to find.
+     *
+     * @return
+     *     The connection group having the given name within the given parent
+     *     group, or null if no such connection group exists.
+     */
+    ConnectionGroupModel selectOneByName(@Param("parentIdentifier") String parentIdentifier,
+            @Param("name") String name);
+    
+}
\ No newline at end of file
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/connectiongroup/ConnectionGroupModel.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/connectiongroup/ConnectionGroupModel.java
new file mode 100644
index 0000000..161f133
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/connectiongroup/ConnectionGroupModel.java
@@ -0,0 +1,180 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.jdbc.connectiongroup;
+
+import org.glyptodon.guacamole.auth.jdbc.base.GroupedObjectModel;
+import org.glyptodon.guacamole.net.auth.ConnectionGroup;
+
+/**
+ * Object representation of a Guacamole connection group, as represented in the
+ * database.
+ *
+ * @author Michael Jumper
+ */
+public class ConnectionGroupModel extends GroupedObjectModel {
+
+    /**
+     * The human-readable name associated with this connection group.
+     */
+    private String name;
+
+    /**
+     * The type of this connection group, such as organizational or balancing.
+     */
+    private ConnectionGroup.Type type;
+
+    /**
+     * The maximum number of connections that can be established to this
+     * connection group concurrently, zero if no restriction applies, or
+     * null if the default restrictions should be applied.
+     */
+    private Integer maxConnections;
+
+    /**
+     * The maximum number of connections that can be established to this
+     * connection group concurrently by any one user, zero if no restriction
+     * applies, or null if the default restrictions should be applied.
+     */
+    private Integer maxConnectionsPerUser;
+
+    /**
+     * Creates a new, empty connection group.
+     */
+    public ConnectionGroupModel() {
+    }
+
+    /**
+     * Returns the name associated with this connection group.
+     *
+     * @return
+     *     The name associated with this connection group.
+     */
+    public String getName() {
+        return name;
+    }
+
+    /**
+     * Sets the name associated with this connection group.
+     *
+     * @param name
+     *     The name to associate with this connection group.
+     */
+    public void setName(String name) {
+        this.name = name;
+    }
+
+    /**
+     * Returns the type of this connection group, such as organizational or
+     * balancing.
+     *
+     * @return
+     *     The type of this connection group.
+     */
+    public ConnectionGroup.Type getType() {
+        return type;
+    }
+
+    /**
+     * Sets the type of this connection group, such as organizational or
+     * balancing.
+     *
+     * @param type
+     *     The type of this connection group.
+     */
+    public void setType(ConnectionGroup.Type type) {
+        this.type = type;
+    }
+
+    /**
+     * Returns the maximum number of connections that can be established to
+     * this connection group concurrently.
+     *
+     * @return
+     *     The maximum number of connections that can be established to this
+     *     connection group concurrently, zero if no restriction applies, or
+     *     null if the default restrictions should be applied.
+     */
+    public Integer getMaxConnections() {
+        return maxConnections;
+    }
+
+    /**
+     * Sets the maximum number of connections that can be established to this
+     * connection group concurrently.
+     *
+     * @param maxConnections
+     *     The maximum number of connections that can be established to this
+     *     connection group concurrently, zero if no restriction applies, or
+     *     null if the default restrictions should be applied.
+     */
+    public void setMaxConnections(Integer maxConnections) {
+        this.maxConnections = maxConnections;
+    }
+
+    /**
+     * Returns the maximum number of connections that can be established to
+     * this connection group concurrently by any one user.
+     *
+     * @return
+     *     The maximum number of connections that can be established to this
+     *     connection group concurrently by any one user, zero if no
+     *     restriction applies, or null if the default restrictions should be
+     *     applied.
+     */
+    public Integer getMaxConnectionsPerUser() {
+        return maxConnectionsPerUser;
+    }
+
+    /**
+     * Sets the maximum number of connections that can be established to this
+     * connection group concurrently by any one user.
+     *
+     * @param maxConnectionsPerUser
+     *     The maximum number of connections that can be established to this
+     *     connection group concurrently by any one user, zero if no
+     *     restriction applies, or null if the default restrictions should be
+     *     applied.
+     */
+    public void setMaxConnectionsPerUser(Integer maxConnectionsPerUser) {
+        this.maxConnectionsPerUser = maxConnectionsPerUser;
+    }
+
+    @Override
+    public String getIdentifier() {
+
+        // If no associated ID, then no associated identifier
+        Integer id = getObjectID();
+        if (id == null)
+            return null;
+
+        // Otherwise, the identifier is the ID as a string
+        return id.toString();
+
+    }
+
+    @Override
+    public void setIdentifier(String identifier) {
+        throw new UnsupportedOperationException("Connection group identifiers are derived from IDs. They cannot be set.");
+    }
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/connectiongroup/ConnectionGroupService.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/connectiongroup/ConnectionGroupService.java
new file mode 100644
index 0000000..fb74f51
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/connectiongroup/ConnectionGroupService.java
@@ -0,0 +1,258 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.jdbc.connectiongroup;
+
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.util.Set;
+import org.glyptodon.guacamole.auth.jdbc.user.AuthenticatedUser;
+import org.glyptodon.guacamole.auth.jdbc.base.ModeledDirectoryObjectMapper;
+import org.glyptodon.guacamole.auth.jdbc.tunnel.GuacamoleTunnelService;
+import org.glyptodon.guacamole.GuacamoleClientException;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.GuacamoleSecurityException;
+import org.glyptodon.guacamole.GuacamoleUnsupportedException;
+import org.glyptodon.guacamole.auth.jdbc.base.ModeledGroupedDirectoryObjectService;
+import org.glyptodon.guacamole.auth.jdbc.permission.ConnectionGroupPermissionMapper;
+import org.glyptodon.guacamole.auth.jdbc.permission.ObjectPermissionMapper;
+import org.glyptodon.guacamole.net.GuacamoleTunnel;
+import org.glyptodon.guacamole.net.auth.ConnectionGroup;
+import org.glyptodon.guacamole.net.auth.permission.ObjectPermission;
+import org.glyptodon.guacamole.net.auth.permission.ObjectPermissionSet;
+import org.glyptodon.guacamole.net.auth.permission.SystemPermission;
+import org.glyptodon.guacamole.net.auth.permission.SystemPermissionSet;
+import org.glyptodon.guacamole.protocol.GuacamoleClientInformation;
+
+/**
+ * Service which provides convenience methods for creating, retrieving, and
+ * manipulating connection groups.
+ *
+ * @author Michael Jumper, James Muehlner
+ */
+public class ConnectionGroupService extends ModeledGroupedDirectoryObjectService<ModeledConnectionGroup,
+        ConnectionGroup, ConnectionGroupModel> {
+
+    /**
+     * Mapper for accessing connection groups.
+     */
+    @Inject
+    private ConnectionGroupMapper connectionGroupMapper;
+
+    /**
+     * Mapper for manipulating connection group permissions.
+     */
+    @Inject
+    private ConnectionGroupPermissionMapper connectionGroupPermissionMapper;
+    
+    /**
+     * Provider for creating connection groups.
+     */
+    @Inject
+    private Provider<ModeledConnectionGroup> connectionGroupProvider;
+
+    /**
+     * Service for creating and tracking tunnels.
+     */
+    @Inject
+    private GuacamoleTunnelService tunnelService;
+    
+    @Override
+    protected ModeledDirectoryObjectMapper<ConnectionGroupModel> getObjectMapper() {
+        return connectionGroupMapper;
+    }
+
+    @Override
+    protected ObjectPermissionMapper getPermissionMapper() {
+        return connectionGroupPermissionMapper;
+    }
+
+    @Override
+    protected ModeledConnectionGroup getObjectInstance(AuthenticatedUser currentUser,
+            ConnectionGroupModel model) {
+        ModeledConnectionGroup connectionGroup = connectionGroupProvider.get();
+        connectionGroup.init(currentUser, model);
+        return connectionGroup;
+    }
+
+    @Override
+    protected ConnectionGroupModel getModelInstance(AuthenticatedUser currentUser,
+            final ConnectionGroup object) {
+
+        // Create new ModeledConnectionGroup backed by blank model
+        ConnectionGroupModel model = new ConnectionGroupModel();
+        ModeledConnectionGroup connectionGroup = getObjectInstance(currentUser, model);
+
+        // Set model contents through ModeledConnectionGroup, copying the provided connection group
+        connectionGroup.setParentIdentifier(object.getParentIdentifier());
+        connectionGroup.setName(object.getName());
+        connectionGroup.setType(object.getType());
+        connectionGroup.setAttributes(object.getAttributes());
+
+        return model;
+        
+    }
+
+    @Override
+    protected boolean hasCreatePermission(AuthenticatedUser user)
+            throws GuacamoleException {
+
+        // Return whether user has explicit connection group creation permission
+        SystemPermissionSet permissionSet = user.getUser().getSystemPermissions();
+        return permissionSet.hasPermission(SystemPermission.Type.CREATE_CONNECTION_GROUP);
+
+    }
+
+    @Override
+    protected ObjectPermissionSet getPermissionSet(AuthenticatedUser user)
+            throws GuacamoleException {
+
+        // Return permissions related to connection groups 
+        return user.getUser().getConnectionGroupPermissions();
+
+    }
+
+    @Override
+    protected void beforeCreate(AuthenticatedUser user,
+            ConnectionGroupModel model) throws GuacamoleException {
+
+        super.beforeCreate(user, model);
+        
+        // Name must not be blank
+        if (model.getName() == null || model.getName().trim().isEmpty())
+            throw new GuacamoleClientException("Connection group names must not be blank.");
+        
+        // Do not attempt to create duplicate connection groups
+        ConnectionGroupModel existing = connectionGroupMapper.selectOneByName(model.getParentIdentifier(), model.getName());
+        if (existing != null)
+            throw new GuacamoleClientException("The connection group \"" + model.getName() + "\" already exists.");
+
+    }
+
+    @Override
+    protected void beforeUpdate(AuthenticatedUser user,
+            ConnectionGroupModel model) throws GuacamoleException {
+
+        super.beforeUpdate(user, model);
+        
+        // Name must not be blank
+        if (model.getName() == null || model.getName().trim().isEmpty())
+            throw new GuacamoleClientException("Connection group names must not be blank.");
+        
+        // Check whether such a connection group is already present
+        ConnectionGroupModel existing = connectionGroupMapper.selectOneByName(model.getParentIdentifier(), model.getName());
+        if (existing != null) {
+
+            // If the specified name matches a DIFFERENT existing connection group, the update cannot continue
+            if (!existing.getObjectID().equals(model.getObjectID()))
+                throw new GuacamoleClientException("The connection group \"" + model.getName() + "\" already exists.");
+
+        }
+
+        // Verify that this connection group's location does not create a cycle
+        String relativeParentIdentifier = model.getParentIdentifier();
+        while (relativeParentIdentifier != null) {
+
+            // Abort if cycle is detected
+            if (relativeParentIdentifier.equals(model.getIdentifier()))
+                throw new GuacamoleUnsupportedException("A connection group may not contain itself.");
+
+            // Advance to next parent
+            ModeledConnectionGroup relativeParentGroup = retrieveObject(user, relativeParentIdentifier);
+            relativeParentIdentifier = relativeParentGroup.getModel().getParentIdentifier();
+
+        } 
+
+    }
+
+    /**
+     * Returns the set of all identifiers for all connection groups within the
+     * connection group having the given identifier. Only connection groups
+     * that the user has read access to will be returned.
+     * 
+     * Permission to read the connection group having the given identifier is
+     * NOT checked.
+     *
+     * @param user
+     *     The user retrieving the identifiers.
+     * 
+     * @param identifier
+     *     The identifier of the parent connection group, or null to check the
+     *     root connection group.
+     *
+     * @return
+     *     The set of all identifiers for all connection groups in the
+     *     connection group having the given identifier that the user has read
+     *     access to.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while reading identifiers.
+     */
+    public Set<String> getIdentifiersWithin(AuthenticatedUser user,
+            String identifier)
+            throws GuacamoleException {
+
+        // Bypass permission checks if the user is a system admin
+        if (user.getUser().isAdministrator())
+            return connectionGroupMapper.selectIdentifiersWithin(identifier);
+
+        // Otherwise only return explicitly readable identifiers
+        else
+            return connectionGroupMapper.selectReadableIdentifiersWithin(user.getUser().getModel(), identifier);
+
+    }
+
+    /**
+     * Connects to the given connection group as the given user, using the
+     * given client information. If the user does not have permission to read
+     * the connection group, permission will be denied.
+     *
+     * @param user
+     *     The user connecting to the connection group.
+     *
+     * @param connectionGroup
+     *     The connectionGroup being connected to.
+     *
+     * @param info
+     *     Information associated with the connecting client.
+     *
+     * @return
+     *     A connected GuacamoleTunnel associated with a newly-established
+     *     connection.
+     *
+     * @throws GuacamoleException
+     *     If permission to connect to this connection is denied.
+     */
+    public GuacamoleTunnel connect(AuthenticatedUser user,
+            ModeledConnectionGroup connectionGroup, GuacamoleClientInformation info)
+            throws GuacamoleException {
+
+        // Connect only if READ permission is granted
+        if (hasObjectPermission(user, connectionGroup.getIdentifier(), ObjectPermission.Type.READ))
+            return tunnelService.getGuacamoleTunnel(user, connectionGroup, info);
+
+        // The user does not have permission to connect
+        throw new GuacamoleSecurityException("Permission denied.");
+
+    }
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/connectiongroup/ModeledConnectionGroup.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/connectiongroup/ModeledConnectionGroup.java
new file mode 100644
index 0000000..513409a
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/connectiongroup/ModeledConnectionGroup.java
@@ -0,0 +1,247 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.jdbc.connectiongroup;
+
+import com.google.inject.Inject;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
+import org.glyptodon.guacamole.auth.jdbc.connection.ConnectionService;
+import org.glyptodon.guacamole.auth.jdbc.tunnel.GuacamoleTunnelService;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.auth.jdbc.JDBCEnvironment;
+import org.glyptodon.guacamole.auth.jdbc.base.ModeledGroupedDirectoryObject;
+import org.glyptodon.guacamole.form.Field;
+import org.glyptodon.guacamole.form.Form;
+import org.glyptodon.guacamole.form.NumericField;
+import org.glyptodon.guacamole.net.GuacamoleTunnel;
+import org.glyptodon.guacamole.net.auth.ConnectionGroup;
+import org.glyptodon.guacamole.protocol.GuacamoleClientInformation;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * An implementation of the ConnectionGroup object which is backed by a
+ * database model.
+ *
+ * @author James Muehlner
+ * @author Michael Jumper
+ */
+public class ModeledConnectionGroup extends ModeledGroupedDirectoryObject<ConnectionGroupModel>
+    implements ConnectionGroup {
+
+    /**
+     * Logger for this class.
+     */
+    private static final Logger logger = LoggerFactory.getLogger(ModeledConnectionGroup.class);
+
+    /**
+     * The name of the attribute which controls the maximum number of
+     * concurrent connections.
+     */
+    public static final String MAX_CONNECTIONS_NAME = "max-connections";
+
+    /**
+     * The name of the attribute which controls the maximum number of
+     * concurrent connections per user.
+     */
+    public static final String MAX_CONNECTIONS_PER_USER_NAME = "max-connections-per-user";
+
+    /**
+     * All attributes related to restricting user accounts, within a logical
+     * form.
+     */
+    public static final Form CONCURRENCY_LIMITS = new Form("concurrency", Arrays.<Field>asList(
+        new NumericField(MAX_CONNECTIONS_NAME),
+        new NumericField(MAX_CONNECTIONS_PER_USER_NAME)
+    ));
+
+    /**
+     * All possible attributes of connection group objects organized as
+     * individual, logical forms.
+     */
+    public static final Collection<Form> ATTRIBUTES = Collections.unmodifiableCollection(Arrays.asList(
+        CONCURRENCY_LIMITS
+    ));
+
+    /**
+     * The environment of the Guacamole server.
+     */
+    @Inject
+    private JDBCEnvironment environment;
+
+    /**
+     * Service for managing connections.
+     */
+    @Inject
+    private ConnectionService connectionService;
+
+    /**
+     * Service for managing connection groups.
+     */
+    @Inject
+    private ConnectionGroupService connectionGroupService;
+
+    /**
+     * Service for creating and tracking tunnels.
+     */
+    @Inject
+    private GuacamoleTunnelService tunnelService;
+
+    /**
+     * Creates a new, empty ModeledConnectionGroup.
+     */
+    public ModeledConnectionGroup() {
+    }
+
+    @Override
+    public String getName() {
+        return getModel().getName();
+    }
+
+    @Override
+    public void setName(String name) {
+        getModel().setName(name);
+    }
+
+    @Override
+    public GuacamoleTunnel connect(GuacamoleClientInformation info)
+            throws GuacamoleException {
+        return connectionGroupService.connect(getCurrentUser(), this, info);
+    }
+
+    @Override
+    public int getActiveConnections() {
+        return tunnelService.getActiveConnections(this).size();
+    }
+
+    @Override
+    public void setType(Type type) {
+        getModel().setType(type);
+    }
+
+    @Override
+    public Type getType() {
+        return getModel().getType();
+    }
+
+    @Override
+    public Set<String> getConnectionIdentifiers()
+            throws GuacamoleException {
+        return connectionService.getIdentifiersWithin(getCurrentUser(), getIdentifier());
+    }
+
+    @Override
+    public Set<String> getConnectionGroupIdentifiers()
+            throws GuacamoleException {
+        return connectionGroupService.getIdentifiersWithin(getCurrentUser(), getIdentifier());
+    }
+
+    @Override
+    public Map<String, String> getAttributes() {
+
+        Map<String, String> attributes = new HashMap<String, String>();
+
+        // Set connection limit attribute
+        attributes.put(MAX_CONNECTIONS_NAME, NumericField.format(getModel().getMaxConnections()));
+
+        // Set per-user connection limit attribute
+        attributes.put(MAX_CONNECTIONS_PER_USER_NAME, NumericField.format(getModel().getMaxConnectionsPerUser()));
+
+        return attributes;
+    }
+
+    @Override
+    public void setAttributes(Map<String, String> attributes) {
+
+        // Translate connection limit attribute
+        try { getModel().setMaxConnections(NumericField.parse(attributes.get(MAX_CONNECTIONS_NAME))); }
+        catch (NumberFormatException e) {
+            logger.warn("Not setting maximum connections: {}", e.getMessage());
+            logger.debug("Unable to parse numeric attribute.", e);
+        }
+
+        // Translate per-user connection limit attribute
+        try { getModel().setMaxConnectionsPerUser(NumericField.parse(attributes.get(MAX_CONNECTIONS_PER_USER_NAME))); }
+        catch (NumberFormatException e) {
+            logger.warn("Not setting maximum connections per user: {}", e.getMessage());
+            logger.debug("Unable to parse numeric attribute.", e);
+        }
+
+    }
+
+    /**
+     * Returns the maximum number of connections that should be allowed to this
+     * connection group overall. If no limit applies, zero is returned.
+     *
+     * @return
+     *     The maximum number of connections that should be allowed to this
+     *     connection group overall, or zero if no limit applies.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while parsing the concurrency limit properties
+     *     specified within guacamole.properties.
+     */
+    public int getMaxConnections() throws GuacamoleException {
+
+        // Pull default from environment if connection limit is unset
+        Integer value = getModel().getMaxConnections();
+        if (value == null)
+            return environment.getDefaultMaxGroupConnections();
+
+        // Otherwise use defined value
+        return value;
+
+    }
+
+    /**
+     * Returns the maximum number of connections that should be allowed to this
+     * connection group for any individual user. If no limit applies, zero is
+     * returned.
+     *
+     * @return
+     *     The maximum number of connections that should be allowed to this
+     *     connection group for any individual user, or zero if no limit
+     *     applies.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while parsing the concurrency limit properties
+     *     specified within guacamole.properties.
+     */
+    public int getMaxConnectionsPerUser() throws GuacamoleException {
+
+        // Pull default from environment if per-user connection limit is unset
+        Integer value = getModel().getMaxConnectionsPerUser();
+        if (value == null)
+            return environment.getDefaultMaxGroupConnectionsPerUser();
+
+        // Otherwise use defined value
+        return value;
+
+    }
+
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/connectiongroup/RootConnectionGroup.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/connectiongroup/RootConnectionGroup.java
new file mode 100644
index 0000000..74fb429
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/connectiongroup/RootConnectionGroup.java
@@ -0,0 +1,150 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.jdbc.connectiongroup;
+
+import com.google.inject.Inject;
+import java.util.Collections;
+import java.util.Map;
+import java.util.Set;
+import org.glyptodon.guacamole.auth.jdbc.connection.ConnectionService;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.GuacamoleSecurityException;
+import org.glyptodon.guacamole.auth.jdbc.base.RestrictedObject;
+import org.glyptodon.guacamole.net.GuacamoleTunnel;
+import org.glyptodon.guacamole.net.auth.ConnectionGroup;
+import org.glyptodon.guacamole.protocol.GuacamoleClientInformation;
+
+/**
+ * The root connection group, here represented as its own dedicated object as
+ * the database does not contain an actual root group.
+ *
+ * @author Michael Jumper
+ */
+public class RootConnectionGroup extends RestrictedObject
+    implements ConnectionGroup {
+
+    /**
+     * The identifier used to represent the root connection group. There is no
+     * corresponding entry in the database, thus a reserved identifier that
+     * cannot collide with database-generated identifiers is needed.
+     */
+    public static final String IDENTIFIER = "ROOT";
+
+    /**
+     * The human-readable name of this connection group. The name of the root
+     * group is not normally visible, and may even be replaced by the web
+     * interface for the sake of translation.
+     */
+    public static final String NAME = "ROOT";
+
+    /**
+     * Service for managing connection objects.
+     */
+    @Inject
+    private ConnectionService connectionService;
+
+    /**
+     * Service for managing connection group objects.
+     */
+    @Inject
+    private ConnectionGroupService connectionGroupService;
+    
+    /**
+     * Creates a new, empty RootConnectionGroup.
+     */
+    public RootConnectionGroup() {
+    }
+
+    @Override
+    public String getName() {
+        return NAME;
+    }
+
+    @Override
+    public void setName(String name) {
+        throw new UnsupportedOperationException("The root connection group cannot be modified.");
+    }
+
+    @Override
+    public String getParentIdentifier() {
+        return null;
+    }
+
+    @Override
+    public void setParentIdentifier(String parentIdentifier) {
+        throw new UnsupportedOperationException("The root connection group cannot be modified.");
+    }
+
+    @Override
+    public Type getType() {
+        return ConnectionGroup.Type.ORGANIZATIONAL;
+    }
+
+    @Override
+    public void setType(Type type) {
+        throw new UnsupportedOperationException("The root connection group cannot be modified.");
+    }
+
+    @Override
+    public Set<String> getConnectionIdentifiers() throws GuacamoleException {
+        return connectionService.getIdentifiersWithin(getCurrentUser(), null);
+    }
+
+    @Override
+    public Set<String> getConnectionGroupIdentifiers()
+            throws GuacamoleException {
+        return connectionGroupService.getIdentifiersWithin(getCurrentUser(), null);
+    }
+
+    @Override
+    public String getIdentifier() {
+        return IDENTIFIER;
+    }
+
+    @Override
+    public void setIdentifier(String identifier) {
+        throw new UnsupportedOperationException("The root connection group cannot be modified.");
+    }
+
+    @Override
+    public GuacamoleTunnel connect(GuacamoleClientInformation info)
+            throws GuacamoleException {
+        throw new GuacamoleSecurityException("Permission denied.");
+    }
+
+    @Override
+    public int getActiveConnections() {
+        return 0;
+    }
+
+    @Override
+    public Map<String, String> getAttributes() {
+        return Collections.<String, String>emptyMap();
+    }
+
+    @Override
+    public void setAttributes(Map<String, String> attributes) {
+        throw new UnsupportedOperationException("The root connection group cannot be modified.");
+    }
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/connectiongroup/package-info.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/connectiongroup/package-info.java
new file mode 100644
index 0000000..a1d0bd2
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/connectiongroup/package-info.java
@@ -0,0 +1,26 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Classes related to connection groups.
+ */
+package org.glyptodon.guacamole.auth.jdbc.connectiongroup;
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/package-info.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/package-info.java
new file mode 100644
index 0000000..e4d12fc
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/package-info.java
@@ -0,0 +1,29 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * The base JDBC authentication provider. This authentication provider serves
+ * as a basis for other JDBC authentication provider implementations which are
+ * driven by relatively-common schemas. The only difference between such
+ * implementations are maintained within database-specific MyBatis mappings.
+ */
+package org.glyptodon.guacamole.auth.jdbc;
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/permission/AbstractPermissionService.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/permission/AbstractPermissionService.java
new file mode 100644
index 0000000..feff734
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/permission/AbstractPermissionService.java
@@ -0,0 +1,84 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.jdbc.permission;
+
+import org.glyptodon.guacamole.auth.jdbc.user.AuthenticatedUser;
+import org.glyptodon.guacamole.auth.jdbc.user.ModeledUser;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.net.auth.permission.ObjectPermission;
+import org.glyptodon.guacamole.net.auth.permission.ObjectPermissionSet;
+import org.glyptodon.guacamole.net.auth.permission.Permission;
+import org.glyptodon.guacamole.net.auth.permission.PermissionSet;
+
+/**
+ * Abstract PermissionService implementation which provides additional
+ * convenience methods for enforcing the permission model.
+ *
+ * @author Michael Jumper
+ * @param <PermissionSetType>
+ *     The type of permission sets this service provides access to.
+ *
+ * @param <PermissionType>
+ *     The type of permission this service provides access to.
+ */
+public abstract class AbstractPermissionService<PermissionSetType extends PermissionSet<PermissionType>,
+        PermissionType extends Permission>
+    implements PermissionService<PermissionSetType, PermissionType> {
+
+    /**
+     * Determines whether the given user can read the permissions currently
+     * granted to the given target user. If the reading user and the target
+     * user are not the same, then explicit READ or SYSTEM_ADMINISTER access is
+     * required.
+     *
+     * @param user
+     *     The user attempting to read permissions.
+     *
+     * @param targetUser
+     *     The user whose permissions are being read.
+     *
+     * @return
+     *     true if permission is granted, false otherwise.
+     *
+     * @throws GuacamoleException 
+     *     If an error occurs while checking permission status, or if
+     *     permission is denied to read the current user's permissions.
+     */
+    protected boolean canReadPermissions(AuthenticatedUser user,
+            ModeledUser targetUser) throws GuacamoleException {
+
+        // A user can always read their own permissions
+        if (user.getUser().getIdentifier().equals(targetUser.getIdentifier()))
+            return true;
+        
+        // A system adminstrator can do anything
+        if (user.getUser().isAdministrator())
+            return true;
+
+        // Can read permissions on target user if explicit READ is granted
+        ObjectPermissionSet userPermissionSet = user.getUser().getUserPermissions();
+        return userPermissionSet.hasPermission(ObjectPermission.Type.READ, targetUser.getIdentifier());
+
+    }
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/permission/ConnectionGroupPermissionMapper.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/permission/ConnectionGroupPermissionMapper.java
new file mode 100644
index 0000000..cc791d6
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/permission/ConnectionGroupPermissionMapper.java
@@ -0,0 +1,30 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.jdbc.permission;
+
+/**
+ * Mapper for connection group permissions.
+ *
+ * @author Michael Jumper
+ */
+public interface ConnectionGroupPermissionMapper extends ObjectPermissionMapper {}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/permission/ConnectionGroupPermissionService.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/permission/ConnectionGroupPermissionService.java
new file mode 100644
index 0000000..b16078e
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/permission/ConnectionGroupPermissionService.java
@@ -0,0 +1,69 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.jdbc.permission;
+
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import org.glyptodon.guacamole.auth.jdbc.user.AuthenticatedUser;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.auth.jdbc.user.ModeledUser;
+
+/**
+ * Service which provides convenience methods for creating, retrieving, and
+ * deleting connection group permissions. This service will automatically
+ * enforce the permissions of the current user.
+ *
+ * @author Michael Jumper
+ */
+public class ConnectionGroupPermissionService extends ModeledObjectPermissionService {
+
+    /**
+     * Mapper for connection group permissions.
+     */
+    @Inject
+    private ConnectionGroupPermissionMapper connectionGroupPermissionMapper;
+    
+    /**
+     * Provider for connection group permission sets.
+     */
+    @Inject
+    private Provider<ConnectionGroupPermissionSet> connectionGroupPermissionSetProvider;
+    
+    @Override
+    protected ObjectPermissionMapper getPermissionMapper() {
+        return connectionGroupPermissionMapper;
+    }
+
+    @Override
+    public ObjectPermissionSet getPermissionSet(AuthenticatedUser user,
+            ModeledUser targetUser) throws GuacamoleException {
+
+        // Create permission set for requested user
+        ObjectPermissionSet permissionSet = connectionGroupPermissionSetProvider.get();
+        permissionSet.init(user, targetUser);
+
+        return permissionSet;
+        
+    }
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/permission/ConnectionGroupPermissionSet.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/permission/ConnectionGroupPermissionSet.java
new file mode 100644
index 0000000..5f057dc
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/permission/ConnectionGroupPermissionSet.java
@@ -0,0 +1,47 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.jdbc.permission;
+
+import com.google.inject.Inject;
+
+/**
+ * A database implementation of ObjectPermissionSet which uses an injected
+ * service to query and manipulate the connection group permissions associated
+ * with a particular user.
+ *
+ * @author Michael Jumper
+ */
+public class ConnectionGroupPermissionSet extends ObjectPermissionSet {
+
+    /**
+     * Service for querying and manipulating connection group permissions.
+     */
+    @Inject
+    private ConnectionGroupPermissionService connectionGroupPermissionService;
+    
+    @Override
+    protected ObjectPermissionService getObjectPermissionService() {
+        return connectionGroupPermissionService;
+    }
+ 
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/permission/ConnectionPermissionMapper.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/permission/ConnectionPermissionMapper.java
new file mode 100644
index 0000000..5dfdd07
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/permission/ConnectionPermissionMapper.java
@@ -0,0 +1,30 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.jdbc.permission;
+
+/**
+ * Mapper for connection permissions.
+ *
+ * @author Michael Jumper
+ */
+public interface ConnectionPermissionMapper extends ObjectPermissionMapper {}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/permission/ConnectionPermissionService.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/permission/ConnectionPermissionService.java
new file mode 100644
index 0000000..d0bb6f7
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/permission/ConnectionPermissionService.java
@@ -0,0 +1,69 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.jdbc.permission;
+
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import org.glyptodon.guacamole.auth.jdbc.user.AuthenticatedUser;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.auth.jdbc.user.ModeledUser;
+
+/**
+ * Service which provides convenience methods for creating, retrieving, and
+ * deleting connection permissions. This service will automatically enforce the
+ * permissions of the current user.
+ *
+ * @author Michael Jumper
+ */
+public class ConnectionPermissionService extends ModeledObjectPermissionService {
+
+    /**
+     * Mapper for connection permissions.
+     */
+    @Inject
+    private ConnectionPermissionMapper connectionPermissionMapper;
+    
+    /**
+     * Provider for connection permission sets.
+     */
+    @Inject
+    private Provider<ConnectionPermissionSet> connectionPermissionSetProvider;
+    
+    @Override
+    protected ObjectPermissionMapper getPermissionMapper() {
+        return connectionPermissionMapper;
+    }
+
+    @Override
+    public ObjectPermissionSet getPermissionSet(AuthenticatedUser user,
+            ModeledUser targetUser) throws GuacamoleException {
+
+        // Create permission set for requested user
+        ObjectPermissionSet permissionSet = connectionPermissionSetProvider.get();
+        permissionSet.init(user, targetUser);
+
+        return permissionSet;
+        
+    }
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/permission/ConnectionPermissionSet.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/permission/ConnectionPermissionSet.java
new file mode 100644
index 0000000..6ed57eb
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/permission/ConnectionPermissionSet.java
@@ -0,0 +1,47 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.jdbc.permission;
+
+import com.google.inject.Inject;
+
+/**
+ * A database implementation of ObjectPermissionSet which uses an injected
+ * service to query and manipulate the connection permissions associated with
+ * a particular user.
+ *
+ * @author Michael Jumper
+ */
+public class ConnectionPermissionSet extends ObjectPermissionSet {
+
+    /**
+     * Service for querying and manipulating connection permissions.
+     */
+    @Inject
+    private ConnectionPermissionService connectionPermissionService;
+    
+    @Override
+    protected ObjectPermissionService getObjectPermissionService() {
+        return connectionPermissionService;
+    }
+ 
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/permission/ModeledObjectPermissionService.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/permission/ModeledObjectPermissionService.java
new file mode 100644
index 0000000..d939fbd
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/permission/ModeledObjectPermissionService.java
@@ -0,0 +1,210 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.jdbc.permission;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashSet;
+import org.glyptodon.guacamole.auth.jdbc.user.AuthenticatedUser;
+import org.glyptodon.guacamole.auth.jdbc.user.ModeledUser;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.GuacamoleSecurityException;
+import org.glyptodon.guacamole.net.auth.permission.ObjectPermission;
+import org.glyptodon.guacamole.net.auth.permission.ObjectPermissionSet;
+
+/**
+ * Service which provides convenience methods for creating, retrieving, and
+ * deleting object permissions within a backend database model. This service
+ * will automatically enforce the permissions of the current user.
+ *
+ * @author Michael Jumper
+ */
+public abstract class ModeledObjectPermissionService
+    extends ModeledPermissionService<ObjectPermissionSet, ObjectPermission, ObjectPermissionModel>
+    implements ObjectPermissionService {
+
+    @Override
+    protected abstract ObjectPermissionMapper getPermissionMapper();
+
+    @Override
+    protected ObjectPermission getPermissionInstance(ObjectPermissionModel model) {
+        return new ObjectPermission(model.getType(), model.getObjectIdentifier());
+    }
+
+    @Override
+    protected ObjectPermissionModel getModelInstance(ModeledUser targetUser,
+            ObjectPermission permission) {
+
+        ObjectPermissionModel model = new ObjectPermissionModel();
+
+        // Populate model object with data from user and permission
+        model.setUserID(targetUser.getModel().getObjectID());
+        model.setUsername(targetUser.getModel().getIdentifier());
+        model.setType(permission.getType());
+        model.setObjectIdentifier(permission.getObjectIdentifier());
+
+        return model;
+        
+    }
+
+    /**
+     * Determines whether the current user has permission to update the given
+     * target user, adding or removing the given permissions. Such permission
+     * depends on whether the current user is a system administrator, whether
+     * they have explicit UPDATE permission on the target user, and whether
+     * they have explicit ADMINISTER permission on all affected objects.
+     *
+     * @param user
+     *     The user who is changing permissions.
+     *
+     * @param targetUser
+     *     The user whose permissions are being changed.
+     *
+     * @param permissions
+     *     The permissions that are being added or removed from the target
+     *     user.
+     *
+     * @return
+     *     true if the user has permission to change the target users
+     *     permissions as specified, false otherwise.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while checking permission status, or if
+     *     permission is denied to read the current user's permissions.
+     */
+    protected boolean canAlterPermissions(AuthenticatedUser user, ModeledUser targetUser,
+            Collection<ObjectPermission> permissions)
+            throws GuacamoleException {
+
+        // A system adminstrator can do anything
+        if (user.getUser().isAdministrator())
+            return true;
+        
+        // Verify user has update permission on the target user
+        ObjectPermissionSet userPermissionSet = user.getUser().getUserPermissions();
+        if (!userPermissionSet.hasPermission(ObjectPermission.Type.UPDATE, targetUser.getIdentifier()))
+            return false;
+
+        // Produce collection of affected identifiers
+        Collection<String> affectedIdentifiers = new HashSet<String>(permissions.size());
+        for (ObjectPermission permission : permissions)
+            affectedIdentifiers.add(permission.getObjectIdentifier());
+
+        // Determine subset of affected identifiers that we have admin access to
+        ObjectPermissionSet affectedPermissionSet = getPermissionSet(user, user.getUser());
+        Collection<String> allowedSubset = affectedPermissionSet.getAccessibleObjects(
+            Collections.singleton(ObjectPermission.Type.ADMINISTER),
+            affectedIdentifiers
+        );
+
+        // The permissions can be altered if and only if the set of objects we
+        // are allowed to administer is equal to the set of objects we will be
+        // affecting.
+
+        return affectedIdentifiers.size() == allowedSubset.size();
+        
+    }
+    
+    @Override
+    public void createPermissions(AuthenticatedUser user, ModeledUser targetUser,
+            Collection<ObjectPermission> permissions)
+            throws GuacamoleException {
+
+        // Create permissions only if user has permission to do so
+        if (canAlterPermissions(user, targetUser, permissions)) {
+            Collection<ObjectPermissionModel> models = getModelInstances(targetUser, permissions);
+            getPermissionMapper().insert(models);
+            return;
+        }
+        
+        // User lacks permission to create object permissions
+        throw new GuacamoleSecurityException("Permission denied.");
+
+    }
+
+    @Override
+    public void deletePermissions(AuthenticatedUser user, ModeledUser targetUser,
+            Collection<ObjectPermission> permissions)
+            throws GuacamoleException {
+
+        // Delete permissions only if user has permission to do so
+        if (canAlterPermissions(user, targetUser, permissions)) {
+            Collection<ObjectPermissionModel> models = getModelInstances(targetUser, permissions);
+            getPermissionMapper().delete(models);
+            return;
+        }
+        
+        // User lacks permission to delete object permissions
+        throw new GuacamoleSecurityException("Permission denied.");
+
+    }
+
+    @Override
+    public ObjectPermission retrievePermission(AuthenticatedUser user,
+            ModeledUser targetUser, ObjectPermission.Type type,
+            String identifier) throws GuacamoleException {
+
+        // Retrieve permissions only if allowed
+        if (canReadPermissions(user, targetUser)) {
+
+            // Read permission from database, return null if not found
+            ObjectPermissionModel model = getPermissionMapper().selectOne(targetUser.getModel(), type, identifier);
+            if (model == null)
+                return null;
+
+            return getPermissionInstance(model);
+
+        }
+
+        // User cannot read this user's permissions
+        throw new GuacamoleSecurityException("Permission denied.");
+        
+    }
+
+    @Override
+    public Collection<String> retrieveAccessibleIdentifiers(AuthenticatedUser user,
+            ModeledUser targetUser, Collection<ObjectPermission.Type> permissions,
+            Collection<String> identifiers) throws GuacamoleException {
+
+        // Nothing is always accessible
+        if (identifiers.isEmpty())
+            return identifiers;
+        
+        // Retrieve permissions only if allowed
+        if (canReadPermissions(user, targetUser)) {
+
+            // If user is an admin, everything is accessible
+            if (user.getUser().isAdministrator())
+                return identifiers;
+
+            // Otherwise, return explicitly-retrievable identifiers
+            return getPermissionMapper().selectAccessibleIdentifiers(targetUser.getModel(), permissions, identifiers);
+            
+        }
+
+        // User cannot read this user's permissions
+        throw new GuacamoleSecurityException("Permission denied.");
+
+    }
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/permission/ModeledPermissionService.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/permission/ModeledPermissionService.java
new file mode 100644
index 0000000..819ef00
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/permission/ModeledPermissionService.java
@@ -0,0 +1,158 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.jdbc.permission;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.Set;
+import org.glyptodon.guacamole.auth.jdbc.user.AuthenticatedUser;
+import org.glyptodon.guacamole.auth.jdbc.user.ModeledUser;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.GuacamoleSecurityException;
+import org.glyptodon.guacamole.net.auth.permission.Permission;
+import org.glyptodon.guacamole.net.auth.permission.PermissionSet;
+
+/**
+ * Service which provides convenience methods for creating, retrieving, and
+ * deleting permissions within a backend database model, and for obtaining the
+ * permission sets that contain these permissions. This service will
+ * automatically enforce the permissions of the current user.
+ *
+ * @author Michael Jumper
+ * @param <PermissionSetType>
+ *     The type of permission sets this service provides access to.
+ *
+ * @param <PermissionType>
+ *     The type of permission this service provides access to.
+ *
+ * @param <ModelType>
+ *     The underlying model object used to represent PermissionType in the
+ *     database.
+ */
+public abstract class ModeledPermissionService<PermissionSetType extends PermissionSet<PermissionType>,
+        PermissionType extends Permission, ModelType>
+    extends AbstractPermissionService<PermissionSetType, PermissionType> {
+
+    /**
+     * Returns an instance of a mapper for the type of permission used by this
+     * service.
+     *
+     * @return
+     *     A mapper which provides access to the model objects associated with
+     *     the permissions used by this service.
+     */
+    protected abstract PermissionMapper<ModelType> getPermissionMapper();
+
+    /**
+     * Returns an instance of a permission which is based on the given model
+     * object.
+     *
+     * @param model
+     *     The model object to use to produce the returned permission.
+     *
+     * @return
+     *     A permission which is based on the given model object.
+     */
+    protected abstract PermissionType getPermissionInstance(ModelType model);
+
+    /**
+     * Returns a collection of permissions which are based on the models in
+     * the given collection.
+     *
+     * @param models
+     *     The model objects to use to produce the permissions within the
+     *     returned set.
+     *
+     * @return
+     *     A set of permissions which are based on the models in the given
+     *     collection.
+     */
+    protected Set<PermissionType> getPermissionInstances(Collection<ModelType> models) {
+
+        // Create new collection of permissions by manually converting each model
+        Set<PermissionType> permissions = new HashSet<PermissionType>(models.size());
+        for (ModelType model : models)
+            permissions.add(getPermissionInstance(model));
+
+        return permissions;
+        
+    }
+
+    /**
+     * Returns an instance of a model object which is based on the given
+     * permission and target user.
+     *
+     * @param targetUser
+     *     The user to whom this permission is granted.
+     *
+     * @param permission
+     *     The permission to use to produce the returned model object.
+     *
+     * @return
+     *     A model object which is based on the given permission and target
+     *     user.
+     */
+    protected abstract ModelType getModelInstance(ModeledUser targetUser,
+            PermissionType permission);
+    
+    /**
+     * Returns a collection of model objects which are based on the given
+     * permissions and target user.
+     *
+     * @param targetUser
+     *     The user to whom this permission is granted.
+     *
+     * @param permissions
+     *     The permissions to use to produce the returned model objects.
+     *
+     * @return
+     *     A collection of model objects which are based on the given
+     *     permissions and target user.
+     */
+    protected Collection<ModelType> getModelInstances(ModeledUser targetUser,
+            Collection<PermissionType> permissions) {
+
+        // Create new collection of models by manually converting each permission 
+        Collection<ModelType> models = new ArrayList<ModelType>(permissions.size());
+        for (PermissionType permission : permissions)
+            models.add(getModelInstance(targetUser, permission));
+
+        return models;
+
+    }
+
+    @Override
+    public Set<PermissionType> retrievePermissions(AuthenticatedUser user,
+            ModeledUser targetUser) throws GuacamoleException {
+
+        // Retrieve permissions only if allowed
+        if (canReadPermissions(user, targetUser))
+            return getPermissionInstances(getPermissionMapper().select(targetUser.getModel()));
+
+        // User cannot read this user's permissions
+        throw new GuacamoleSecurityException("Permission denied.");
+        
+    }
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/permission/ObjectPermissionMapper.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/permission/ObjectPermissionMapper.java
new file mode 100644
index 0000000..fcd54b1
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/permission/ObjectPermissionMapper.java
@@ -0,0 +1,83 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.jdbc.permission;
+
+import java.util.Collection;
+import org.apache.ibatis.annotations.Param;
+import org.glyptodon.guacamole.auth.jdbc.user.UserModel;
+import org.glyptodon.guacamole.net.auth.permission.ObjectPermission;
+
+/**
+ * Mapper for object-related permissions.
+ *
+ * @author Michael Jumper
+ */
+public interface ObjectPermissionMapper extends PermissionMapper<ObjectPermissionModel> {
+
+    /**
+     * Retrieve the permission of the given type associated with the given
+     * user and object, if it exists. If no such permission exists, null is
+     * returned.
+     *
+     * @param user
+     *     The user to retrieve permissions for.
+     * 
+     * @param type
+     *     The type of permission to return.
+     * 
+     * @param identifier
+     *     The identifier of the object affected by the permission to return.
+     *
+     * @return
+     *     The requested permission, or null if no such permission is granted
+     *     to the given user for the given object.
+     */
+    ObjectPermissionModel selectOne(@Param("user") UserModel user,
+            @Param("type") ObjectPermission.Type type,
+            @Param("identifier") String identifier);
+
+    /**
+     * Retrieves the subset of the given identifiers for which the given user
+     * has at least one of the given permissions.
+     *
+     * @param user
+     *     The user to check permissions of.
+     *
+     * @param permissions
+     *     The permissions to check. An identifier will be included in the
+     *     resulting collection if at least one of these permissions is granted
+     *     for the associated object
+     *
+     * @param identifiers
+     *     The identifiers of the objects affected by the permissions being
+     *     checked.
+     *
+     * @return
+     *     A collection containing the subset of identifiers for which at least
+     *     one of the specified permissions is granted.
+     */
+    Collection<String> selectAccessibleIdentifiers(@Param("user") UserModel user,
+            @Param("permissions") Collection<ObjectPermission.Type> permissions,
+            @Param("identifiers") Collection<String> identifiers);
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/permission/ObjectPermissionModel.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/permission/ObjectPermissionModel.java
new file mode 100644
index 0000000..0a00081
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/permission/ObjectPermissionModel.java
@@ -0,0 +1,66 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.jdbc.permission;
+
+import org.glyptodon.guacamole.net.auth.permission.ObjectPermission;
+
+/**
+ * Object representation of an object-related Guacamole permission, as
+ * represented in the database.
+ *
+ * @author Michael Jumper
+ */
+public class ObjectPermissionModel extends PermissionModel<ObjectPermission.Type> {
+
+    /**
+     * The unique identifier of the object affected by this permission.
+     */
+    private String objectIdentifier;
+
+    /**
+     * Creates a new, empty object permission.
+     */
+    public ObjectPermissionModel() {
+    }
+
+    /**
+     * Returns the unique identifier of the object affected by this permission.
+     *
+     * @return
+     *     The unique identifier of the object affected by this permission.
+     */
+    public String getObjectIdentifier() {
+        return objectIdentifier;
+    }
+
+    /**
+     * Sets the unique identifier of the object affected by this permission.
+     *
+     * @param objectIdentifier 
+     *     The unique identifier of the object affected by this permission.
+     */
+    public void setObjectIdentifier(String objectIdentifier) {
+        this.objectIdentifier = objectIdentifier;
+    }
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/permission/ObjectPermissionService.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/permission/ObjectPermissionService.java
new file mode 100644
index 0000000..04e66ad
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/permission/ObjectPermissionService.java
@@ -0,0 +1,99 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.jdbc.permission;
+
+import java.util.Collection;
+import org.glyptodon.guacamole.auth.jdbc.user.AuthenticatedUser;
+import org.glyptodon.guacamole.auth.jdbc.user.ModeledUser;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.net.auth.permission.ObjectPermission;
+import org.glyptodon.guacamole.net.auth.permission.ObjectPermissionSet;
+
+/**
+ * Service which provides convenience methods for creating, retrieving, and
+ * deleting object permissions. This service will automatically enforce the
+ * permissions of the current user.
+ *
+ * @author Michael Jumper
+ */
+public interface ObjectPermissionService
+    extends PermissionService<ObjectPermissionSet, ObjectPermission> {
+
+    /**
+     * Retrieves the permission of the given type associated with the given
+     * user and object, if it exists. If no such permission exists, null is
+     *
+     * @param user
+     *     The user retrieving the permission.
+     *
+     * @param targetUser
+     *     The user associated with the permission to be retrieved.
+     * 
+     * @param type
+     *     The type of permission to retrieve.
+     *
+     * @param identifier
+     *     The identifier of the object affected by the permission to return.
+     *
+     * @return
+     *     The permission of the given type associated with the given user and
+     *     object, or null if no such permission exists.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while retrieving the requested permission.
+     */
+    ObjectPermission retrievePermission(AuthenticatedUser user,
+            ModeledUser targetUser, ObjectPermission.Type type,
+            String identifier) throws GuacamoleException;
+
+    /**
+     * Retrieves the subset of the given identifiers for which the given user
+     * has at least one of the given permissions.
+     *
+     * @param user
+     *     The user checking the permissions.
+     *
+     * @param targetUser
+     *     The user to check permissions of.
+     *
+     * @param permissions
+     *     The permissions to check. An identifier will be included in the
+     *     resulting collection if at least one of these permissions is granted
+     *     for the associated object
+     *
+     * @param identifiers
+     *     The identifiers of the objects affected by the permissions being
+     *     checked.
+     *
+     * @return
+     *     A collection containing the subset of identifiers for which at least
+     *     one of the specified permissions is granted.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while retrieving permissions.
+     */
+    Collection<String> retrieveAccessibleIdentifiers(AuthenticatedUser user,
+            ModeledUser targetUser, Collection<ObjectPermission.Type> permissions,
+            Collection<String> identifiers) throws GuacamoleException;
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/permission/ObjectPermissionSet.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/permission/ObjectPermissionSet.java
new file mode 100644
index 0000000..3806f04
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/permission/ObjectPermissionSet.java
@@ -0,0 +1,125 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.jdbc.permission;
+
+import org.glyptodon.guacamole.auth.jdbc.user.ModeledUser;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Set;
+import org.glyptodon.guacamole.auth.jdbc.user.AuthenticatedUser;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.auth.jdbc.base.RestrictedObject;
+import org.glyptodon.guacamole.net.auth.permission.ObjectPermission;
+
+/**
+ * A database implementation of ObjectPermissionSet which uses an injected
+ * service to query and manipulate the object-level permissions associated with
+ * a particular user.
+ *
+ * @author Michael Jumper
+ */
+public abstract class ObjectPermissionSet extends RestrictedObject
+    implements org.glyptodon.guacamole.net.auth.permission.ObjectPermissionSet {
+
+    /**
+     * The user associated with this permission set. Each of the permissions in
+     * this permission set is granted to this user.
+     */
+    private ModeledUser user;
+
+    /**
+     * Creates a new ObjectPermissionSet. The resulting permission set
+     * must still be initialized by a call to init(), or the information
+     * necessary to read and modify this set will be missing.
+     */
+    public ObjectPermissionSet() {
+    }
+
+    /**
+     * Initializes this permission set with the current user and the user
+     * to whom the permissions in this set are granted.
+     *
+     * @param currentUser
+     *     The user who queried this permission set, and whose permissions
+     *     dictate the access level of all operations performed on this set.
+     *
+     * @param user
+     *     The user to whom the permissions in this set are granted.
+     */
+    public void init(AuthenticatedUser currentUser, ModeledUser user) {
+        super.init(currentUser);
+        this.user = user;
+    }
+
+    /**
+     * Returns an ObjectPermissionService implementation for manipulating the
+     * type of permissions contained within this permission set.
+     *
+     * @return
+     *     An object permission service for manipulating the type of
+     *     permissions contained within this permission set.
+     */
+    protected abstract ObjectPermissionService getObjectPermissionService();
+ 
+    @Override
+    public Set<ObjectPermission> getPermissions() throws GuacamoleException {
+        return getObjectPermissionService().retrievePermissions(getCurrentUser(), user);
+    }
+
+    @Override
+    public boolean hasPermission(ObjectPermission.Type permission,
+            String identifier) throws GuacamoleException {
+        return getObjectPermissionService().retrievePermission(getCurrentUser(), user, permission, identifier) != null;
+    }
+
+    @Override
+    public void addPermission(ObjectPermission.Type permission,
+            String identifier) throws GuacamoleException {
+        addPermissions(Collections.singleton(new ObjectPermission(permission, identifier)));
+    }
+
+    @Override
+    public void removePermission(ObjectPermission.Type permission,
+            String identifier) throws GuacamoleException {
+        removePermissions(Collections.singleton(new ObjectPermission(permission, identifier)));
+    }
+
+    @Override
+    public Collection<String> getAccessibleObjects(Collection<ObjectPermission.Type> permissions,
+            Collection<String> identifiers) throws GuacamoleException {
+        return getObjectPermissionService().retrieveAccessibleIdentifiers(getCurrentUser(), user, permissions, identifiers);
+    }
+
+    @Override
+    public void addPermissions(Set<ObjectPermission> permissions)
+            throws GuacamoleException {
+        getObjectPermissionService().createPermissions(getCurrentUser(), user, permissions);
+    }
+
+    @Override
+    public void removePermissions(Set<ObjectPermission> permissions)
+            throws GuacamoleException {
+        getObjectPermissionService().deletePermissions(getCurrentUser(), user, permissions);
+    }
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/permission/PermissionMapper.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/permission/PermissionMapper.java
new file mode 100644
index 0000000..eff35a1
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/permission/PermissionMapper.java
@@ -0,0 +1,73 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.jdbc.permission;
+
+import java.util.Collection;
+import org.glyptodon.guacamole.auth.jdbc.user.UserModel;
+import org.apache.ibatis.annotations.Param;
+
+/**
+ * Generic base for mappers which handle permissions.
+ *
+ * @author Michael Jumper
+ * @param <PermissionType>
+ *     The type of permission model object handled by this mapper.
+ */
+public interface PermissionMapper<PermissionType> {
+
+    /**
+     * Retrieves all permissions associated with the given user.
+     *
+     * @param user
+     *     The user to retrieve permissions for.
+     *
+     * @return
+     *     All permissions associated with the given user.
+     */
+    Collection<PermissionType> select(@Param("user") UserModel user);
+
+    /**
+     * Inserts the given permissions into the database. If any permissions
+     * already exist, they will be ignored.
+     *
+     * @param permissions 
+     *     The permissions to insert.
+     *
+     * @return
+     *     The number of rows inserted.
+     */
+    int insert(@Param("permissions") Collection<PermissionType> permissions);
+
+    /**
+     * Deletes the given permissions from the database. If any permissions do
+     * not exist, they will be ignored.
+     *
+     * @param permissions
+     *     The permissions to delete.
+     *
+     * @return
+     *     The number of rows deleted.
+     */
+    int delete(@Param("permissions") Collection<PermissionType> permissions);
+
+}
\ No newline at end of file
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/permission/PermissionModel.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/permission/PermissionModel.java
new file mode 100644
index 0000000..d50c970
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/permission/PermissionModel.java
@@ -0,0 +1,110 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.jdbc.permission;
+
+/**
+ * Generic base permission model which grants a permission of a particular type
+ * to a specific user.
+ *
+ * @author Michael Jumper
+ * @param <PermissionType>
+ *     The type of permissions allowed within this model.
+ */
+public abstract class PermissionModel<PermissionType> {
+
+    /**
+     * The database ID of the user to whom this permission is granted.
+     */
+    private Integer userID;
+
+    /**
+     * The username of the user to whom this permission is granted.
+     */
+    private String username;
+
+    /**
+     * The type of action granted by this permission.
+     */
+    private PermissionType type;
+    
+    /**
+     * Returns the database ID of the user to whom this permission is granted.
+     * 
+     * @return
+     *     The database ID of the user to whom this permission is granted.
+     */
+    public Integer getUserID() {
+        return userID;
+    }
+
+    /**
+     * Sets the database ID of the user to whom this permission is granted.
+     *
+     * @param userID
+     *     The database ID of the user to whom this permission is granted.
+     */
+    public void setUserID(Integer userID) {
+        this.userID = userID;
+    }
+
+    /**
+     * Returns the username of the user to whom this permission is granted.
+     * 
+     * @return
+     *     The username of the user to whom this permission is granted.
+     */
+    public String getUsername() {
+        return username;
+    }
+
+    /**
+     * Sets the username of the user to whom this permission is granted.
+     *
+     * @param username
+     *     The username of the user to whom this permission is granted.
+     */
+    public void setUsername(String username) {
+        this.username = username;
+    }
+
+    /**
+     * Returns the type of action granted by this permission.
+     *
+     * @return
+     *     The type of action granted by this permission.
+     */
+    public PermissionType getType() {
+        return type;
+    }
+
+    /**
+     * Sets the type of action granted by this permission.
+     *
+     * @param type
+     *     The type of action granted by this permission.
+     */
+    public void setType(PermissionType type) {
+        this.type = type;
+    }
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/permission/PermissionService.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/permission/PermissionService.java
new file mode 100644
index 0000000..c350c3c
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/permission/PermissionService.java
@@ -0,0 +1,136 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.jdbc.permission;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.Set;
+import org.glyptodon.guacamole.auth.jdbc.user.AuthenticatedUser;
+import org.glyptodon.guacamole.auth.jdbc.user.ModeledUser;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.GuacamoleSecurityException;
+import org.glyptodon.guacamole.net.auth.permission.ObjectPermission;
+import org.glyptodon.guacamole.net.auth.permission.ObjectPermissionSet;
+import org.glyptodon.guacamole.net.auth.permission.Permission;
+import org.glyptodon.guacamole.net.auth.permission.PermissionSet;
+
+/**
+ * Service which provides convenience methods for creating, retrieving, and
+ * deleting permissions, and for obtaining the permission sets that contain
+ * these permissions. This service will automatically enforce the permissions
+ * of the current user.
+ *
+ * @author Michael Jumper
+ * @param <PermissionSetType>
+ *     The type of permission sets this service provides access to.
+ *
+ * @param <PermissionType>
+ *     The type of permission this service provides access to.
+ */
+public interface PermissionService<PermissionSetType extends PermissionSet<PermissionType>,
+        PermissionType extends Permission> {
+
+    /**
+     * Returns a permission set that can be used to retrieve and manipulate the
+     * permissions of the given user.
+     *
+     * @param user
+     *     The user who will be retrieving or manipulating permissions through
+     *     the returned permission set.
+     *
+     * @param targetUser
+     *     The user to whom the permissions in the returned permission set are
+     *     granted.
+     *
+     * @return
+     *     A permission set that contains all permissions associated with the
+     *     given user, and can be used to manipulate that user's permissions.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while retrieving the permissions of the given
+     *     user, or if permission to retrieve the permissions of the given
+     *     user is denied.
+     */
+    PermissionSetType getPermissionSet(AuthenticatedUser user,
+            ModeledUser targetUser) throws GuacamoleException;
+
+    /**
+     * Retrieves all permissions associated with the given user.
+     *
+     * @param user
+     *     The user retrieving the permissions.
+     *
+     * @param targetUser
+     *     The user associated with the permissions to be retrieved.
+     *
+     * @return
+     *     The permissions associated with the given user.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while retrieving the requested permissions.
+     */
+    Set<PermissionType> retrievePermissions(AuthenticatedUser user,
+            ModeledUser targetUser) throws GuacamoleException;
+
+    /**
+     * Creates the given permissions within the database. If any permissions
+     * already exist, they will be ignored.
+     *
+     * @param user
+     *     The user creating the permissions.
+     *
+     * @param targetUser
+     *     The user associated with the permissions to be created.
+     *
+     * @param permissions 
+     *     The permissions to create.
+     *
+     * @throws GuacamoleException
+     *     If the user lacks permission to create the permissions, or an error
+     *     occurs while creating the permissions.
+     */
+    void createPermissions(AuthenticatedUser user, ModeledUser targetUser,
+            Collection<PermissionType> permissions) throws GuacamoleException;
+
+    /**
+     * Deletes the given permissions. If any permissions do not exist, they
+     * will be ignored.
+     *
+     * @param user
+     *     The user deleting the permissions.
+     *
+     * @param targetUser
+     *     The user associated with the permissions to be deleted.
+     *
+     * @param permissions
+     *     The permissions to delete.
+     *
+     * @throws GuacamoleException
+     *     If the user lacks permission to delete the permissions, or an error
+     *     occurs while deleting the permissions.
+     */
+    void deletePermissions(AuthenticatedUser user, ModeledUser targetUser,
+            Collection<PermissionType> permissions) throws GuacamoleException;
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/permission/SystemPermissionMapper.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/permission/SystemPermissionMapper.java
new file mode 100644
index 0000000..fdcb63c
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/permission/SystemPermissionMapper.java
@@ -0,0 +1,53 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.jdbc.permission;
+
+import org.glyptodon.guacamole.auth.jdbc.user.UserModel;
+import org.apache.ibatis.annotations.Param;
+import org.glyptodon.guacamole.net.auth.permission.SystemPermission;
+
+/**
+ * Mapper for system-level permissions.
+ *
+ * @author Michael Jumper
+ */
+public interface SystemPermissionMapper extends PermissionMapper<SystemPermissionModel> {
+
+    /**
+     * Retrieve the permission of the given type associated with the given
+     * user, if it exists. If no such permission exists, null is returned.
+     *
+     * @param user
+     *     The user to retrieve permissions for.
+     * 
+     * @param type
+     *     The type of permission to return.
+     *
+     * @return
+     *     The requested permission, or null if no such permission is granted
+     *     to the given user.
+     */
+    SystemPermissionModel selectOne(@Param("user") UserModel user,
+            @Param("type") SystemPermission.Type type);
+
+}
\ No newline at end of file
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/permission/SystemPermissionModel.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/permission/SystemPermissionModel.java
new file mode 100644
index 0000000..8c1c13a
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/permission/SystemPermissionModel.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.jdbc.permission;
+
+import org.glyptodon.guacamole.net.auth.permission.SystemPermission;
+
+/**
+ * Object representation of an system-level Guacamole permission, as
+ * represented in the database.
+ *
+ * @author Michael Jumper
+ */
+public class SystemPermissionModel extends PermissionModel<SystemPermission.Type> {
+
+    /**
+     * Creates a new, empty System permission.
+     */
+    public SystemPermissionModel() {
+    }
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/permission/SystemPermissionService.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/permission/SystemPermissionService.java
new file mode 100644
index 0000000..a30f930
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/permission/SystemPermissionService.java
@@ -0,0 +1,171 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.jdbc.permission;
+
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.util.Collection;
+import org.glyptodon.guacamole.auth.jdbc.user.AuthenticatedUser;
+import org.glyptodon.guacamole.auth.jdbc.user.ModeledUser;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.GuacamoleSecurityException;
+import org.glyptodon.guacamole.GuacamoleUnsupportedException;
+import org.glyptodon.guacamole.net.auth.permission.SystemPermission;
+
+/**
+ * Service which provides convenience methods for creating, retrieving, and
+ * deleting system permissions. This service will automatically enforce
+ * the permissions of the current user.
+ *
+ * @author Michael Jumper
+ */
+public class SystemPermissionService
+    extends ModeledPermissionService<SystemPermissionSet, SystemPermission, SystemPermissionModel> {
+
+    /**
+     * Mapper for system-level permissions.
+     */
+    @Inject
+    private SystemPermissionMapper systemPermissionMapper;
+
+    /**
+     * Provider for creating system permission sets.
+     */
+    @Inject
+    private Provider<SystemPermissionSet> systemPermissionSetProvider;
+
+    @Override
+    protected SystemPermissionMapper getPermissionMapper() {
+        return systemPermissionMapper;
+    }
+    
+    @Override
+    protected SystemPermission getPermissionInstance(SystemPermissionModel model) {
+        return new SystemPermission(model.getType());
+    }
+
+    @Override
+    protected SystemPermissionModel getModelInstance(final ModeledUser targetUser,
+            final SystemPermission permission) {
+
+        SystemPermissionModel model = new SystemPermissionModel();
+
+        // Populate model object with data from user and permission
+        model.setUserID(targetUser.getModel().getObjectID());
+        model.setUsername(targetUser.getModel().getIdentifier());
+        model.setType(permission.getType());
+
+        return model;
+        
+    }
+
+    @Override
+    public SystemPermissionSet getPermissionSet(AuthenticatedUser user,
+            ModeledUser targetUser) throws GuacamoleException {
+
+        // Create permission set for requested user
+        SystemPermissionSet permissionSet = systemPermissionSetProvider.get();
+        permissionSet.init(user, targetUser);
+
+        return permissionSet;
+        
+    }
+    
+    @Override
+    public void createPermissions(AuthenticatedUser user, ModeledUser targetUser,
+            Collection<SystemPermission> permissions) throws GuacamoleException {
+
+        // Only an admin can create system permissions
+        if (user.getUser().isAdministrator()) {
+            Collection<SystemPermissionModel> models = getModelInstances(targetUser, permissions);
+            systemPermissionMapper.insert(models);
+            return;
+        }
+
+        // User lacks permission to create system permissions
+        throw new GuacamoleSecurityException("Permission denied.");
+        
+    }
+
+    @Override
+    public void deletePermissions(AuthenticatedUser user, ModeledUser targetUser,
+            Collection<SystemPermission> permissions) throws GuacamoleException {
+
+        // Only an admin can delete system permissions
+        if (user.getUser().isAdministrator()) {
+
+            // Do not allow users to remove their own admin powers
+            if (user.getUser().getIdentifier().equals(targetUser.getIdentifier()))
+                throw new GuacamoleUnsupportedException("Removing your own administrative permissions is not allowed.");
+            
+            Collection<SystemPermissionModel> models = getModelInstances(targetUser, permissions);
+            systemPermissionMapper.delete(models);
+            return;
+        }
+
+        // User lacks permission to delete system permissions
+        throw new GuacamoleSecurityException("Permission denied.");
+        
+    }
+
+    /**
+     * Retrieves the permission of the given type associated with the given
+     * user, if it exists. If no such permission exists, null is returned.
+     *
+     * @param user
+     *     The user retrieving the permission.
+     *
+     * @param targetUser
+     *     The user associated with the permission to be retrieved.
+     * 
+     * @param type
+     *     The type of permission to retrieve.
+     *
+     * @return
+     *     The permission of the given type associated with the given user, or
+     *     null if no such permission exists.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while retrieving the requested permission.
+     */
+    public SystemPermission retrievePermission(AuthenticatedUser user,
+            ModeledUser targetUser, SystemPermission.Type type) throws GuacamoleException {
+
+        // Retrieve permissions only if allowed
+        if (canReadPermissions(user, targetUser)) {
+
+            // Read permission from database, return null if not found
+            SystemPermissionModel model = getPermissionMapper().selectOne(targetUser.getModel(), type);
+            if (model == null)
+                return null;
+
+            return getPermissionInstance(model);
+
+        }
+
+        // User cannot read this user's permissions
+        throw new GuacamoleSecurityException("Permission denied.");
+        
+    }
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/permission/SystemPermissionSet.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/permission/SystemPermissionSet.java
new file mode 100644
index 0000000..485eaec
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/permission/SystemPermissionSet.java
@@ -0,0 +1,115 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.jdbc.permission;
+
+import org.glyptodon.guacamole.auth.jdbc.user.ModeledUser;
+import com.google.inject.Inject;
+import java.util.Collections;
+import java.util.Set;
+import org.glyptodon.guacamole.auth.jdbc.user.AuthenticatedUser;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.auth.jdbc.base.RestrictedObject;
+import org.glyptodon.guacamole.net.auth.permission.SystemPermission;
+
+/**
+ * A database implementation of SystemPermissionSet which uses an injected
+ * service to query and manipulate the system permissions associated with a
+ * particular user.
+ *
+ * @author Michael Jumper
+ */
+public class SystemPermissionSet extends RestrictedObject
+    implements org.glyptodon.guacamole.net.auth.permission.SystemPermissionSet {
+
+    /**
+     * The user associated with this permission set. Each of the permissions in
+     * this permission set is granted to this user.
+     */
+    private ModeledUser user;
+
+    /**
+     * Service for reading and manipulating system permissions.
+     */
+    @Inject
+    private SystemPermissionService systemPermissionService;
+    
+    /**
+     * Creates a new SystemPermissionSet. The resulting permission set
+     * must still be initialized by a call to init(), or the information
+     * necessary to read and modify this set will be missing.
+     */
+    public SystemPermissionSet() {
+    }
+
+    /**
+     * Initializes this permission set with the current user and the user
+     * to whom the permissions in this set are granted.
+     *
+     * @param currentUser
+     *     The user who queried this permission set, and whose permissions
+     *     dictate the access level of all operations performed on this set.
+     *
+     * @param user
+     *     The user to whom the permissions in this set are granted.
+     */
+    public void init(AuthenticatedUser currentUser, ModeledUser user) {
+        super.init(currentUser);
+        this.user = user;
+    }
+
+    @Override
+    public Set<SystemPermission> getPermissions() throws GuacamoleException {
+        return systemPermissionService.retrievePermissions(getCurrentUser(), user);
+    }
+
+    @Override
+    public boolean hasPermission(SystemPermission.Type permission)
+            throws GuacamoleException {
+        return systemPermissionService.retrievePermission(getCurrentUser(), user, permission) != null;
+    }
+
+    @Override
+    public void addPermission(SystemPermission.Type permission)
+            throws GuacamoleException {
+        addPermissions(Collections.singleton(new SystemPermission(permission)));
+    }
+
+    @Override
+    public void removePermission(SystemPermission.Type permission)
+            throws GuacamoleException {
+        removePermissions(Collections.singleton(new SystemPermission(permission)));
+    }
+
+    @Override
+    public void addPermissions(Set<SystemPermission> permissions)
+            throws GuacamoleException {
+        systemPermissionService.createPermissions(getCurrentUser(), user, permissions);
+    }
+
+    @Override
+    public void removePermissions(Set<SystemPermission> permissions)
+            throws GuacamoleException {
+        systemPermissionService.deletePermissions(getCurrentUser(), user, permissions);
+    }
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/permission/UserPermissionMapper.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/permission/UserPermissionMapper.java
new file mode 100644
index 0000000..a6c3275
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/permission/UserPermissionMapper.java
@@ -0,0 +1,30 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.jdbc.permission;
+
+/**
+ * Mapper for user permissions.
+ *
+ * @author Michael Jumper
+ */
+public interface UserPermissionMapper extends ObjectPermissionMapper {}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/permission/UserPermissionService.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/permission/UserPermissionService.java
new file mode 100644
index 0000000..c70d717
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/permission/UserPermissionService.java
@@ -0,0 +1,69 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.jdbc.permission;
+
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import org.glyptodon.guacamole.auth.jdbc.user.AuthenticatedUser;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.auth.jdbc.user.ModeledUser;
+
+/**
+ * Service which provides convenience methods for creating, retrieving, and
+ * deleting user permissions. This service will automatically enforce the
+ * permissions of the current user.
+ *
+ * @author Michael Jumper
+ */
+public class UserPermissionService extends ModeledObjectPermissionService {
+
+    /**
+     * Mapper for user permissions.
+     */
+    @Inject
+    private UserPermissionMapper userPermissionMapper;
+    
+    /**
+     * Provider for user permission sets.
+     */
+    @Inject
+    private Provider<UserPermissionSet> userPermissionSetProvider;
+    
+    @Override
+    protected ObjectPermissionMapper getPermissionMapper() {
+        return userPermissionMapper;
+    }
+
+    @Override
+    public ObjectPermissionSet getPermissionSet(AuthenticatedUser user,
+            ModeledUser targetUser) throws GuacamoleException {
+
+        // Create permission set for requested user
+        ObjectPermissionSet permissionSet = userPermissionSetProvider.get();
+        permissionSet.init(user, targetUser);
+
+        return permissionSet;
+        
+    }
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/permission/UserPermissionSet.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/permission/UserPermissionSet.java
new file mode 100644
index 0000000..ca99be7
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/permission/UserPermissionSet.java
@@ -0,0 +1,47 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.jdbc.permission;
+
+import com.google.inject.Inject;
+
+/**
+ * A database implementation of ObjectPermissionSet which uses an injected
+ * service to query and manipulate the user permissions associated with a
+ * particular user.
+ *
+ * @author Michael Jumper
+ */
+public class UserPermissionSet extends ObjectPermissionSet {
+
+    /**
+     * Service for querying and manipulating user permissions.
+     */
+    @Inject
+    private UserPermissionService userPermissionService;
+    
+    @Override
+    protected ObjectPermissionService getObjectPermissionService() {
+        return userPermissionService;
+    }
+ 
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/permission/package-info.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/permission/package-info.java
new file mode 100644
index 0000000..01b820a
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/permission/package-info.java
@@ -0,0 +1,26 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Classes related to object- and system-level permissions.
+ */
+package org.glyptodon.guacamole.auth.jdbc.permission;
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/security/PasswordEncryptionService.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/security/PasswordEncryptionService.java
new file mode 100644
index 0000000..2e78725
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/security/PasswordEncryptionService.java
@@ -0,0 +1,46 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.jdbc.security;
+
+/**
+ * A service to perform password encryption and checking.
+ * @author James Muehlner
+ */
+public interface PasswordEncryptionService {
+
+    /**
+     * Creates a password hash based on the provided username, password, and
+     * salt. If the provided salt is null, only the password itself is hashed.
+     *
+     * @param password
+     *     The password to hash.
+     *
+     * @param salt
+     *     The salt to use when hashing the password, if any.
+     *
+     * @return
+     *     The generated password hash.
+     */
+    public byte[] createPasswordHash(String password, byte[] salt);
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/security/SHA256PasswordEncryptionService.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/security/SHA256PasswordEncryptionService.java
new file mode 100644
index 0000000..577bdb0
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/security/SHA256PasswordEncryptionService.java
@@ -0,0 +1,65 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.jdbc.security;
+
+import java.io.UnsupportedEncodingException;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import javax.xml.bind.DatatypeConverter;
+
+/**
+ * Provides a SHA-256 based implementation of the password encryption functionality.
+ * @author James Muehlner
+ */
+public class SHA256PasswordEncryptionService implements PasswordEncryptionService {
+
+    @Override
+    public byte[] createPasswordHash(String password, byte[] salt) {
+
+        try {
+
+            // Build salted password, if a salt was provided
+            StringBuilder builder = new StringBuilder();
+            builder.append(password);
+
+            if (salt != null)
+                builder.append(DatatypeConverter.printHexBinary(salt));
+
+            // Hash UTF-8 bytes of possibly-salted password
+            MessageDigest md = MessageDigest.getInstance("SHA-256");
+            md.update(builder.toString().getBytes("UTF-8"));
+            return md.digest();
+
+        }
+
+        // Throw hard errors if standard pieces of Java are missing
+        catch (UnsupportedEncodingException e) {
+            throw new UnsupportedOperationException("Unexpected lack of UTF-8 support.", e);
+        }
+        catch (NoSuchAlgorithmException e) {
+            throw new UnsupportedOperationException("Unexpected lack of SHA-256 support.", e);
+        }
+
+    }
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/security/SaltService.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/security/SaltService.java
new file mode 100644
index 0000000..7badde4
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/security/SaltService.java
@@ -0,0 +1,35 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.jdbc.security;
+
+/**
+ * A service to generate password salts.
+ * @author James Muehlner
+ */
+public interface SaltService {
+    /**
+     * Generates a new String that can be used as a password salt.
+     * @return a new salt for password encryption.
+     */
+    public byte[] generateSalt();
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/security/SecureRandomSaltService.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/security/SecureRandomSaltService.java
new file mode 100644
index 0000000..608733b
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/security/SecureRandomSaltService.java
@@ -0,0 +1,46 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.jdbc.security;
+
+
+import java.security.SecureRandom;
+
+/**
+ * Generates password salts via SecureRandom.
+ * @author James Muehlner
+ */
+public class SecureRandomSaltService implements SaltService {
+
+    /**
+     * Instance of SecureRandom for generating the salt.
+     */
+    private SecureRandom secureRandom = new SecureRandom();
+
+    @Override
+    public byte[] generateSalt() {
+        byte[] salt = new byte[32];
+        secureRandom.nextBytes(salt);
+        return salt;
+    }
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/security/package-info.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/security/package-info.java
new file mode 100644
index 0000000..3f1d8b4
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/security/package-info.java
@@ -0,0 +1,26 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Classes related to hashing or encryption.
+ */
+package org.glyptodon.guacamole.auth.jdbc.security;
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/tunnel/AbstractGuacamoleTunnelService.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/tunnel/AbstractGuacamoleTunnelService.java
new file mode 100644
index 0000000..ea721c0
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/tunnel/AbstractGuacamoleTunnelService.java
@@ -0,0 +1,558 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.jdbc.tunnel;
+
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Date;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicBoolean;
+import org.glyptodon.guacamole.auth.jdbc.user.AuthenticatedUser;
+import org.glyptodon.guacamole.auth.jdbc.connection.ModeledConnection;
+import org.glyptodon.guacamole.auth.jdbc.connectiongroup.ModeledConnectionGroup;
+import org.glyptodon.guacamole.auth.jdbc.connection.ConnectionRecordMapper;
+import org.glyptodon.guacamole.auth.jdbc.connection.ParameterMapper;
+import org.glyptodon.guacamole.auth.jdbc.connection.ConnectionModel;
+import org.glyptodon.guacamole.auth.jdbc.connection.ConnectionRecordModel;
+import org.glyptodon.guacamole.auth.jdbc.connection.ParameterModel;
+import org.glyptodon.guacamole.auth.jdbc.user.UserModel;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.GuacamoleSecurityException;
+import org.glyptodon.guacamole.auth.jdbc.JDBCEnvironment;
+import org.glyptodon.guacamole.auth.jdbc.connection.ConnectionMapper;
+import org.glyptodon.guacamole.environment.Environment;
+import org.glyptodon.guacamole.net.GuacamoleSocket;
+import org.glyptodon.guacamole.net.GuacamoleTunnel;
+import org.glyptodon.guacamole.net.auth.Connection;
+import org.glyptodon.guacamole.net.auth.ConnectionGroup;
+import org.glyptodon.guacamole.protocol.ConfiguredGuacamoleSocket;
+import org.glyptodon.guacamole.protocol.GuacamoleClientInformation;
+import org.glyptodon.guacamole.protocol.GuacamoleConfiguration;
+import org.glyptodon.guacamole.token.StandardTokens;
+import org.glyptodon.guacamole.token.TokenFilter;
+import org.mybatis.guice.transactional.Transactional;
+
+
+/**
+ * Base implementation of the GuacamoleTunnelService, handling retrieval of
+ * connection parameters, load balancing, and connection usage counts. The
+ * implementation of concurrency rules is up to policy-specific subclasses.
+ *
+ * @author Michael Jumper
+ */
+public abstract class AbstractGuacamoleTunnelService implements GuacamoleTunnelService {
+
+    /**
+     * The environment of the Guacamole server.
+     */
+    @Inject
+    private JDBCEnvironment environment;
+ 
+    /**
+     * Mapper for accessing connections.
+     */
+    @Inject
+    private ConnectionMapper connectionMapper;
+
+    /**
+     * Provider for creating connections.
+     */
+    @Inject
+    private Provider<ModeledConnection> connectionProvider;
+
+    /**
+     * Mapper for accessing connection parameters.
+     */
+    @Inject
+    private ParameterMapper parameterMapper;
+
+    /**
+     * Mapper for accessing connection history.
+     */
+    @Inject
+    private ConnectionRecordMapper connectionRecordMapper;
+
+    /**
+     * The hostname to use when connecting to guacd if no hostname is provided
+     * within guacamole.properties.
+     */
+    private static final String DEFAULT_GUACD_HOSTNAME = "localhost";
+
+    /**
+     * The port to use when connecting to guacd if no port is provided within
+     * guacamole.properties.
+     */
+    private static final int DEFAULT_GUACD_PORT = 4822;
+
+    /**
+     * All active connections through the tunnel having a given UUID.
+     */
+    private final Map<String, ActiveConnectionRecord> activeTunnels =
+            new ConcurrentHashMap<String, ActiveConnectionRecord>();
+    
+    /**
+     * All active connections to a connection having a given identifier.
+     */
+    private final ActiveConnectionMultimap activeConnections = new ActiveConnectionMultimap();
+
+    /**
+     * All active connections to a connection group having a given identifier.
+     */
+    private final ActiveConnectionMultimap activeConnectionGroups = new ActiveConnectionMultimap();
+
+    /**
+     * Acquires possibly-exclusive access to any one of the given connections
+     * on behalf of the given user. If access is denied for any reason, or if
+     * no connection is available, an exception is thrown.
+     *
+     * @param user
+     *     The user acquiring access.
+     *
+     * @param connections
+     *     The connections being accessed.
+     *
+     * @return
+     *     The connection that has been acquired on behalf of the given user.
+     *
+     * @throws GuacamoleException
+     *     If access is denied to the given user for any reason.
+     */
+    protected abstract ModeledConnection acquire(AuthenticatedUser user,
+            List<ModeledConnection> connections) throws GuacamoleException;
+
+    /**
+     * Releases possibly-exclusive access to the given connection on behalf of
+     * the given user. If the given user did not already have access, the
+     * behavior of this function is undefined.
+     *
+     * @param user
+     *     The user releasing access.
+     *
+     * @param connection
+     *     The connection being released.
+     */
+    protected abstract void release(AuthenticatedUser user,
+            ModeledConnection connection);
+
+    /**
+     * Acquires possibly-exclusive access to the given connection group on
+     * behalf of the given user. If access is denied for any reason, an
+     * exception is thrown.
+     *
+     * @param user
+     *     The user acquiring access.
+     *
+     * @param connectionGroup
+     *     The connection group being accessed.
+     *
+     * @throws GuacamoleException
+     *     If access is denied to the given user for any reason.
+     */
+    protected abstract void acquire(AuthenticatedUser user,
+            ModeledConnectionGroup connectionGroup) throws GuacamoleException;
+
+    /**
+     * Releases possibly-exclusive access to the given connection group on
+     * behalf of the given user. If the given user did not already have access,
+     * the behavior of this function is undefined.
+     *
+     * @param user
+     *     The user releasing access.
+     *
+     * @param connectionGroup
+     *     The connection group being released.
+     */
+    protected abstract void release(AuthenticatedUser user,
+            ModeledConnectionGroup connectionGroup);
+
+    /**
+     * Returns a guacamole configuration containing the protocol and parameters
+     * from the given connection. If tokens are used in the connection
+     * parameter values, credentials from the given user will be substituted
+     * appropriately.
+     *
+     * @param user
+     *     The user whose credentials should be used if necessary.
+     *
+     * @param connection
+     *     The connection whose protocol and parameters should be added to the
+     *     returned configuration.
+     *
+     * @return
+     *     A GuacamoleConfiguration containing the protocol and parameters from
+     *     the given connection.
+     */
+    private GuacamoleConfiguration getGuacamoleConfiguration(AuthenticatedUser user,
+            ModeledConnection connection) {
+
+        // Generate configuration from available data
+        GuacamoleConfiguration config = new GuacamoleConfiguration();
+
+        // Set protocol from connection
+        ConnectionModel model = connection.getModel();
+        config.setProtocol(model.getProtocol());
+
+        // Set parameters from associated data
+        Collection<ParameterModel> parameters = parameterMapper.select(connection.getIdentifier());
+        for (ParameterModel parameter : parameters)
+            config.setParameter(parameter.getName(), parameter.getValue());
+
+        // Build token filter containing credential tokens
+        TokenFilter tokenFilter = new TokenFilter();
+        StandardTokens.addStandardTokens(tokenFilter, user.getCredentials());
+
+        // Filter the configuration
+        tokenFilter.filterValues(config.getParameters());
+
+        return config;
+        
+    }
+
+    /**
+     * Saves the given ActiveConnectionRecord to the database. The end date of
+     * the saved record will be populated with the current time.
+     *
+     * @param record
+     *     The record to save.
+     */
+    private void saveConnectionRecord(ActiveConnectionRecord record) {
+
+        // Get associated connection
+        ModeledConnection connection = record.getConnection();
+        
+        // Get associated models
+        AuthenticatedUser user = record.getUser();
+        UserModel userModel = user.getUser().getModel();
+        ConnectionRecordModel recordModel = new ConnectionRecordModel();
+
+        // Copy user information and timestamps into new record
+        recordModel.setUserID(userModel.getObjectID());
+        recordModel.setUsername(userModel.getIdentifier());
+        recordModel.setConnectionIdentifier(connection.getIdentifier());
+        recordModel.setStartDate(record.getStartDate());
+        recordModel.setEndDate(new Date());
+
+        // Insert connection record
+        connectionRecordMapper.insert(recordModel);
+
+    }
+
+    /**
+     * Returns an unconfigured GuacamoleSocket that is already connected to
+     * guacd as specified in guacamole.properties, using SSL if necessary.
+     *
+     * @return
+     *     An unconfigured GuacamoleSocket, already connected to guacd.
+     *
+     * @throws GuacamoleException 
+     *     If an error occurs while connecting to guacd, or while parsing
+     *     guacd-related properties.
+     */
+    private GuacamoleSocket getUnconfiguredGuacamoleSocket(Runnable socketClosedCallback)
+        throws GuacamoleException {
+
+        // Use SSL if requested
+        if (environment.getProperty(Environment.GUACD_SSL, false))
+            return new ManagedSSLGuacamoleSocket(
+                environment.getProperty(Environment.GUACD_HOSTNAME, DEFAULT_GUACD_HOSTNAME),
+                environment.getProperty(Environment.GUACD_PORT,     DEFAULT_GUACD_PORT),
+                socketClosedCallback
+            );
+
+        // Otherwise, just use straight TCP
+        return new ManagedInetGuacamoleSocket(
+            environment.getProperty(Environment.GUACD_HOSTNAME, DEFAULT_GUACD_HOSTNAME),
+            environment.getProperty(Environment.GUACD_PORT,     DEFAULT_GUACD_PORT),
+            socketClosedCallback
+        );
+
+    }
+
+    /**
+     * Task which handles cleanup of a connection associated with some given
+     * ActiveConnectionRecord.
+     */
+    private class ConnectionCleanupTask implements Runnable {
+
+        /**
+         * Whether this task has run.
+         */
+        private final AtomicBoolean hasRun = new AtomicBoolean(false);
+
+        /**
+         * The ActiveConnectionRecord whose connection will be cleaned up once
+         * this task runs.
+         */
+        private final ActiveConnectionRecord activeConnection;
+
+        /**
+         * Creates a new task which automatically cleans up after the
+         * connection associated with the given ActiveConnectionRecord. The
+         * connection and parent group will be removed from the maps of active
+         * connections and groups, and exclusive access will be released.
+         *
+         * @param activeConnection
+         *     The ActiveConnectionRecord whose associated connection should be
+         *     cleaned up once this task runs.
+         */
+        public ConnectionCleanupTask(ActiveConnectionRecord activeConnection) {
+            this.activeConnection = activeConnection;
+        }
+        
+        @Override
+        public void run() {
+
+            // Only run once
+            if (!hasRun.compareAndSet(false, true))
+                return;
+
+            // Get original user and connection
+            AuthenticatedUser user = activeConnection.getUser();
+            ModeledConnection connection = activeConnection.getConnection();
+
+            // Get associated identifiers
+            String identifier = connection.getIdentifier();
+            String parentIdentifier = connection.getParentIdentifier();
+
+            // Release connection
+            activeTunnels.remove(activeConnection.getUUID().toString());
+            activeConnections.remove(identifier, activeConnection);
+            activeConnectionGroups.remove(parentIdentifier, activeConnection);
+            release(user, connection);
+
+            // Release any associated group
+            if (activeConnection.hasBalancingGroup())
+                release(user, activeConnection.getBalancingGroup());
+            
+            // Save history record to database
+            saveConnectionRecord(activeConnection);
+
+        }
+
+    }
+
+    /**
+     * Creates a tunnel for the given user which connects to the given
+     * connection, which MUST already be acquired via acquire(). The given
+     * client information will be passed to guacd when the connection is
+     * established.
+     * 
+     * The connection will be automatically released when it closes, or if it
+     * fails to establish entirely.
+     *
+     * @param activeConnection
+     *     The active connection record of the connection in use.
+     *
+     * @param info
+     *     Information describing the Guacamole client connecting to the given
+     *     connection.
+     *
+     * @return
+     *     A new GuacamoleTunnel which is configured and connected to the given
+     *     connection.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while the connection is being established, or
+     *     while connection configuration information is being retrieved.
+     */
+    private GuacamoleTunnel assignGuacamoleTunnel(ActiveConnectionRecord activeConnection,
+            GuacamoleClientInformation info)
+            throws GuacamoleException {
+
+        ModeledConnection connection = activeConnection.getConnection();
+        
+        // Record new active connection
+        Runnable cleanupTask = new ConnectionCleanupTask(activeConnection);
+        activeTunnels.put(activeConnection.getUUID().toString(), activeConnection);
+        activeConnections.put(connection.getIdentifier(), activeConnection);
+        activeConnectionGroups.put(connection.getParentIdentifier(), activeConnection);
+
+        try {
+
+            // Obtain socket which will automatically run the cleanup task
+            GuacamoleSocket socket = new ConfiguredGuacamoleSocket(
+                getUnconfiguredGuacamoleSocket(cleanupTask),
+                getGuacamoleConfiguration(activeConnection.getUser(), connection),
+                info
+            );
+
+            // Assign and return new tunnel 
+            return activeConnection.assignGuacamoleTunnel(socket);
+            
+        }
+
+        // Execute cleanup if socket could not be created
+        catch (GuacamoleException e) {
+            cleanupTask.run();
+            throw e;
+        }
+
+    }
+
+    /**
+     * Returns a list of all balanced connections within a given connection
+     * group. If the connection group is not balancing, or it contains no
+     * connections, an empty list is returned.
+     *
+     * @param user
+     *     The user on whose behalf the balanced connections within the given
+     *     connection group are being retrieved.
+     *
+     * @param connectionGroup
+     *     The connection group to retrieve the balanced connections of.
+     *
+     * @return
+     *     A list containing all balanced connections within the given group,
+     *     or an empty list if there are no such connections.
+     */
+    private List<ModeledConnection> getBalancedConnections(AuthenticatedUser user,
+            ModeledConnectionGroup connectionGroup) {
+
+        // If not a balancing group, there are no balanced connections
+        if (connectionGroup.getType() != ConnectionGroup.Type.BALANCING)
+            return Collections.<ModeledConnection>emptyList();
+
+        // If group has no children, there are no balanced connections
+        Collection<String> identifiers = connectionMapper.selectIdentifiersWithin(connectionGroup.getIdentifier());
+        if (identifiers.isEmpty())
+            return Collections.<ModeledConnection>emptyList();
+
+        // Retrieve all children
+        Collection<ConnectionModel> models = connectionMapper.select(identifiers);
+        List<ModeledConnection> connections = new ArrayList<ModeledConnection>(models.size());
+
+        // Convert each retrieved model to a modeled connection
+        for (ConnectionModel model : models) {
+            ModeledConnection connection = connectionProvider.get();
+            connection.init(user, model);
+            connections.add(connection);
+        }
+
+        return connections;
+        
+    }
+
+    @Override
+    public Collection<ActiveConnectionRecord> getActiveConnections(AuthenticatedUser user)
+        throws GuacamoleException {
+
+        // Simply return empty list if there are no active tunnels
+        Collection<ActiveConnectionRecord> records = activeTunnels.values();
+        if (records.isEmpty())
+            return Collections.<ActiveConnectionRecord>emptyList();
+
+        // A system administrator can view all connections; no need to filter
+        if (user.getUser().isAdministrator())
+            return records;
+
+        // Build set of all connection identifiers associated with active tunnels
+        Set<String> identifiers = new HashSet<String>(records.size());
+        for (ActiveConnectionRecord record : records)
+            identifiers.add(record.getConnection().getIdentifier());
+
+        // Produce collection of readable connection identifiers
+        Collection<ConnectionModel> connections = connectionMapper.selectReadable(user.getUser().getModel(), identifiers);
+
+        // Ensure set contains only identifiers of readable connections
+        identifiers.clear();
+        for (ConnectionModel connection : connections)
+            identifiers.add(connection.getIdentifier());
+
+        // Produce readable subset of records
+        Collection<ActiveConnectionRecord> visibleRecords = new ArrayList<ActiveConnectionRecord>(records.size());
+        for (ActiveConnectionRecord record : records) {
+            if (identifiers.contains(record.getConnection().getIdentifier()))
+                visibleRecords.add(record);
+        }
+
+        return visibleRecords;
+
+    }
+
+    @Override
+    @Transactional
+    public GuacamoleTunnel getGuacamoleTunnel(final AuthenticatedUser user,
+            final ModeledConnection connection, GuacamoleClientInformation info)
+            throws GuacamoleException {
+
+        // Acquire and connect to single connection
+        acquire(user, Collections.singletonList(connection));
+        return assignGuacamoleTunnel(new ActiveConnectionRecord(user, connection), info);
+
+    }
+
+    @Override
+    public Collection<ActiveConnectionRecord> getActiveConnections(Connection connection) {
+        return activeConnections.get(connection.getIdentifier());
+    }
+
+    @Override
+    @Transactional
+    public GuacamoleTunnel getGuacamoleTunnel(AuthenticatedUser user,
+            ModeledConnectionGroup connectionGroup,
+            GuacamoleClientInformation info) throws GuacamoleException {
+
+        // If group has no associated balanced connections, cannot connect
+        List<ModeledConnection> connections = getBalancedConnections(user, connectionGroup);
+        if (connections.isEmpty())
+            throw new GuacamoleSecurityException("Permission denied.");
+
+        // Acquire group
+        acquire(user, connectionGroup);
+
+        // Attempt to acquire to any child
+        ModeledConnection connection;
+        try {
+            connection = acquire(user, connections);
+        }
+
+        // Ensure connection group is always released if child acquire fails
+        catch (GuacamoleException e) {
+            release(user, connectionGroup);
+            throw e;
+        }
+
+        // Connect to acquired child
+        return assignGuacamoleTunnel(new ActiveConnectionRecord(user, connectionGroup, connection), info);
+
+    }
+
+    @Override
+    public Collection<ActiveConnectionRecord> getActiveConnections(ConnectionGroup connectionGroup) {
+
+        // If not a balancing group, assume no connections
+        if (connectionGroup.getType() != ConnectionGroup.Type.BALANCING)
+            return Collections.<ActiveConnectionRecord>emptyList();
+
+        return activeConnectionGroups.get(connectionGroup.getIdentifier());
+
+    }
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/tunnel/ActiveConnectionMultimap.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/tunnel/ActiveConnectionMultimap.java
new file mode 100644
index 0000000..26fff00
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/tunnel/ActiveConnectionMultimap.java
@@ -0,0 +1,128 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.jdbc.tunnel;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Mapping of object identifiers to lists of connection records. Records are
+ * added or removed individually, and the overall list of current records
+ * associated with a given object can be retrieved at any time. The public
+ * methods of this class are all threadsafe.
+ *
+ * @author Michael Jumper
+ */
+public class ActiveConnectionMultimap {
+
+    /**
+     * All active connections to a connection having a given identifier.
+     */
+    private final Map<String, Set<ActiveConnectionRecord>> records =
+            new HashMap<String, Set<ActiveConnectionRecord>>();
+
+    /**
+     * Stores the given connection record in the list of active connections
+     * associated with the object having the given identifier.
+     *
+     * @param identifier
+     *     The identifier of the object being connected to.
+     *
+     * @param record
+     *     The record associated with the active connection.
+     */
+    public void put(String identifier, ActiveConnectionRecord record) {
+        synchronized (records) {
+
+            // Get set of active connection records, creating if necessary
+            Set<ActiveConnectionRecord> connections = records.get(identifier);
+            if (connections == null) {
+                connections = Collections.synchronizedSet(Collections.newSetFromMap(new LinkedHashMap<ActiveConnectionRecord, Boolean>()));
+                records.put(identifier, connections);
+            }
+
+            // Add active connection
+            connections.add(record);
+
+        }
+    }
+
+    /**
+     * Removes the given connection record from the list of active connections
+     * associated with the object having the given identifier.
+     *
+     * @param identifier
+     *     The identifier of the object being disconnected from.
+     *
+     * @param record
+     *     The record associated with the active connection.
+     */
+    public void remove(String identifier, ActiveConnectionRecord record) {
+        synchronized (records) {
+
+            // Get set of active connection records
+            Set<ActiveConnectionRecord> connections = records.get(identifier);
+            assert(connections != null);
+
+            // Remove old record
+            connections.remove(record);
+
+            // If now empty, clean the tracking entry
+            if (connections.isEmpty())
+                records.remove(identifier);
+
+        }
+    }
+
+    /**
+     * Returns a collection of active connection records associated with the
+     * object having the given identifier. The collection will be sorted in
+     * insertion order. If there are no such connections, an empty collection is
+     * returned.
+     *
+     * @param identifier
+     *     The identifier of the object to check.
+     *
+     * @return
+     *     An immutable collection of records associated with the object having
+     *     the given identifier, or an empty collection if there are no such
+     *     records.
+     */
+    public Collection<ActiveConnectionRecord> get(String identifier) {
+        synchronized (records) {
+
+            // Get set of active connection records
+            Collection<ActiveConnectionRecord> connections = records.get(identifier);
+            if (connections != null)
+                return Collections.unmodifiableCollection(connections);
+
+            return Collections.<ActiveConnectionRecord>emptyList();
+
+        }
+    }
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/tunnel/ActiveConnectionRecord.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/tunnel/ActiveConnectionRecord.java
new file mode 100644
index 0000000..1c2a40a
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/tunnel/ActiveConnectionRecord.java
@@ -0,0 +1,265 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.jdbc.tunnel;
+
+import java.util.Date;
+import java.util.UUID;
+import org.glyptodon.guacamole.auth.jdbc.connection.ModeledConnection;
+import org.glyptodon.guacamole.auth.jdbc.connectiongroup.ModeledConnectionGroup;
+import org.glyptodon.guacamole.auth.jdbc.user.AuthenticatedUser;
+import org.glyptodon.guacamole.net.AbstractGuacamoleTunnel;
+import org.glyptodon.guacamole.net.GuacamoleSocket;
+import org.glyptodon.guacamole.net.GuacamoleTunnel;
+import org.glyptodon.guacamole.net.auth.ConnectionRecord;
+
+
+/**
+ * A connection record implementation that describes an active connection. As
+ * the associated connection has not yet ended, getEndDate() will always return
+ * null, and isActive() will always return true. The associated start date will
+ * be the time of this objects creation.
+ *
+ * @author Michael Jumper
+ */
+public class ActiveConnectionRecord implements ConnectionRecord {
+
+    /**
+     * The user that connected to the connection associated with this connection
+     * record.
+     */
+    private final AuthenticatedUser user;
+
+    /**
+     * The balancing group from which the associated connection was chosen, if
+     * any. If no balancing group was used, this will be null.
+     */
+    private final ModeledConnectionGroup balancingGroup;
+
+    /**
+     * The connection associated with this connection record.
+     */
+    private final ModeledConnection connection;
+
+    /**
+     * The time this connection record was created.
+     */
+    private final Date startDate = new Date();
+
+    /**
+     * The UUID that will be assigned to the underlying tunnel.
+     */
+    private final UUID uuid = UUID.randomUUID();
+    
+    /**
+     * The GuacamoleTunnel used by the connection associated with this
+     * connection record.
+     */
+    private GuacamoleTunnel tunnel;
+    
+    /**
+     * Creates a new connection record associated with the given user,
+     * connection, and balancing connection group. The given balancing
+     * connection group MUST be the connection group from which the given
+     * connection was chosen. The start date of this connection record will be
+     * the time of its creation.
+     *
+     * @param user
+     *     The user that connected to the connection associated with this
+     *     connection record.
+     *
+     * @param balancingGroup
+     *     The balancing group from which the given connection was chosen.
+     *
+     * @param connection
+     *     The connection to associate with this connection record.
+     */
+    public ActiveConnectionRecord(AuthenticatedUser user,
+            ModeledConnectionGroup balancingGroup,
+            ModeledConnection connection) {
+        this.user = user;
+        this.balancingGroup = balancingGroup;
+        this.connection = connection;
+    }
+
+    /**
+     * Creates a new connection record associated with the given user and
+     * connection. The start date of this connection record will be the time of
+     * its creation.
+     *
+     * @param user
+     *     The user that connected to the connection associated with this
+     *     connection record.
+     *
+     * @param connection
+     *     The connection to associate with this connection record.
+     */
+    public ActiveConnectionRecord(AuthenticatedUser user,
+            ModeledConnection connection) {
+        this(user, null, connection);
+    }
+
+    /**
+     * Returns the user that connected to the connection associated with this
+     * connection record.
+     *
+     * @return
+     *     The user that connected to the connection associated with this
+     *     connection record.
+     */
+    public AuthenticatedUser getUser() {
+        return user;
+    }
+
+    /**
+     * Returns the balancing group from which the connection associated with
+     * this connection record was chosen.
+     *
+     * @return
+     *     The balancing group from which the connection associated with this
+     *     connection record was chosen.
+     */
+    public ModeledConnectionGroup getBalancingGroup() {
+        return balancingGroup;
+    }
+
+    /**
+     * Returns the connection associated with this connection record.
+     *
+     * @return
+     *     The connection associated with this connection record.
+     */
+    public ModeledConnection getConnection() {
+        return connection;
+    }
+
+    /**
+     * Returns whether the connection associated with this connection record
+     * was chosen from a balancing group.
+     *
+     * @return
+     *     true if the connection associated with this connection record was
+     *     chosen from a balancing group, false otherwise.
+     */
+    public boolean hasBalancingGroup() {
+        return balancingGroup != null;
+    }
+
+    @Override
+    public String getConnectionIdentifier() {
+        return connection.getIdentifier();
+    }
+
+    @Override
+    public String getConnectionName() {
+        return connection.getName();
+    }
+
+    @Override
+    public Date getStartDate() {
+        return startDate;
+    }
+
+    @Override
+    public Date getEndDate() {
+
+        // Active connections have not yet ended
+        return null;
+        
+    }
+
+    @Override
+    public String getRemoteHost() {
+        return user.getRemoteHost();
+    }
+
+    @Override
+    public String getUsername() {
+        return user.getUser().getIdentifier();
+    }
+
+    @Override
+    public boolean isActive() {
+
+        // Active connections are active by definition
+        return true;
+        
+    }
+
+    /**
+     * Returns the GuacamoleTunnel currently associated with the active
+     * connection represented by this connection record.
+     *
+     * @return
+     *     The GuacamoleTunnel currently associated with the active connection
+     *     represented by this connection record.
+     */
+    public GuacamoleTunnel getTunnel() {
+        return tunnel;
+    }
+
+    /**
+     * Associates a new GuacamoleTunnel with this connection record using the
+     * given socket.
+     *
+     * @param socket
+     *     The GuacamoleSocket to use to create the tunnel associated with this
+     *     connection record.
+     * 
+     * @return
+     *     The newly-created tunnel associated with this connection record.
+     */
+    public GuacamoleTunnel assignGuacamoleTunnel(final GuacamoleSocket socket) {
+
+        // Create tunnel with given socket
+        this.tunnel = new AbstractGuacamoleTunnel() {
+
+            @Override
+            public GuacamoleSocket getSocket() {
+                return socket;
+            }
+            
+            @Override
+            public UUID getUUID() {
+                return uuid;
+            }
+
+        };
+
+        // Return newly-created tunnel
+        return this.tunnel;
+        
+    }
+
+    /**
+     * Returns the UUID of the underlying tunnel. If there is no underlying
+     * tunnel, this will be the UUID assigned to the underlying tunnel when the
+     * tunnel is set.
+     *
+     * @return
+     *     The current or future UUID of the underlying tunnel.
+     */
+    public UUID getUUID() {
+        return uuid;
+    }
+    
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/tunnel/GuacamoleTunnelService.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/tunnel/GuacamoleTunnelService.java
new file mode 100644
index 0000000..c965a82
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/tunnel/GuacamoleTunnelService.java
@@ -0,0 +1,151 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.jdbc.tunnel;
+
+import java.util.Collection;
+import org.glyptodon.guacamole.auth.jdbc.user.AuthenticatedUser;
+import org.glyptodon.guacamole.auth.jdbc.connection.ModeledConnection;
+import org.glyptodon.guacamole.auth.jdbc.connectiongroup.ModeledConnectionGroup;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.net.GuacamoleTunnel;
+import org.glyptodon.guacamole.net.auth.Connection;
+import org.glyptodon.guacamole.net.auth.ConnectionGroup;
+import org.glyptodon.guacamole.protocol.GuacamoleClientInformation;
+
+
+/**
+ * Service which creates pre-configured GuacamoleSocket instances for
+ * connections and balancing groups, applying concurrent usage rules.
+ *
+ * @author Michael Jumper
+ */
+public interface GuacamoleTunnelService {
+
+    /**
+     * Returns a collection containing connection records representing all
+     * currently-active connections visible by the given user.
+     *
+     * @param user
+     *     The user retrieving active connections.
+     *
+     * @return
+     *     A collection containing connection records representing all
+     *     currently-active connections.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while retrieving all active connections, or if
+     *     permission is denied.
+     */
+    public Collection<ActiveConnectionRecord> getActiveConnections(AuthenticatedUser user)
+            throws GuacamoleException;
+
+    /**
+     * Creates a socket for the given user which connects to the given
+     * connection. The given client information will be passed to guacd when
+     * the connection is established. This function will apply any concurrent
+     * usage rules in effect, but will NOT test object- or system-level
+     * permissions.
+     *
+     * @param user
+     *     The user for whom the connection is being established.
+     *
+     * @param connection
+     *     The connection the user is connecting to.
+     *
+     * @param info
+     *     Information describing the Guacamole client connecting to the given
+     *     connection.
+     *
+     * @return
+     *     A new GuacamoleTunnel which is configured and connected to the given
+     *     connection.
+     *
+     * @throws GuacamoleException
+     *     If the connection cannot be established due to concurrent usage
+     *     rules.
+     */
+    GuacamoleTunnel getGuacamoleTunnel(AuthenticatedUser user,
+            ModeledConnection connection, GuacamoleClientInformation info)
+            throws GuacamoleException;
+
+    /**
+     * Returns a collection containing connection records representing all
+     * currently-active connections using the given connection. These records
+     * will have usernames and start dates, but no end date, and will be
+     * sorted in ascending order by start date.
+     *
+     * @param connection
+     *     The connection to check.
+     *
+     * @return
+     *     A collection containing connection records representing all
+     *     currently-active connections.
+     */
+    public Collection<ActiveConnectionRecord> getActiveConnections(Connection connection);
+
+    /**
+     * Creates a socket for the given user which connects to the given
+     * connection group. The given client information will be passed to guacd
+     * when the connection is established. This function will apply any
+     * concurrent usage rules in effect, but will NOT test object- or
+     * system-level permissions.
+     *
+     * @param user
+     *     The user for whom the connection is being established.
+     *
+     * @param connectionGroup
+     *     The connection group the user is connecting to.
+     *
+     * @param info
+     *     Information describing the Guacamole client connecting to the given
+     *     connection group.
+     *
+     * @return
+     *     A new GuacamoleTunnel which is configured and connected to the given
+     *     connection group.
+     *
+     * @throws GuacamoleException
+     *     If the connection cannot be established due to concurrent usage
+     *     rules, or if the connection group is not balancing.
+     */
+    GuacamoleTunnel getGuacamoleTunnel(AuthenticatedUser user,
+            ModeledConnectionGroup connectionGroup,
+            GuacamoleClientInformation info)
+            throws GuacamoleException;
+
+    /**
+     * Returns a collection containing connection records representing all
+     * currently-active connections using the given connection group. These
+     * records will have usernames and start dates, but no end date, and will
+     * be sorted in ascending order by start date.
+     *
+     * @param connectionGroup
+     *     The connection group to check.
+     *
+     * @return
+     *     A collection containing connection records representing all
+     *     currently-active connections.
+     */
+    public Collection<ActiveConnectionRecord> getActiveConnections(ConnectionGroup connectionGroup);
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/tunnel/ManagedInetGuacamoleSocket.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/tunnel/ManagedInetGuacamoleSocket.java
new file mode 100644
index 0000000..739b477
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/tunnel/ManagedInetGuacamoleSocket.java
@@ -0,0 +1,71 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.jdbc.tunnel;
+
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.net.InetGuacamoleSocket;
+
+/**
+ * Implementation of GuacamoleSocket which connects via TCP to a given hostname
+ * and port. If the socket is closed for any reason, a given task is run.
+ *
+ * @author Michael Jumper
+ */
+public class ManagedInetGuacamoleSocket extends InetGuacamoleSocket {
+
+    /**
+     * The task to run when the socket is closed.
+     */
+    private final Runnable socketClosedTask;
+
+    /**
+     * Creates a new socket which connects via TCP to a given hostname and
+     * port. If the socket is closed for any reason, the given task is run.
+     * 
+     * @param hostname
+     *     The hostname of the Guacamole proxy server to connect to.
+     *
+     * @param port
+     *     The port of the Guacamole proxy server to connect to.
+     *
+     * @param socketClosedTask
+     *     The task to run when the socket is closed. This task will NOT be
+     *     run if an exception occurs during connection, and this
+     *     ManagedInetGuacamoleSocket instance is ultimately not created.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while connecting to the Guacamole proxy server.
+     */
+    public ManagedInetGuacamoleSocket(String hostname, int port,
+            Runnable socketClosedTask) throws GuacamoleException {
+        super(hostname, port);
+        this.socketClosedTask = socketClosedTask;
+    }
+
+    @Override
+    public void close() throws GuacamoleException {
+        super.close();
+        socketClosedTask.run();
+    }
+    
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/tunnel/ManagedSSLGuacamoleSocket.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/tunnel/ManagedSSLGuacamoleSocket.java
new file mode 100644
index 0000000..cf3f380
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/tunnel/ManagedSSLGuacamoleSocket.java
@@ -0,0 +1,71 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.jdbc.tunnel;
+
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.net.SSLGuacamoleSocket;
+
+/**
+ * Implementation of GuacamoleSocket which connects via SSL to a given hostname
+ * and port. If the socket is closed for any reason, a given task is run.
+ *
+ * @author Michael Jumper
+ */
+public class ManagedSSLGuacamoleSocket extends SSLGuacamoleSocket {
+
+    /**
+     * The task to run when the socket is closed.
+     */
+    private final Runnable socketClosedTask;
+
+    /**
+     * Creates a new socket which connects via SSL to a given hostname and
+     * port. If the socket is closed for any reason, the given task is run.
+     * 
+     * @param hostname
+     *     The hostname of the Guacamole proxy server to connect to.
+     *
+     * @param port
+     *     The port of the Guacamole proxy server to connect to.
+     *
+     * @param socketClosedTask
+     *     The task to run when the socket is closed. This task will NOT be
+     *     run if an exception occurs during connection, and this
+     *     ManagedInetGuacamoleSocket instance is ultimately not created.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while connecting to the Guacamole proxy server.
+     */
+    public ManagedSSLGuacamoleSocket(String hostname, int port,
+            Runnable socketClosedTask) throws GuacamoleException {
+        super(hostname, port);
+        this.socketClosedTask = socketClosedTask;
+    }
+
+    @Override
+    public void close() throws GuacamoleException {
+        super.close();
+        socketClosedTask.run();
+    }
+    
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/tunnel/RestrictedGuacamoleTunnelService.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/tunnel/RestrictedGuacamoleTunnelService.java
new file mode 100644
index 0000000..7f5f283
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/tunnel/RestrictedGuacamoleTunnelService.java
@@ -0,0 +1,216 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.jdbc.tunnel;
+
+import com.google.common.collect.ConcurrentHashMultiset;
+import com.google.inject.Singleton;
+import java.util.Arrays;
+import java.util.Comparator;
+import java.util.List;
+import org.glyptodon.guacamole.GuacamoleClientTooManyException;
+import org.glyptodon.guacamole.auth.jdbc.user.AuthenticatedUser;
+import org.glyptodon.guacamole.auth.jdbc.connection.ModeledConnection;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.GuacamoleResourceConflictException;
+import org.glyptodon.guacamole.auth.jdbc.connectiongroup.ModeledConnectionGroup;
+
+
+/**
+ * GuacamoleTunnelService implementation which restricts concurrency for each
+ * connection and group according to a maximum number of connections and
+ * maximum number of connections per user.
+ *
+ * @author James Muehlner
+ * @author Michael Jumper
+ */
+ at Singleton
+public class RestrictedGuacamoleTunnelService
+    extends AbstractGuacamoleTunnelService {
+
+    /**
+     * Set of all currently-active user/connection pairs (seats).
+     */
+    private final ConcurrentHashMultiset<Seat> activeSeats = ConcurrentHashMultiset.<Seat>create();
+
+    /**
+     * Set of all currently-active connections.
+     */
+    private final ConcurrentHashMultiset<String> activeConnections = ConcurrentHashMultiset.<String>create();
+
+    /**
+     * Set of all currently-active user/connection group pairs (seats).
+     */
+    private final ConcurrentHashMultiset<Seat> activeGroupSeats = ConcurrentHashMultiset.<Seat>create();
+
+    /**
+     * Set of all currently-active connection groups.
+     */
+    private final ConcurrentHashMultiset<String> activeGroups = ConcurrentHashMultiset.<String>create();
+
+    /**
+     * Attempts to add a single instance of the given value to the given
+     * multiset without exceeding the specified maximum number of values. If
+     * the value cannot be added without exceeding the maximum, false is
+     * returned.
+     *
+     * @param <T>
+     *     The type of values contained within the multiset.
+     *
+     * @param multiset
+     *     The multiset to attempt to add a value to.
+     *
+     * @param value
+     *     The value to attempt to add.
+     *
+     * @param max
+     *     The maximum number of each distinct value that the given multiset
+     *     should hold, or zero if no limit applies.
+     *
+     * @return
+     *     true if the value was successfully added without exceeding the
+     *     specified maximum, false if the value could not be added.
+     */
+    private <T> boolean tryAdd(ConcurrentHashMultiset<T> multiset, T value, int max) {
+
+        // Repeatedly attempt to add a new value to the given multiset until we
+        // explicitly succeed or explicitly fail
+        while (true) {
+
+            // Get current number of values
+            int count = multiset.count(value);
+
+            // Bail out if the maximum has already been reached
+            if (count >= max && max != 0)
+                return false;
+
+            // Attempt to add one more value
+            if (multiset.setCount(value, count, count+1))
+                return true;
+
+            // Try again if unsuccessful
+
+        }
+
+    }
+
+    @Override
+    protected ModeledConnection acquire(AuthenticatedUser user,
+            List<ModeledConnection> connections) throws GuacamoleException {
+
+        // Get username
+        String username = user.getUser().getIdentifier();
+
+        // Sort connections in ascending order of usage
+        ModeledConnection[] sortedConnections = connections.toArray(new ModeledConnection[connections.size()]);
+        Arrays.sort(sortedConnections, new Comparator<ModeledConnection>() {
+
+            @Override
+            public int compare(ModeledConnection a, ModeledConnection b) {
+
+                return getActiveConnections(a).size()
+                     - getActiveConnections(b).size();
+
+            }
+
+        });
+
+        // Track whether acquire fails due to user-specific limits
+        boolean userSpecificFailure = true;
+
+        // Return the first unreserved connection
+        for (ModeledConnection connection : sortedConnections) {
+
+            // Attempt to aquire connection according to per-user limits
+            Seat seat = new Seat(username, connection.getIdentifier());
+            if (tryAdd(activeSeats, seat,
+                    connection.getMaxConnectionsPerUser())) {
+
+                // Attempt to aquire connection according to overall limits
+                if (tryAdd(activeConnections, connection.getIdentifier(),
+                        connection.getMaxConnections()))
+                    return connection;
+
+                // Acquire failed - retry with next connection
+                activeSeats.remove(seat);
+
+                // Failure to acquire is not user-specific
+                userSpecificFailure = false;
+
+            }
+
+        }
+
+        // Too many connections by this user
+        if (userSpecificFailure)
+            throw new GuacamoleClientTooManyException("Cannot connect. Connection already in use by this user.");
+
+        // Too many connections, but not necessarily due purely to this user
+        else
+            throw new GuacamoleResourceConflictException("Cannot connect. This connection is in use.");
+
+    }
+
+    @Override
+    protected void release(AuthenticatedUser user, ModeledConnection connection) {
+        activeSeats.remove(new Seat(user.getUser().getIdentifier(), connection.getIdentifier()));
+        activeConnections.remove(connection.getIdentifier());
+    }
+
+    @Override
+    protected void acquire(AuthenticatedUser user,
+            ModeledConnectionGroup connectionGroup) throws GuacamoleException {
+
+        // Get username
+        String username = user.getUser().getIdentifier();
+
+        // Attempt to aquire connection group according to per-user limits
+        Seat seat = new Seat(username, connectionGroup.getIdentifier());
+        if (tryAdd(activeGroupSeats, seat,
+                connectionGroup.getMaxConnectionsPerUser())) {
+
+            // Attempt to aquire connection group according to overall limits
+            if (tryAdd(activeGroups, connectionGroup.getIdentifier(),
+                    connectionGroup.getMaxConnections()))
+                return;
+
+            // Acquire failed
+            activeGroupSeats.remove(seat);
+
+            // Failure to acquire is not user-specific
+            throw new GuacamoleResourceConflictException("Cannot connect. This connection group is in use.");
+
+        }
+
+        // Already in use by this user
+        throw new GuacamoleClientTooManyException("Cannot connect. Connection group already in use by this user.");
+
+    }
+
+    @Override
+    protected void release(AuthenticatedUser user,
+            ModeledConnectionGroup connectionGroup) {
+        activeGroupSeats.remove(new Seat(user.getUser().getIdentifier(), connectionGroup.getIdentifier()));
+        activeGroups.remove(connectionGroup.getIdentifier());
+    }
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/tunnel/Seat.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/tunnel/Seat.java
new file mode 100644
index 0000000..008915f
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/tunnel/Seat.java
@@ -0,0 +1,89 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.jdbc.tunnel;
+
+/**
+ * A unique pairing of user and connection or connection group.
+ * 
+ * @author Michael Jumper
+ */
+public class Seat {
+
+    /**
+     * The user using this seat.
+     */
+    private final String username;
+
+    /**
+     * The connection or connection group associated with this seat.
+     */
+    private final String identifier;
+
+    /**
+     * Creates a new seat which associated the given user with the given
+     * connection or connection group.
+     *
+     * @param username
+     *     The username of the user using this seat.
+     *
+     * @param identifier
+     *     The identifier of the connection or connection group associated with
+     *     this seat.
+     */
+    public Seat(String username, String identifier) {
+        this.username = username;
+        this.identifier = identifier;
+    }
+
+    @Override
+    public int hashCode() {
+
+        // The various properties will never be null
+        assert(username != null);
+        assert(identifier != null);
+
+        // Derive hashcode from username and connection identifier
+        int hash = 5;
+        hash = 37 * hash + username.hashCode();
+        hash = 37 * hash + identifier.hashCode();
+        return hash;
+
+    }
+
+    @Override
+    public boolean equals(Object object) {
+
+        // We are only comparing against other seats here
+        assert(object instanceof Seat);
+        Seat seat = (Seat) object;
+
+        // The various properties will never be null
+        assert(seat.username != null);
+        assert(seat.identifier != null);
+
+        return username.equals(seat.username)
+            && identifier.equals(seat.identifier);
+
+    }
+  
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/tunnel/package-info.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/tunnel/package-info.java
new file mode 100644
index 0000000..61657ff
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/tunnel/package-info.java
@@ -0,0 +1,27 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Classes related to obtaining/configuring Guacamole tunnels, and restricting
+ * access to those tunnels.
+ */
+package org.glyptodon.guacamole.auth.jdbc.tunnel;
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/user/AuthenticatedUser.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/user/AuthenticatedUser.java
new file mode 100644
index 0000000..8e87475
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/user/AuthenticatedUser.java
@@ -0,0 +1,178 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.jdbc.user;
+
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import javax.servlet.http.HttpServletRequest;
+import org.glyptodon.guacamole.net.auth.AuthenticationProvider;
+import org.glyptodon.guacamole.net.auth.Credentials;
+
+/**
+ * Associates a user with the credentials they used to authenticate.
+ *
+ * @author Michael Jumper 
+ */
+public class AuthenticatedUser implements org.glyptodon.guacamole.net.auth.AuthenticatedUser {
+
+    /**
+     * The user that authenticated.
+     */
+    private final ModeledUser user;
+
+    /**
+     * The credentials given when this user authenticated.
+     */
+    private final Credentials credentials;
+
+    /**
+     * The AuthenticationProvider that authenticated this user.
+     */
+    private final AuthenticationProvider authenticationProvider;
+
+    /**
+     * The host from which this user authenticated.
+     */
+    private final String remoteHost;
+
+    /**
+     * Regular expression which matches any IPv4 address.
+     */
+    private static final String IPV4_ADDRESS_REGEX = "([0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3})";
+
+    /**
+     * Regular expression which matches any IPv6 address.
+     */
+    private static final String IPV6_ADDRESS_REGEX = "([0-9a-fA-F]*(:[0-9a-fA-F]*){0,7})";
+
+    /**
+     * Regular expression which matches any IP address, regardless of version.
+     */
+    private static final String IP_ADDRESS_REGEX = "(" + IPV4_ADDRESS_REGEX + "|" + IPV6_ADDRESS_REGEX + ")";
+
+    /**
+     * Pattern which matches valid values of the de-facto standard
+     * "X-Forwarded-For" header.
+     */
+    private static final Pattern X_FORWARDED_FOR = Pattern.compile("^" + IP_ADDRESS_REGEX + "(, " + IP_ADDRESS_REGEX + ")*$");
+
+    /**
+     * Derives the remote host of the authenticating user from the given
+     * credentials object. The remote host is derived from X-Forwarded-For
+     * in addition to the actual source IP of the request, and thus is not
+     * trusted. The derived remote host is really only useful for logging,
+     * unless the server is configured such that X-Forwarded-For is guaranteed
+     * to be trustworthy.
+     *
+     * @param credentials
+     *     The credentials to derive the remote host from.
+     *
+     * @return
+     *     The remote host from which the user with the given credentials is
+     *     authenticating.
+     */
+    private static String getRemoteHost(Credentials credentials) {
+
+        HttpServletRequest request = credentials.getRequest();
+
+        // Use X-Forwarded-For, if present and valid
+        String header = request.getHeader("X-Forwarded-For");
+        if (header != null) {
+            Matcher matcher = X_FORWARDED_FOR.matcher(header);
+            if (matcher.matches())
+                return matcher.group(1);
+        }
+
+        // If header absent or invalid, just use source IP
+        return request.getRemoteAddr();
+
+    }
+    
+    /**
+     * Creates a new AuthenticatedUser associating the given user with their
+     * corresponding credentials.
+     *
+     * @param authenticationProvider
+     *     The AuthenticationProvider that has authenticated the given user.
+     *
+     * @param user
+     *     The user this object should represent.
+     *
+     * @param credentials 
+     *     The credentials given by the user when they authenticated.
+     */
+    public AuthenticatedUser(AuthenticationProvider authenticationProvider,
+            ModeledUser user, Credentials credentials) {
+        this.authenticationProvider = authenticationProvider;
+        this.user = user;
+        this.credentials = credentials;
+        this.remoteHost = getRemoteHost(credentials);
+    }
+
+    /**
+     * Returns the user that authenticated.
+     *
+     * @return 
+     *     The user that authenticated.
+     */
+    public ModeledUser getUser() {
+        return user;
+    }
+
+    /**
+     * Returns the credentials given during authentication by this user.
+     *
+     * @return 
+     *     The credentials given during authentication by this user.
+     */
+    @Override
+    public Credentials getCredentials() {
+        return credentials;
+    }
+
+    /**
+     * Returns the host from which this user authenticated.
+     *
+     * @return
+     *     The host from which this user authenticated.
+     */
+    public String getRemoteHost() {
+        return remoteHost;
+    }
+
+    @Override
+    public AuthenticationProvider getAuthenticationProvider() {
+        return authenticationProvider;
+    }
+
+    @Override
+    public String getIdentifier() {
+        return user.getIdentifier();
+    }
+
+    @Override
+    public void setIdentifier(String identifier) {
+        user.setIdentifier(identifier);
+    }
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/user/AuthenticationProviderService.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/user/AuthenticationProviderService.java
new file mode 100644
index 0000000..486e962
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/user/AuthenticationProviderService.java
@@ -0,0 +1,119 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.jdbc.user;
+
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.net.auth.AuthenticationProvider;
+import org.glyptodon.guacamole.net.auth.Credentials;
+import org.glyptodon.guacamole.net.auth.credentials.CredentialsInfo;
+import org.glyptodon.guacamole.net.auth.credentials.GuacamoleInvalidCredentialsException;
+
+/**
+ * Service which authenticates users based on credentials and provides for
+ * the creation of corresponding, new UserContext objects for authenticated
+ * users.
+ *
+ * @author Michael Jumper
+ */
+public class AuthenticationProviderService  {
+
+    /**
+     * Service for accessing users.
+     */
+    @Inject
+    private UserService userService;
+
+    /**
+     * Provider for retrieving UserContext instances.
+     */
+    @Inject
+    private Provider<UserContext> userContextProvider;
+
+    /**
+     * Authenticates the user having the given credentials, returning a new
+     * AuthenticatedUser instance only if the credentials are valid. If the
+     * credentials are invalid or expired, an appropriate GuacamoleException
+     * will be thrown.
+     *
+     * @param authenticationProvider
+     *     The AuthenticationProvider on behalf of which the user is being
+     *     authenticated.
+     *
+     * @param credentials
+     *     The credentials to use to produce the AuthenticatedUser.
+     *
+     * @return
+     *     A new AuthenticatedUser instance for the user identified by the
+     *     given credentials.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs during authentication, or if the given
+     *     credentials are invalid or expired.
+     */
+    public AuthenticatedUser authenticateUser(AuthenticationProvider authenticationProvider,
+            Credentials credentials) throws GuacamoleException {
+
+        // Authenticate user
+        AuthenticatedUser user = userService.retrieveAuthenticatedUser(authenticationProvider, credentials);
+        if (user != null)
+            return user;
+
+        // Otherwise, unauthorized
+        throw new GuacamoleInvalidCredentialsException("Invalid login", CredentialsInfo.USERNAME_PASSWORD);
+
+    }
+
+    /**
+     * Returning a new UserContext instance for the given already-authenticated
+     * user. A new placeholder account will be created for any user that does
+     * not already exist within the database.
+     *
+     * @param authenticatedUser
+     *     The credentials to use to produce the UserContext.
+     *
+     * @return
+     *     A new UserContext instance for the user identified by the given
+     *     credentials.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs during authentication, or if the given
+     *     credentials are invalid or expired.
+     */
+    public UserContext getUserContext(org.glyptodon.guacamole.net.auth.AuthenticatedUser authenticatedUser)
+                throws GuacamoleException {
+
+        // Retrieve user account for already-authenticated user
+        ModeledUser user = userService.retrieveUser(authenticatedUser);
+        if (user == null)
+            return null;
+
+        // Link to user context
+        UserContext context = userContextProvider.get();
+        context.init(user.getCurrentUser());
+        return context;
+
+    }
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/user/ModeledUser.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/user/ModeledUser.java
new file mode 100644
index 0000000..586686b
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/user/ModeledUser.java
@@ -0,0 +1,598 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.jdbc.user;
+
+import com.google.inject.Inject;
+import java.sql.Date;
+import java.sql.Time;
+import java.text.ParseException;
+import java.util.Arrays;
+import java.util.Calendar;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.TimeZone;
+import org.glyptodon.guacamole.auth.jdbc.base.ModeledDirectoryObject;
+import org.glyptodon.guacamole.auth.jdbc.security.PasswordEncryptionService;
+import org.glyptodon.guacamole.auth.jdbc.security.SaltService;
+import org.glyptodon.guacamole.auth.jdbc.permission.SystemPermissionService;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.auth.jdbc.activeconnection.ActiveConnectionPermissionService;
+import org.glyptodon.guacamole.auth.jdbc.permission.ConnectionGroupPermissionService;
+import org.glyptodon.guacamole.auth.jdbc.permission.ConnectionPermissionService;
+import org.glyptodon.guacamole.auth.jdbc.permission.UserPermissionService;
+import org.glyptodon.guacamole.form.BooleanField;
+import org.glyptodon.guacamole.form.DateField;
+import org.glyptodon.guacamole.form.Field;
+import org.glyptodon.guacamole.form.Form;
+import org.glyptodon.guacamole.form.TimeField;
+import org.glyptodon.guacamole.form.TimeZoneField;
+import org.glyptodon.guacamole.net.auth.User;
+import org.glyptodon.guacamole.net.auth.permission.ObjectPermissionSet;
+import org.glyptodon.guacamole.net.auth.permission.SystemPermission;
+import org.glyptodon.guacamole.net.auth.permission.SystemPermissionSet;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * An implementation of the User object which is backed by a database model.
+ *
+ * @author James Muehlner
+ * @author Michael Jumper
+ */
+public class ModeledUser extends ModeledDirectoryObject<UserModel> implements User {
+
+    /**
+     * Logger for this class.
+     */
+    private static final Logger logger = LoggerFactory.getLogger(ModeledUser.class);
+
+    /**
+     * The name of the attribute which controls whether a user account is
+     * disabled.
+     */
+    public static final String DISABLED_ATTRIBUTE_NAME = "disabled";
+
+    /**
+     * The name of the attribute which controls whether a user's password is
+     * expired and must be reset upon login.
+     */
+    public static final String EXPIRED_ATTRIBUTE_NAME = "expired";
+
+    /**
+     * The name of the attribute which controls the time of day after which a
+     * user may login.
+     */
+    public static final String ACCESS_WINDOW_START_ATTRIBUTE_NAME = "access-window-start";
+
+    /**
+     * The name of the attribute which controls the time of day after which a
+     * user may NOT login.
+     */
+    public static final String ACCESS_WINDOW_END_ATTRIBUTE_NAME = "access-window-end";
+
+    /**
+     * The name of the attribute which controls the date after which a user's
+     * account is valid.
+     */
+    public static final String VALID_FROM_ATTRIBUTE_NAME = "valid-from";
+
+    /**
+     * The name of the attribute which controls the date after which a user's
+     * account is no longer valid.
+     */
+    public static final String VALID_UNTIL_ATTRIBUTE_NAME = "valid-until";
+
+    /**
+     * The name of the attribute which defines the time zone used for all
+     * time and date attributes related to this user.
+     */
+    public static final String TIMEZONE_ATTRIBUTE_NAME = "timezone";
+
+    /**
+     * All attributes related to restricting user accounts, within a logical
+     * form.
+     */
+    public static final Form ACCOUNT_RESTRICTIONS = new Form("restrictions", Arrays.<Field>asList(
+        new BooleanField(DISABLED_ATTRIBUTE_NAME, "true"),
+        new BooleanField(EXPIRED_ATTRIBUTE_NAME, "true"),
+        new TimeField(ACCESS_WINDOW_START_ATTRIBUTE_NAME),
+        new TimeField(ACCESS_WINDOW_END_ATTRIBUTE_NAME),
+        new DateField(VALID_FROM_ATTRIBUTE_NAME),
+        new DateField(VALID_UNTIL_ATTRIBUTE_NAME),
+        new TimeZoneField(TIMEZONE_ATTRIBUTE_NAME)
+    ));
+
+    /**
+     * All possible attributes of user objects organized as individual,
+     * logical forms.
+     */
+    public static final Collection<Form> ATTRIBUTES = Collections.unmodifiableCollection(Arrays.asList(
+        ACCOUNT_RESTRICTIONS
+    ));
+
+    /**
+     * Service for hashing passwords.
+     */
+    @Inject
+    private PasswordEncryptionService encryptionService;
+
+    /**
+     * Service for providing secure, random salts.
+     */
+    @Inject
+    private SaltService saltService;
+
+    /**
+     * Service for retrieving system permissions.
+     */
+    @Inject
+    private SystemPermissionService systemPermissionService;
+
+    /**
+     * Service for retrieving connection permissions.
+     */
+    @Inject
+    private ConnectionPermissionService connectionPermissionService;
+
+    /**
+     * Service for retrieving connection group permissions.
+     */
+    @Inject
+    private ConnectionGroupPermissionService connectionGroupPermissionService;
+
+    /**
+     * Service for retrieving active connection permissions.
+     */
+    @Inject
+    private ActiveConnectionPermissionService activeConnectionPermissionService;
+
+    /**
+     * Service for retrieving user permissions.
+     */
+    @Inject
+    private UserPermissionService userPermissionService;
+
+    /**
+     * The plaintext password previously set by a call to setPassword(), if
+     * any. The password of a user cannot be retrieved once saved into the
+     * database, so this serves to ensure getPassword() returns a reasonable
+     * value if setPassword() is called. If no password has been set, or the
+     * user was retrieved from the database, this will be null.
+     */
+    private String password = null;
+    
+    /**
+     * Creates a new, empty ModeledUser.
+     */
+    public ModeledUser() {
+    }
+
+    @Override
+    public String getPassword() {
+        return password;
+    }
+
+    @Override
+    public void setPassword(String password) {
+
+        UserModel userModel = getModel();
+        
+        // Store plaintext password internally
+        this.password = password;
+
+        // If no password provided, clear password salt and hash
+        if (password == null) {
+            userModel.setPasswordSalt(null);
+            userModel.setPasswordHash(null);
+        }
+
+        // Otherwise generate new salt and hash given password using newly-generated salt
+        else {
+            byte[] salt = saltService.generateSalt();
+            byte[] hash = encryptionService.createPasswordHash(password, salt);
+
+            // Set stored salt and hash
+            userModel.setPasswordSalt(salt);
+            userModel.setPasswordHash(hash);
+        }
+
+    }
+
+    /**
+     * Returns whether this user is a system administrator, and thus is not
+     * restricted by permissions.
+     *
+     * @return
+     *    true if this user is a system administrator, false otherwise.
+     *
+     * @throws GuacamoleException 
+     *    If an error occurs while determining the user's system administrator
+     *    status.
+     */
+    public boolean isAdministrator() throws GuacamoleException {
+        SystemPermissionSet systemPermissionSet = getSystemPermissions();
+        return systemPermissionSet.hasPermission(SystemPermission.Type.ADMINISTER);
+    }
+    
+    @Override
+    public SystemPermissionSet getSystemPermissions()
+            throws GuacamoleException {
+        return systemPermissionService.getPermissionSet(getCurrentUser(), this);
+    }
+
+    @Override
+    public ObjectPermissionSet getConnectionPermissions()
+            throws GuacamoleException {
+        return connectionPermissionService.getPermissionSet(getCurrentUser(), this);
+    }
+
+    @Override
+    public ObjectPermissionSet getConnectionGroupPermissions()
+            throws GuacamoleException {
+        return connectionGroupPermissionService.getPermissionSet(getCurrentUser(), this);
+    }
+
+    @Override
+    public ObjectPermissionSet getActiveConnectionPermissions()
+            throws GuacamoleException {
+        return activeConnectionPermissionService.getPermissionSet(getCurrentUser(), this);
+    }
+
+    @Override
+    public ObjectPermissionSet getUserPermissions()
+            throws GuacamoleException {
+        return userPermissionService.getPermissionSet(getCurrentUser(), this);
+    }
+
+    @Override
+    public Map<String, String> getAttributes() {
+
+        Map<String, String> attributes = new HashMap<String, String>();
+
+        // Set disabled attribute
+        attributes.put(DISABLED_ATTRIBUTE_NAME, getModel().isDisabled() ? "true" : null);
+
+        // Set password expired attribute
+        attributes.put(EXPIRED_ATTRIBUTE_NAME, getModel().isExpired() ? "true" : null);
+
+        // Set access window start time
+        attributes.put(ACCESS_WINDOW_START_ATTRIBUTE_NAME, TimeField.format(getModel().getAccessWindowStart()));
+
+        // Set access window end time
+        attributes.put(ACCESS_WINDOW_END_ATTRIBUTE_NAME, TimeField.format(getModel().getAccessWindowEnd()));
+
+        // Set account validity start date
+        attributes.put(VALID_FROM_ATTRIBUTE_NAME, DateField.format(getModel().getValidFrom()));
+
+        // Set account validity end date
+        attributes.put(VALID_UNTIL_ATTRIBUTE_NAME, DateField.format(getModel().getValidUntil()));
+
+        // Set timezone attribute
+        attributes.put(TIMEZONE_ATTRIBUTE_NAME, getModel().getTimeZone());
+
+        return attributes;
+    }
+
+    /**
+     * Parses the given string into a corresponding date. The string must
+     * follow the standard format used by date attributes, as defined by
+     * DateField.FORMAT and as would be produced by DateField.format().
+     *
+     * @param dateString
+     *     The date string to parse, which may be null.
+     *
+     * @return
+     *     The date corresponding to the given date string, or null if the
+     *     provided date string was null or blank.
+     *
+     * @throws ParseException
+     *     If the given date string does not conform to the standard format
+     *     used by date attributes.
+     */
+    private Date parseDate(String dateString)
+    throws ParseException {
+
+        // Return null if no date provided
+        java.util.Date parsedDate = DateField.parse(dateString);
+        if (parsedDate == null)
+            return null;
+
+        // Convert to SQL Date
+        return new Date(parsedDate.getTime());
+
+    }
+
+    /**
+     * Parses the given string into a corresponding time. The string must
+     * follow the standard format used by time attributes, as defined by
+     * TimeField.FORMAT and as would be produced by TimeField.format().
+     *
+     * @param timeString
+     *     The time string to parse, which may be null.
+     *
+     * @return
+     *     The time corresponding to the given time string, or null if the
+     *     provided time string was null or blank.
+     *
+     * @throws ParseException
+     *     If the given time string does not conform to the standard format
+     *     used by time attributes.
+     */
+    private Time parseTime(String timeString)
+    throws ParseException {
+
+        // Return null if no time provided
+        java.util.Date parsedDate = TimeField.parse(timeString);
+        if (parsedDate == null)
+            return null;
+
+        // Convert to SQL Time 
+        return new Time(parsedDate.getTime());
+
+    }
+
+    @Override
+    public void setAttributes(Map<String, String> attributes) {
+
+        // Translate disabled attribute
+        getModel().setDisabled("true".equals(attributes.get(DISABLED_ATTRIBUTE_NAME)));
+
+        // Translate password expired attribute
+        getModel().setExpired("true".equals(attributes.get(EXPIRED_ATTRIBUTE_NAME)));
+
+        // Translate access window start time
+        try { getModel().setAccessWindowStart(parseTime(attributes.get(ACCESS_WINDOW_START_ATTRIBUTE_NAME))); }
+        catch (ParseException e) {
+            logger.warn("Not setting start time of user access window: {}", e.getMessage());
+            logger.debug("Unable to parse time attribute.", e);
+        }
+
+        // Translate access window end time
+        try { getModel().setAccessWindowEnd(parseTime(attributes.get(ACCESS_WINDOW_END_ATTRIBUTE_NAME))); }
+        catch (ParseException e) {
+            logger.warn("Not setting end time of user access window: {}", e.getMessage());
+            logger.debug("Unable to parse time attribute.", e);
+        }
+
+        // Translate account validity start date
+        try { getModel().setValidFrom(parseDate(attributes.get(VALID_FROM_ATTRIBUTE_NAME))); }
+        catch (ParseException e) {
+            logger.warn("Not setting user validity start date: {}", e.getMessage());
+            logger.debug("Unable to parse date attribute.", e);
+        }
+
+        // Translate account validity end date
+        try { getModel().setValidUntil(parseDate(attributes.get(VALID_UNTIL_ATTRIBUTE_NAME))); }
+        catch (ParseException e) {
+            logger.warn("Not setting user validity end date: {}", e.getMessage());
+            logger.debug("Unable to parse date attribute.", e);
+        }
+
+        // Translate timezone attribute
+        getModel().setTimeZone(TimeZoneField.parse(attributes.get(TIMEZONE_ATTRIBUTE_NAME)));
+
+    }
+
+    /**
+     * Returns the time zone associated with this user. This time zone must be
+     * used when interpreting all date/time restrictions related to this user.
+     *
+     * @return
+     *     The time zone associated with this user.
+     */
+    private TimeZone getTimeZone() {
+
+        // If no time zone is set, use the default
+        String timeZone = getModel().getTimeZone();
+        if (timeZone == null)
+            return TimeZone.getDefault();
+
+        // Otherwise parse and return time zone
+        return TimeZone.getTimeZone(timeZone);
+
+    }
+
+    /**
+     * Converts a SQL Time to a Calendar, independently of time zone, using the
+     * given Calendar as a base. The time components will be copied to the
+     * given Calendar verbatim, leaving the date and time zone components of
+     * the given Calendar otherwise intact.
+     *
+     * @param base
+     *     The Calendar object to use as a base for the conversion.
+     *
+     * @param time
+     *     The SQL Time object containing the time components to be applied to
+     *     the given Calendar.
+     *
+     * @return
+     *     The given Calendar, now modified to represent the given time.
+     */
+    private Calendar asCalendar(Calendar base, Time time) {
+
+        // Get calendar from given SQL time
+        Calendar timeCalendar = Calendar.getInstance();
+        timeCalendar.setTime(time);
+
+        // Apply given time to base calendar
+        base.set(Calendar.HOUR_OF_DAY, timeCalendar.get(Calendar.HOUR_OF_DAY));
+        base.set(Calendar.MINUTE,      timeCalendar.get(Calendar.MINUTE));
+        base.set(Calendar.SECOND,      timeCalendar.get(Calendar.SECOND));
+        base.set(Calendar.MILLISECOND, timeCalendar.get(Calendar.MILLISECOND));
+
+        return base;
+        
+    }
+
+    /**
+     * Returns the time during the current day when this user account can start
+     * being used.
+     *
+     * @return
+     *     The time during the current day when this user account can start
+     *     being used.
+     */
+    private Calendar getAccessWindowStart() {
+
+        // Get window start time
+        Time start = getModel().getAccessWindowStart();
+        if (start == null)
+            return null;
+
+        // Return within defined time zone, current day
+        return asCalendar(Calendar.getInstance(getTimeZone()), start);
+
+    }
+
+    /**
+     * Returns the time during the current day when this user account can no
+     * longer be used.
+     *
+     * @return
+     *     The time during the current day when this user account can no longer
+     *     be used.
+     */
+    private Calendar getAccessWindowEnd() {
+
+        // Get window end time
+        Time end = getModel().getAccessWindowEnd();
+        if (end == null)
+            return null;
+
+        // Return within defined time zone, current day
+        return asCalendar(Calendar.getInstance(getTimeZone()), end);
+
+    }
+
+    /**
+     * Returns the date after which this account becomes valid. The time
+     * components of the resulting Calendar object will be set to midnight of
+     * the date in question.
+     *
+     * @return
+     *     The date after which this account becomes valid.
+     */
+    private Calendar getValidFrom() {
+
+        // Get valid from date
+        Date validFrom = getModel().getValidFrom();
+        if (validFrom == null)
+            return null;
+
+        // Convert to midnight within defined time zone
+        Calendar validFromCalendar = Calendar.getInstance(getTimeZone());
+        validFromCalendar.setTime(validFrom);
+        validFromCalendar.set(Calendar.HOUR_OF_DAY, 0);
+        validFromCalendar.set(Calendar.MINUTE,      0);
+        validFromCalendar.set(Calendar.SECOND,      0);
+        validFromCalendar.set(Calendar.MILLISECOND, 0);
+        return validFromCalendar;
+
+    }
+
+    /**
+     * Returns the date after which this account becomes invalid. The time
+     * components of the resulting Calendar object will be set to the last
+     * millisecond of the day in question (23:59:59.999).
+     *
+     * @return
+     *     The date after which this account becomes invalid.
+     */
+    private Calendar getValidUntil() {
+
+        // Get valid until date
+        Date validUntil = getModel().getValidUntil();
+        if (validUntil == null)
+            return null;
+
+        // Convert to end-of-day within defined time zone
+        Calendar validUntilCalendar = Calendar.getInstance(getTimeZone());
+        validUntilCalendar.setTime(validUntil);
+        validUntilCalendar.set(Calendar.HOUR_OF_DAY,  23);
+        validUntilCalendar.set(Calendar.MINUTE,       59);
+        validUntilCalendar.set(Calendar.SECOND,       59);
+        validUntilCalendar.set(Calendar.MILLISECOND, 999);
+        return validUntilCalendar;
+
+    }
+
+    /**
+     * Given a time when a particular state changes from inactive to active,
+     * and a time when a particular state changes from active to inactive,
+     * determines whether that state is currently active.
+     *
+     * @param activeStart
+     *     The time at which the state changes from inactive to active.
+     *
+     * @param inactiveStart
+     *     The time at which the state changes from active to inactive.
+     *
+     * @return
+     *     true if the state is currently active, false otherwise.
+     */
+    private boolean isActive(Calendar activeStart, Calendar inactiveStart) {
+
+        // If end occurs before start, convert to equivalent case where start
+        // start is before end
+        if (inactiveStart != null && activeStart != null && inactiveStart.before(activeStart))
+            return !isActive(inactiveStart, activeStart);
+
+        // Get current time
+        Calendar current = Calendar.getInstance();
+
+        // State is active iff the current time is between the start and end
+        return !(activeStart != null && current.before(activeStart))
+            && !(inactiveStart != null && current.after(inactiveStart));
+
+    }
+
+    /**
+     * Returns whether this user account is currently valid as of today.
+     * Account validity depends on optional date-driven restrictions which
+     * define when an account becomes valid, and when an account ceases being
+     * valid.
+     *
+     * @return
+     *     true if the account is valid as of today, false otherwise.
+     */
+    public boolean isAccountValid() {
+        return isActive(getValidFrom(), getValidUntil());
+    }
+
+    /**
+     * Returns whether the current time is within this user's allowed access
+     * window. If the login times for this user are not limited, this will
+     * return true.
+     *
+     * @return
+     *     true if the current time is within this user's allowed access
+     *     window, or if this user has no restrictions on login time, false
+     *     otherwise.
+     */
+    public boolean isAccountAccessible() {
+        return isActive(getAccessWindowStart(), getAccessWindowEnd());
+    }
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/user/UserContext.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/user/UserContext.java
new file mode 100644
index 0000000..41e7a6f
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/user/UserContext.java
@@ -0,0 +1,179 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.jdbc.user;
+
+
+import org.glyptodon.guacamole.auth.jdbc.connectiongroup.RootConnectionGroup;
+import org.glyptodon.guacamole.auth.jdbc.connectiongroup.ConnectionGroupDirectory;
+import org.glyptodon.guacamole.auth.jdbc.connection.ConnectionDirectory;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.util.Collection;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.auth.jdbc.base.RestrictedObject;
+import org.glyptodon.guacamole.auth.jdbc.activeconnection.ActiveConnectionDirectory;
+import org.glyptodon.guacamole.auth.jdbc.connection.ConnectionRecordSet;
+import org.glyptodon.guacamole.auth.jdbc.connection.ModeledConnection;
+import org.glyptodon.guacamole.auth.jdbc.connectiongroup.ModeledConnectionGroup;
+import org.glyptodon.guacamole.form.Form;
+import org.glyptodon.guacamole.net.auth.ActiveConnection;
+import org.glyptodon.guacamole.net.auth.AuthenticationProvider;
+import org.glyptodon.guacamole.net.auth.Connection;
+import org.glyptodon.guacamole.net.auth.ConnectionGroup;
+import org.glyptodon.guacamole.net.auth.Directory;
+import org.glyptodon.guacamole.net.auth.User;
+
+/**
+ * UserContext implementation which is driven by an arbitrary, underlying
+ * database.
+ *
+ * @author James Muehlner
+ * @author Michael Jumper
+ */
+public class UserContext extends RestrictedObject
+    implements org.glyptodon.guacamole.net.auth.UserContext {
+
+    /**
+     * The AuthenticationProvider that created this UserContext.
+     */
+    @Inject
+    private AuthenticationProvider authProvider;
+
+    /**
+     * User directory restricted by the permissions of the user associated
+     * with this context.
+     */
+    @Inject
+    private UserDirectory userDirectory;
+ 
+    /**
+     * Connection directory restricted by the permissions of the user
+     * associated with this context.
+     */
+    @Inject
+    private ConnectionDirectory connectionDirectory;
+
+    /**
+     * Connection group directory restricted by the permissions of the user
+     * associated with this context.
+     */
+    @Inject
+    private ConnectionGroupDirectory connectionGroupDirectory;
+
+    /**
+     * ActiveConnection directory restricted by the permissions of the user
+     * associated with this context.
+     */
+    @Inject
+    private ActiveConnectionDirectory activeConnectionDirectory;
+
+    /**
+     * Provider for creating the root group.
+     */
+    @Inject
+    private Provider<RootConnectionGroup> rootGroupProvider;
+
+    /**
+     * Provider for creating connection record sets.
+     */
+    @Inject
+    private Provider<ConnectionRecordSet> connectionRecordSetProvider;
+    
+    @Override
+    public void init(AuthenticatedUser currentUser) {
+
+        super.init(currentUser);
+        
+        // Init directories
+        userDirectory.init(currentUser);
+        connectionDirectory.init(currentUser);
+        connectionGroupDirectory.init(currentUser);
+        activeConnectionDirectory.init(currentUser);
+
+    }
+
+    @Override
+    public User self() {
+        return getCurrentUser().getUser();
+    }
+
+    @Override
+    public AuthenticationProvider getAuthenticationProvider() {
+        return authProvider;
+    }
+
+    @Override
+    public Directory<User> getUserDirectory() throws GuacamoleException {
+        return userDirectory;
+    }
+
+    @Override
+    public Directory<Connection> getConnectionDirectory() throws GuacamoleException {
+        return connectionDirectory;
+    }
+
+    @Override
+    public Directory<ConnectionGroup> getConnectionGroupDirectory() throws GuacamoleException {
+        return connectionGroupDirectory;
+    }
+
+    @Override
+    public Directory<ActiveConnection> getActiveConnectionDirectory()
+            throws GuacamoleException {
+        return activeConnectionDirectory;
+    }
+
+    @Override
+    public ConnectionRecordSet getConnectionHistory()
+            throws GuacamoleException {
+        ConnectionRecordSet connectionRecordSet = connectionRecordSetProvider.get();
+        connectionRecordSet.init(getCurrentUser());
+        return connectionRecordSet;
+    }
+
+    @Override
+    public ConnectionGroup getRootConnectionGroup() throws GuacamoleException {
+
+        // Build and return a root group for the current user
+        RootConnectionGroup rootGroup = rootGroupProvider.get();
+        rootGroup.init(getCurrentUser());
+        return rootGroup;
+
+    }
+
+    @Override
+    public Collection<Form> getUserAttributes() {
+        return ModeledUser.ATTRIBUTES;
+    }
+
+    @Override
+    public Collection<Form> getConnectionAttributes() {
+        return ModeledConnection.ATTRIBUTES;
+    }
+
+    @Override
+    public Collection<Form> getConnectionGroupAttributes() {
+        return ModeledConnectionGroup.ATTRIBUTES;
+    }
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/user/UserDirectory.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/user/UserDirectory.java
new file mode 100644
index 0000000..826957b
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/user/UserDirectory.java
@@ -0,0 +1,89 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.jdbc.user;
+
+
+import com.google.inject.Inject;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Set;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.auth.jdbc.base.RestrictedObject;
+import org.glyptodon.guacamole.net.auth.Directory;
+import org.glyptodon.guacamole.net.auth.User;
+import org.mybatis.guice.transactional.Transactional;
+
+/**
+ * Implementation of the User Directory which is driven by an underlying,
+ * arbitrary database.
+ *
+ * @author James Muehlner
+ * @author Michael Jumper
+ */
+public class UserDirectory extends RestrictedObject
+    implements Directory<User> {
+
+    /**
+     * Service for managing user objects.
+     */
+    @Inject
+    private UserService userService;
+
+    @Override
+    public User get(String identifier) throws GuacamoleException {
+        return userService.retrieveObject(getCurrentUser(), identifier);
+    }
+
+    @Override
+    @Transactional
+    public Collection<User> getAll(Collection<String> identifiers) throws GuacamoleException {
+        Collection<ModeledUser> objects = userService.retrieveObjects(getCurrentUser(), identifiers);
+        return Collections.<User>unmodifiableCollection(objects);
+    }
+
+    @Override
+    @Transactional
+    public Set<String> getIdentifiers() throws GuacamoleException {
+        return userService.getIdentifiers(getCurrentUser());
+    }
+
+    @Override
+    @Transactional
+    public void add(User object) throws GuacamoleException {
+        userService.createObject(getCurrentUser(), object);
+    }
+
+    @Override
+    @Transactional
+    public void update(User object) throws GuacamoleException {
+        ModeledUser user = (ModeledUser) object;
+        userService.updateObject(getCurrentUser(), user);
+    }
+
+    @Override
+    @Transactional
+    public void remove(String identifier) throws GuacamoleException {
+        userService.deleteObject(getCurrentUser(), identifier);
+    }
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/user/UserMapper.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/user/UserMapper.java
new file mode 100644
index 0000000..8627390
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/user/UserMapper.java
@@ -0,0 +1,47 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.jdbc.user;
+
+import org.glyptodon.guacamole.auth.jdbc.base.ModeledDirectoryObjectMapper;
+import org.apache.ibatis.annotations.Param;
+
+/**
+ * Mapper for user objects.
+ *
+ * @author Michael Jumper
+ */
+public interface UserMapper extends ModeledDirectoryObjectMapper<UserModel> {
+
+    /**
+     * Returns the user having the given username, if any. If no such user
+     * exists, null is returned.
+     *
+     * @param username
+     *     The username of the user to return.
+     *
+     * @return
+     *     The user having the given username, or null if no such user exists.
+     */
+    UserModel selectOne(@Param("username") String username);
+    
+}
\ No newline at end of file
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/user/UserModel.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/user/UserModel.java
new file mode 100644
index 0000000..d1ca751
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/user/UserModel.java
@@ -0,0 +1,329 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.jdbc.user;
+
+import java.sql.Date;
+import java.sql.Time;
+import org.glyptodon.guacamole.auth.jdbc.base.ObjectModel;
+
+/**
+ * Object representation of a Guacamole user, as represented in the database.
+ *
+ * @author Michael Jumper
+ */
+public class UserModel extends ObjectModel {
+
+    /**
+     * The SHA-256 hash of the password and salt.
+     */
+    private byte[] passwordHash;
+
+    /**
+     * The 32-byte random binary password salt that was appended to the
+     * password prior to hashing.
+     */
+    private byte[] passwordSalt;
+
+    /**
+     * Whether the user account is disabled. Disabled accounts exist and can
+     * be modified, but cannot be used.
+     */
+    private boolean disabled;
+
+    /**
+     * Whether the user's password is expired. If a user's password is expired,
+     * it must be changed immediately upon login, and the account cannot be
+     * used until this occurs.
+     */
+    private boolean expired;
+
+    /**
+     * The time each day after which this user account may be used, stored in
+     * local time according to the value of timeZone.
+     */
+    private Time accessWindowStart;
+
+    /**
+     * The time each day after which this user account may NOT be used, stored
+     * in local time according to the value of timeZone.
+     */
+    private Time accessWindowEnd;
+
+    /**
+     * The day after which this account becomes valid and usable. Account
+     * validity begins at midnight of this day. Time information within the
+     * Date object is ignored.
+     */
+    private Date validFrom;
+
+    /**
+     * The day after which this account can no longer be used. Account validity
+     * ends at midnight of the day following this day. Time information within
+     * the Date object is ignored.
+     */
+    private Date validUntil;
+
+    /**
+     * The ID of the time zone used for all time comparisons for this user.
+     * Both accessWindowStart and accessWindowEnd values will use this time
+     * zone, as will checks for whether account validity dates have passed. If
+     * unset, the server's local time zone is used.
+     */
+    private String timeZone;
+
+    /**
+     * Creates a new, empty user.
+     */
+    public UserModel() {
+    }
+
+    /**
+     * Returns the hash of this user's password and password salt. This may be
+     * null if the user was not retrieved from the database, and setPassword()
+     * has not yet been called.
+     *
+     * @return
+     *     The hash of this user's password and password salt.
+     */
+    public byte[] getPasswordHash() {
+        return passwordHash;
+    }
+
+    /**
+     * Sets the hash of this user's password and password salt. This is
+     * normally only set upon retrieval from the database, or through a call
+     * to the higher-level setPassword() function.
+     *
+     * @param passwordHash
+     *     The hash of this user's password and password salt.
+     */
+    public void setPasswordHash(byte[] passwordHash) {
+        this.passwordHash = passwordHash;
+    }
+
+    /**
+     * Returns the random salt that was used when generating this user's
+     * password hash. This may be null if the user was not retrieved from the
+     * database, and setPassword() has not yet been called.
+     *
+     * @return
+     *     The random salt that was used when generating this user's password
+     *     hash.
+     */
+    public byte[] getPasswordSalt() {
+        return passwordSalt;
+    }
+
+    /**
+     * Sets the random salt that was used when generating this user's password
+     * hash. This is normally only set upon retrieval from the database, or
+     * through a call to the higher-level setPassword() function.
+     *
+     * @param passwordSalt
+     *     The random salt used when generating this user's password hash.
+     */
+    public void setPasswordSalt(byte[] passwordSalt) {
+        this.passwordSalt = passwordSalt;
+    }
+
+    /**
+     * Returns whether the user has been disabled. Disabled users are not
+     * allowed to login. Although their account data exists, all login attempts
+     * will fail as if the account does not exist.
+     *
+     * @return
+     *     true if the account is disabled, false otherwise.
+     */
+    public boolean isDisabled() {
+        return disabled;
+    }
+
+    /**
+     * Sets whether the user is disabled. Disabled users are not allowed to
+     * login. Although their account data exists, all login attempts will fail
+     * as if the account does not exist.
+     *
+     * @param disabled
+     *     true if the account should be disabled, false otherwise.
+     */
+    public void setDisabled(boolean disabled) {
+        this.disabled = disabled;
+    }
+
+    /**
+     * Returns whether the user's password has expired. If a user's password is
+     * expired, it must be immediately changed upon login. A user account with
+     * an expired password cannot be used until the password has been changed.
+     *
+     * @return
+     *     true if the user's password has expired, false otherwise.
+     */
+    public boolean isExpired() {
+        return expired;
+    }
+
+    /**
+     * Sets whether the user's password is expired. If a user's password is
+     * expired, it must be immediately changed upon login. A user account with
+     * an expired password cannot be used until the password has been changed.
+     *
+     * @param expired
+     *     true to expire the user's password, false otherwise.
+     */
+    public void setExpired(boolean expired) {
+        this.expired = expired;
+    }
+
+    /**
+     * Returns the time each day after which this user account may be used. The
+     * time returned will be local time according to the time zone set with
+     * setTimeZone().
+     *
+     * @return
+     *     The time each day after which this user account may be used, or null
+     *     if this restriction does not apply.
+     */
+    public Time getAccessWindowStart() {
+        return accessWindowStart;
+    }
+
+    /**
+     * Sets the time each day after which this user account may be used. The
+     * time given must be in local time according to the time zone set with
+     * setTimeZone().
+     *
+     * @param accessWindowStart
+     *     The time each day after which this user account may be used, or null
+     *     if this restriction does not apply.
+     */
+    public void setAccessWindowStart(Time accessWindowStart) {
+        this.accessWindowStart = accessWindowStart;
+    }
+
+    /**
+     * Returns the time each day after which this user account may NOT be used.
+     * The time returned will be local time according to the time zone set with
+     * setTimeZone().
+     *
+     * @return
+     *     The time each day after which this user account may NOT be used, or
+     *     null if this restriction does not apply.
+     */
+    public Time getAccessWindowEnd() {
+        return accessWindowEnd;
+    }
+
+    /**
+     * Sets the time each day after which this user account may NOT be used.
+     * The time given must be in local time according to the time zone set with
+     * setTimeZone().
+     *
+     * @param accessWindowEnd
+     *     The time each day after which this user account may NOT be used, or
+     *     null if this restriction does not apply.
+     */
+    public void setAccessWindowEnd(Time accessWindowEnd) {
+        this.accessWindowEnd = accessWindowEnd;
+    }
+
+    /**
+     * Returns the day after which this account becomes valid and usable.
+     * Account validity begins at midnight of this day. Any time information
+     * within the returned Date object must be ignored.
+     *
+     * @return
+     *     The day after which this account becomes valid and usable, or null
+     *     if this restriction does not apply.
+     */
+    public Date getValidFrom() {
+        return validFrom;
+    }
+
+    /**
+     * Sets the day after which this account becomes valid and usable. Account
+     * validity begins at midnight of this day. Any time information within
+     * the provided Date object will be ignored.
+     *
+     * @param validFrom
+     *     The day after which this account becomes valid and usable, or null
+     *     if this restriction does not apply.
+     */
+    public void setValidFrom(Date validFrom) {
+        this.validFrom = validFrom;
+    }
+
+    /**
+     * Returns the day after which this account can no longer be used. Account
+     * validity ends at midnight of the day following this day. Any time
+     * information within the returned Date object must be ignored.
+     *
+     * @return
+     *     The day after which this account can no longer be used, or null if
+     *     this restriction does not apply.
+     */
+    public Date getValidUntil() {
+        return validUntil;
+    }
+
+    /**
+     * Sets the day after which this account can no longer be used. Account
+     * validity ends at midnight of the day following this day. Any time
+     * information within the provided Date object will be ignored.
+     *
+     * @param validUntil
+     *     The day after which this account can no longer be used, or null if
+     *     this restriction does not apply.
+     */
+    public void setValidUntil(Date validUntil) {
+        this.validUntil = validUntil;
+    }
+
+    /**
+     * Returns the Java ID of the time zone to be used for all time comparisons
+     * for this user. This ID should correspond to a value returned by
+     * TimeZone.getAvailableIDs(). If unset or invalid, the server's local time
+     * zone must be used.
+     *
+     * @return
+     *     The ID of the time zone to be used for all time comparisons, which
+     *     should correspond to a value returned by TimeZone.getAvailableIDs().
+     */
+    public String getTimeZone() {
+        return timeZone;
+    }
+
+    /**
+     * Sets the Java ID of the time zone to be used for all time comparisons
+     * for this user. This ID should correspond to a value returned by
+     * TimeZone.getAvailableIDs(). If unset or invalid, the server's local time
+     * zone will be used.
+     *
+     * @param timeZone
+     *     The ID of the time zone to be used for all time comparisons, which
+     *     should correspond to a value returned by TimeZone.getAvailableIDs().
+     */
+    public void setTimeZone(String timeZone) {
+        this.timeZone = timeZone;
+    }
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/user/UserService.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/user/UserService.java
new file mode 100644
index 0000000..6f609b2
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/user/UserService.java
@@ -0,0 +1,389 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.jdbc.user;
+
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import javax.servlet.http.HttpServletRequest;
+import org.glyptodon.guacamole.net.auth.Credentials;
+import org.glyptodon.guacamole.auth.jdbc.base.ModeledDirectoryObjectMapper;
+import org.glyptodon.guacamole.auth.jdbc.base.ModeledDirectoryObjectService;
+import org.glyptodon.guacamole.GuacamoleClientException;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.GuacamoleUnsupportedException;
+import org.glyptodon.guacamole.auth.jdbc.permission.ObjectPermissionMapper;
+import org.glyptodon.guacamole.auth.jdbc.permission.ObjectPermissionModel;
+import org.glyptodon.guacamole.auth.jdbc.permission.UserPermissionMapper;
+import org.glyptodon.guacamole.auth.jdbc.security.PasswordEncryptionService;
+import org.glyptodon.guacamole.form.Field;
+import org.glyptodon.guacamole.form.PasswordField;
+import org.glyptodon.guacamole.net.auth.AuthenticationProvider;
+import org.glyptodon.guacamole.net.auth.User;
+import org.glyptodon.guacamole.net.auth.credentials.CredentialsInfo;
+import org.glyptodon.guacamole.net.auth.credentials.GuacamoleInsufficientCredentialsException;
+import org.glyptodon.guacamole.net.auth.permission.ObjectPermission;
+import org.glyptodon.guacamole.net.auth.permission.ObjectPermissionSet;
+import org.glyptodon.guacamole.net.auth.permission.SystemPermission;
+import org.glyptodon.guacamole.net.auth.permission.SystemPermissionSet;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Service which provides convenience methods for creating, retrieving, and
+ * manipulating users.
+ *
+ * @author Michael Jumper, James Muehlner
+ */
+public class UserService extends ModeledDirectoryObjectService<ModeledUser, User, UserModel> {
+    
+    /**
+     * Logger for this class.
+     */
+    private static final Logger logger = LoggerFactory.getLogger(UserService.class);
+
+    /**
+     * All user permissions which are implicitly granted to the new user upon
+     * creation.
+     */
+    private static final ObjectPermission.Type[] IMPLICIT_USER_PERMISSIONS = {
+        ObjectPermission.Type.READ
+    };
+
+    /**
+     * The name of the HTTP password parameter to expect if the user is
+     * changing their expired password upon login.
+     */
+    private static final String NEW_PASSWORD_PARAMETER = "new-password";
+
+    /**
+     * The password field to provide the user when their password is expired
+     * and must be changed.
+     */
+    private static final Field NEW_PASSWORD = new PasswordField(NEW_PASSWORD_PARAMETER);
+
+    /**
+     * The name of the HTTP password confirmation parameter to expect if the
+     * user is changing their expired password upon login.
+     */
+    private static final String CONFIRM_NEW_PASSWORD_PARAMETER = "confirm-new-password";
+
+    /**
+     * The password confirmation field to provide the user when their password
+     * is expired and must be changed.
+     */
+    private static final Field CONFIRM_NEW_PASSWORD = new PasswordField(CONFIRM_NEW_PASSWORD_PARAMETER);
+
+    /**
+     * Information describing the expected credentials if a user's password is
+     * expired. If a user's password is expired, it must be changed during the
+     * login process.
+     */
+    private static final CredentialsInfo EXPIRED_PASSWORD = new CredentialsInfo(Arrays.asList(
+        CredentialsInfo.USERNAME,
+        CredentialsInfo.PASSWORD,
+        NEW_PASSWORD,
+        CONFIRM_NEW_PASSWORD
+    ));
+
+    /**
+     * Mapper for accessing users.
+     */
+    @Inject
+    private UserMapper userMapper;
+
+    /**
+     * Mapper for manipulating user permissions.
+     */
+    @Inject
+    private UserPermissionMapper userPermissionMapper;
+    
+    /**
+     * Provider for creating users.
+     */
+    @Inject
+    private Provider<ModeledUser> userProvider;
+
+    /**
+     * Service for hashing passwords.
+     */
+    @Inject
+    private PasswordEncryptionService encryptionService;
+
+    @Override
+    protected ModeledDirectoryObjectMapper<UserModel> getObjectMapper() {
+        return userMapper;
+    }
+
+    @Override
+    protected ObjectPermissionMapper getPermissionMapper() {
+        return userPermissionMapper;
+    }
+
+    @Override
+    protected ModeledUser getObjectInstance(AuthenticatedUser currentUser,
+            UserModel model) {
+        ModeledUser user = userProvider.get();
+        user.init(currentUser, model);
+        return user;
+    }
+
+    @Override
+    protected UserModel getModelInstance(AuthenticatedUser currentUser,
+            final User object) {
+
+        // Create new ModeledUser backed by blank model
+        UserModel model = new UserModel();
+        ModeledUser user = getObjectInstance(currentUser, model);
+
+        // Set model contents through ModeledUser, copying the provided user
+        user.setIdentifier(object.getIdentifier());
+        user.setPassword(object.getPassword());
+        user.setAttributes(object.getAttributes());
+
+        return model;
+        
+    }
+
+    @Override
+    protected boolean hasCreatePermission(AuthenticatedUser user)
+            throws GuacamoleException {
+
+        // Return whether user has explicit user creation permission
+        SystemPermissionSet permissionSet = user.getUser().getSystemPermissions();
+        return permissionSet.hasPermission(SystemPermission.Type.CREATE_USER);
+
+    }
+
+    @Override
+    protected ObjectPermissionSet getPermissionSet(AuthenticatedUser user)
+            throws GuacamoleException {
+
+        // Return permissions related to users
+        return user.getUser().getUserPermissions();
+
+    }
+
+    @Override
+    protected void beforeCreate(AuthenticatedUser user, UserModel model)
+            throws GuacamoleException {
+
+        super.beforeCreate(user, model);
+        
+        // Username must not be blank
+        if (model.getIdentifier() == null || model.getIdentifier().trim().isEmpty())
+            throw new GuacamoleClientException("The username must not be blank.");
+        
+        // Do not create duplicate users
+        Collection<UserModel> existing = userMapper.select(Collections.singleton(model.getIdentifier()));
+        if (!existing.isEmpty())
+            throw new GuacamoleClientException("User \"" + model.getIdentifier() + "\" already exists.");
+
+    }
+
+    @Override
+    protected void beforeUpdate(AuthenticatedUser user,
+            UserModel model) throws GuacamoleException {
+
+        super.beforeUpdate(user, model);
+        
+        // Username must not be blank
+        if (model.getIdentifier() == null || model.getIdentifier().trim().isEmpty())
+            throw new GuacamoleClientException("The username must not be blank.");
+        
+        // Check whether such a user is already present
+        UserModel existing = userMapper.selectOne(model.getIdentifier());
+        if (existing != null) {
+
+            // Do not rename to existing user
+            if (!existing.getObjectID().equals(model.getObjectID()))
+                throw new GuacamoleClientException("User \"" + model.getIdentifier() + "\" already exists.");
+            
+        }
+        
+    }
+
+    @Override
+    protected Collection<ObjectPermissionModel>
+        getImplicitPermissions(AuthenticatedUser user, UserModel model) {
+            
+        // Get original set of implicit permissions
+        Collection<ObjectPermissionModel> implicitPermissions = super.getImplicitPermissions(user, model);
+        
+        // Grant implicit permissions to the new user
+        for (ObjectPermission.Type permissionType : IMPLICIT_USER_PERMISSIONS) {
+            
+            ObjectPermissionModel permissionModel = new ObjectPermissionModel();
+            permissionModel.setUserID(model.getObjectID());
+            permissionModel.setUsername(model.getIdentifier());
+            permissionModel.setType(permissionType);
+            permissionModel.setObjectIdentifier(model.getIdentifier());
+
+            // Add new permission to implicit permission set 
+            implicitPermissions.add(permissionModel);
+            
+        }
+        
+        return implicitPermissions;
+    }
+        
+    @Override
+    protected void beforeDelete(AuthenticatedUser user, String identifier) throws GuacamoleException {
+
+        super.beforeDelete(user, identifier);
+
+        // Do not allow users to delete themselves
+        if (identifier.equals(user.getUser().getIdentifier()))
+            throw new GuacamoleUnsupportedException("Deleting your own user is not allowed.");
+
+    }
+
+    /**
+     * Retrieves the user corresponding to the given credentials from the
+     * database. If the user account is expired, and the credentials contain
+     * the necessary additional parameters to reset the user's password, the
+     * password is reset.
+     *
+     * @param authenticationProvider
+     *     The AuthenticationProvider on behalf of which the user is being
+     *     retrieved.
+     *
+     * @param credentials
+     *     The credentials to use when locating the user.
+     *
+     * @return
+     *     An AuthenticatedUser containing the existing ModeledUser object if
+     *     the credentials given are valid, null otherwise.
+     *
+     * @throws GuacamoleException
+     *     If the provided credentials to not conform to expectations.
+     */
+    public AuthenticatedUser retrieveAuthenticatedUser(AuthenticationProvider authenticationProvider,
+            Credentials credentials) throws GuacamoleException {
+
+        // Get username and password
+        String username = credentials.getUsername();
+        String password = credentials.getPassword();
+
+        // Retrieve corresponding user model, if such a user exists
+        UserModel userModel = userMapper.selectOne(username);
+        if (userModel == null)
+            return null;
+
+        // If user is disabled, pretend user does not exist
+        if (userModel.isDisabled())
+            return null;
+
+        // Verify provided password is correct
+        byte[] hash = encryptionService.createPasswordHash(password, userModel.getPasswordSalt());
+        if (!Arrays.equals(hash, userModel.getPasswordHash()))
+            return null;
+
+        // Create corresponding user object, set up cyclic reference
+        ModeledUser user = getObjectInstance(null, userModel);
+        user.setCurrentUser(new AuthenticatedUser(authenticationProvider, user, credentials));
+
+        // Verify user account is still valid as of today
+        if (!user.isAccountValid())
+            throw new GuacamoleClientException("LOGIN.ERROR_NOT_VALID");
+
+        // Verify user account is allowed to be used at the current time
+        if (!user.isAccountAccessible())
+            throw new GuacamoleClientException("LOGIN.ERROR_NOT_ACCESSIBLE");
+
+        // Update password if password is expired
+        if (userModel.isExpired()) {
+
+            // Pull new password from HTTP request
+            HttpServletRequest request = credentials.getRequest();
+            String newPassword = request.getParameter(NEW_PASSWORD_PARAMETER);
+            String confirmNewPassword = request.getParameter(CONFIRM_NEW_PASSWORD_PARAMETER);
+
+            // Require new password if account is expired
+            if (newPassword == null || confirmNewPassword == null) {
+                logger.info("The password of user \"{}\" has expired and must be reset.", username);
+                throw new GuacamoleInsufficientCredentialsException("LOGIN.INFO_PASSWORD_EXPIRED", EXPIRED_PASSWORD);
+            }
+
+            // New password must be different from old password
+            if (newPassword.equals(credentials.getPassword()))
+                throw new GuacamoleClientException("LOGIN.ERROR_PASSWORD_SAME");
+
+            // New password must not be blank
+            if (newPassword.isEmpty())
+                throw new GuacamoleClientException("LOGIN.ERROR_PASSWORD_BLANK");
+
+            // Confirm that the password was entered correctly twice
+            if (!newPassword.equals(confirmNewPassword))
+                throw new GuacamoleClientException("LOGIN.ERROR_PASSWORD_MISMATCH");
+
+            // Change password and reset expiration flag
+            userModel.setExpired(false);
+            user.setPassword(newPassword);
+            userMapper.update(userModel);
+            logger.info("Expired password of user \"{}\" has been reset.", username);
+
+        }
+
+        // Return now-authenticated user
+        return user.getCurrentUser();
+
+    }
+
+    /**
+     * Retrieves the user corresponding to the given AuthenticatedUser from the
+     * database.
+     *
+     * @param authenticatedUser
+     *     The AuthenticatedUser to retrieve the corresponding ModeledUser of.
+     *
+     * @return
+     *     The ModeledUser which corresponds to the given AuthenticatedUser, or
+     *     null if no such user exists.
+     */
+    public ModeledUser retrieveUser(org.glyptodon.guacamole.net.auth.AuthenticatedUser authenticatedUser) {
+
+        // If we already queried this user, return that rather than querying again
+        if (authenticatedUser instanceof AuthenticatedUser)
+            return ((AuthenticatedUser) authenticatedUser).getUser();
+
+        // Get username
+        String username = authenticatedUser.getIdentifier();
+
+        // Retrieve corresponding user model, if such a user exists
+        UserModel userModel = userMapper.selectOne(username);
+        if (userModel == null)
+            return null;
+
+        // Create corresponding user object, set up cyclic reference
+        ModeledUser user = getObjectInstance(null, userModel);
+        user.setCurrentUser(new AuthenticatedUser(authenticatedUser.getAuthenticationProvider(), user, authenticatedUser.getCredentials()));
+
+        // Return already-authenticated user
+        return user;
+
+    }
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/user/package-info.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/user/package-info.java
new file mode 100644
index 0000000..e5c1570
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/glyptodon/guacamole/auth/jdbc/user/package-info.java
@@ -0,0 +1,26 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Classes related to Guacamole users.
+ */
+package org.glyptodon.guacamole.auth.jdbc.user;
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/resources/translations/en.json b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/resources/translations/en.json
new file mode 100644
index 0000000..189018b
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/resources/translations/en.json
@@ -0,0 +1,58 @@
+{
+
+    "LOGIN" : {
+
+        "ERROR_PASSWORD_BLANK"    : "@:APP.ERROR_PASSWORD_BLANK",
+        "ERROR_PASSWORD_SAME"     : "The new password must be different from the expired password.",
+        "ERROR_PASSWORD_MISMATCH" : "@:APP.ERROR_PASSWORD_MISMATCH",
+        "ERROR_NOT_VALID"         : "This user account is not currently valid.",
+        "ERROR_NOT_ACCESSIBLE"    : "Access to this account is not currently allowed. Please try again later.",
+
+        "INFO_PASSWORD_EXPIRED" : "Your password has expired and must be reset. Please enter a new password to continue.",
+
+        "FIELD_HEADER_NEW_PASSWORD"         : "New password",
+        "FIELD_HEADER_CONFIRM_NEW_PASSWORD" : "Confirm new password"
+
+    },
+
+    "CONNECTION_ATTRIBUTES" : {
+
+        "FIELD_HEADER_MAX_CONNECTIONS"          : "Maximum number of connections:",
+        "FIELD_HEADER_MAX_CONNECTIONS_PER_USER" : "Maximum number of connections per user:",
+
+        "SECTION_HEADER_CONCURRENCY" : "Concurrency Limits"
+
+    },
+
+    "CONNECTION_GROUP_ATTRIBUTES" : {
+
+        "FIELD_HEADER_MAX_CONNECTIONS"          : "Maximum number of connections:",
+        "FIELD_HEADER_MAX_CONNECTIONS_PER_USER" : "Maximum number of connections per user:",
+
+        "SECTION_HEADER_CONCURRENCY" : "Concurrency Limits (Balancing Groups)"
+
+    },
+
+    "DATA_SOURCE_MYSQL" : {
+        "NAME" : "MySQL"
+    },
+
+    "DATA_SOURCE_POSTGRESQL" : {
+        "NAME" : "PostgreSQL"
+    },
+
+    "USER_ATTRIBUTES" : {
+
+        "FIELD_HEADER_DISABLED"            : "Login disabled:",
+        "FIELD_HEADER_EXPIRED"             : "Password expired:",
+        "FIELD_HEADER_ACCESS_WINDOW_END"   : "Do not allow access after:",
+        "FIELD_HEADER_ACCESS_WINDOW_START" : "Allow access after:",
+        "FIELD_HEADER_TIMEZONE"            : "User time zone:",
+        "FIELD_HEADER_VALID_FROM"          : "Enable account after:",
+        "FIELD_HEADER_VALID_UNTIL"         : "Disable account after:",
+
+        "SECTION_HEADER_RESTRICTIONS" : "Account Restrictions"
+
+    }
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/resources/translations/fr.json b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/resources/translations/fr.json
new file mode 100644
index 0000000..5369d51
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/resources/translations/fr.json
@@ -0,0 +1,13 @@
+{
+
+    "LOGIN" : {
+
+        "ERROR_PASSWORD_BLANK"    : "@:APP.ERROR_PASSWORD_BLANK",
+        "ERROR_PASSWORD_MISMATCH" : "@:APP.ERROR_PASSWORD_MISMATCH",
+
+        "FIELD_HEADER_NEW_PASSWORD"         : "Mot de passe",
+        "FIELD_HEADER_CONFIRM_NEW_PASSWORD" : "Répéter mot de passe"
+
+    }
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/resources/translations/ru.json b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/resources/translations/ru.json
new file mode 100644
index 0000000..4811b49
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/resources/translations/ru.json
@@ -0,0 +1,13 @@
+{
+
+    "LOGIN" : {
+
+        "ERROR_PASSWORD_BLANK"    : "@:APP.ERROR_PASSWORD_BLANK",
+        "ERROR_PASSWORD_MISMATCH" : "@:APP.ERROR_PASSWORD_MISMATCH",
+
+        "FIELD_HEADER_NEW_PASSWORD"         : "Новый пароль",
+        "FIELD_HEADER_CONFIRM_NEW_PASSWORD" : "Подтверждение пароля"
+
+    }
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/pom.xml b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/pom.xml
new file mode 100644
index 0000000..b52a70d
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/pom.xml
@@ -0,0 +1,82 @@
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
+
+    <modelVersion>4.0.0</modelVersion>
+    <groupId>org.glyptodon.guacamole</groupId>
+    <artifactId>guacamole-auth-jdbc-mysql</artifactId>
+    <packaging>jar</packaging>
+    <name>guacamole-auth-jdbc-mysql</name>
+    <url>http://guac-dev.org/</url>
+
+    <properties>
+        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+    </properties>
+
+    <parent>
+        <groupId>org.glyptodon.guacamole</groupId>
+        <artifactId>guacamole-auth-jdbc</artifactId>
+        <version>0.9.9</version>
+        <relativePath>../../</relativePath>
+    </parent>
+
+    <build>
+        <plugins>
+
+            <!-- Written for 1.6 -->
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-compiler-plugin</artifactId>
+                <version>3.3</version>
+                <configuration>
+                    <source>1.6</source>
+                    <target>1.6</target>
+                    <compilerArgs>
+                        <arg>-Xlint:all</arg>
+                        <arg>-Werror</arg>
+                    </compilerArgs>
+                    <fork>true</fork>
+                </configuration>
+            </plugin>
+
+            <!-- Copy dependencies prior to packaging -->
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-dependency-plugin</artifactId>
+                <version>2.10</version>
+                <executions>
+                    <execution>
+                        <id>unpack-dependencies</id>
+                        <phase>prepare-package</phase>
+                        <goals>
+                            <goal>unpack-dependencies</goal>
+                        </goals>
+                        <configuration>
+                            <includeScope>runtime</includeScope>
+                            <outputDirectory>${project.build.directory}/classes</outputDirectory>
+                        </configuration>
+                    </execution>
+                </executions>
+            </plugin>
+
+        </plugins>
+    </build>
+
+    <dependencies>
+
+        <!-- Guacamole Extension API -->
+        <dependency>
+            <groupId>org.glyptodon.guacamole</groupId>
+            <artifactId>guacamole-ext</artifactId>
+            <scope>provided</scope>
+        </dependency>
+
+        <!-- Guacamole JDBC Authentication -->
+        <dependency>
+            <groupId>org.glyptodon.guacamole</groupId>
+            <artifactId>guacamole-auth-jdbc-base</artifactId>
+            <version>0.9.9</version>
+        </dependency>
+
+    </dependencies>
+
+</project>
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/schema/001-create-schema.sql b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/schema/001-create-schema.sql
new file mode 100644
index 0000000..d56d44c
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/schema/001-create-schema.sql
@@ -0,0 +1,257 @@
+--
+-- Copyright (C) 2013 Glyptodon LLC
+--
+-- Permission is hereby granted, free of charge, to any person obtaining a copy
+-- of this software and associated documentation files (the "Software"), to deal
+-- in the Software without restriction, including without limitation the rights
+-- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+-- copies of the Software, and to permit persons to whom the Software is
+-- furnished to do so, subject to the following conditions:
+--
+-- The above copyright notice and this permission notice shall be included in
+-- all copies or substantial portions of the Software.
+--
+-- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+-- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+-- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+-- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+-- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+-- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+-- THE SOFTWARE.
+--
+
+--
+-- Table of connection groups. Each connection group has a name.
+--
+
+CREATE TABLE `guacamole_connection_group` (
+
+  `connection_group_id`   int(11)      NOT NULL AUTO_INCREMENT,
+  `parent_id`             int(11),
+  `connection_group_name` varchar(128) NOT NULL,
+  `type`                  enum('ORGANIZATIONAL',
+                               'BALANCING') NOT NULL DEFAULT 'ORGANIZATIONAL',
+
+  -- Concurrency limits
+  `max_connections`          int(11),
+  `max_connections_per_user` int(11),
+
+  PRIMARY KEY (`connection_group_id`),
+  UNIQUE KEY `connection_group_name_parent` (`connection_group_name`, `parent_id`),
+
+  CONSTRAINT `guacamole_connection_group_ibfk_1`
+    FOREIGN KEY (`parent_id`)
+    REFERENCES `guacamole_connection_group` (`connection_group_id`) ON DELETE CASCADE
+
+) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+
+--
+-- Table of connections. Each connection has a name, protocol, and
+-- associated set of parameters.
+-- A connection may belong to a connection group.
+--
+
+CREATE TABLE `guacamole_connection` (
+
+  `connection_id`       int(11)      NOT NULL AUTO_INCREMENT,
+  `connection_name`     varchar(128) NOT NULL,
+  `parent_id`           int(11),
+  `protocol`            varchar(32)  NOT NULL,
+  
+  -- Concurrency limits
+  `max_connections`          int(11),
+  `max_connections_per_user` int(11),
+
+  PRIMARY KEY (`connection_id`),
+  UNIQUE KEY `connection_name_parent` (`connection_name`, `parent_id`),
+
+  CONSTRAINT `guacamole_connection_ibfk_1`
+    FOREIGN KEY (`parent_id`)
+    REFERENCES `guacamole_connection_group` (`connection_group_id`) ON DELETE CASCADE
+
+) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+
+--
+-- Table of users. Each user has a unique username and a hashed password
+-- with corresponding salt. Although the authentication system will always set
+-- salted passwords, other systems may set unsalted passwords by simply not
+-- providing the salt.
+--
+
+CREATE TABLE `guacamole_user` (
+
+  `user_id`       int(11)      NOT NULL AUTO_INCREMENT,
+
+  -- Username and optionally-salted password
+  `username`      varchar(128) NOT NULL,
+  `password_hash` binary(32)   NOT NULL,
+  `password_salt` binary(32),
+
+  -- Account disabled/expired status
+  `disabled`      boolean      NOT NULL DEFAULT 0,
+  `expired`       boolean      NOT NULL DEFAULT 0,
+
+  -- Time-based access restriction
+  `access_window_start`    TIME,
+  `access_window_end`      TIME,
+
+  -- Date-based access restriction
+  `valid_from`  DATE,
+  `valid_until` DATE,
+
+  -- Timezone used for all date/time comparisons and interpretation
+  `timezone` VARCHAR(64),
+
+  PRIMARY KEY (`user_id`),
+  UNIQUE KEY `username` (`username`)
+
+) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+
+--
+-- Table of connection parameters. Each parameter is simply a name/value pair
+-- associated with a connection.
+--
+
+CREATE TABLE `guacamole_connection_parameter` (
+
+  `connection_id`   int(11)       NOT NULL,
+  `parameter_name`  varchar(128)  NOT NULL,
+  `parameter_value` varchar(4096) NOT NULL,
+
+  PRIMARY KEY (`connection_id`,`parameter_name`),
+
+  CONSTRAINT `guacamole_connection_parameter_ibfk_1`
+    FOREIGN KEY (`connection_id`)
+    REFERENCES `guacamole_connection` (`connection_id`) ON DELETE CASCADE
+
+) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+
+--
+-- Table of connection permissions. Each connection permission grants a user
+-- specific access to a connection.
+--
+
+CREATE TABLE `guacamole_connection_permission` (
+
+  `user_id`       int(11) NOT NULL,
+  `connection_id` int(11) NOT NULL,
+  `permission`    enum('READ',
+                       'UPDATE',
+                       'DELETE',
+                       'ADMINISTER') NOT NULL,
+
+  PRIMARY KEY (`user_id`,`connection_id`,`permission`),
+
+  CONSTRAINT `guacamole_connection_permission_ibfk_1`
+    FOREIGN KEY (`connection_id`)
+    REFERENCES `guacamole_connection` (`connection_id`) ON DELETE CASCADE,
+
+  CONSTRAINT `guacamole_connection_permission_ibfk_2`
+    FOREIGN KEY (`user_id`)
+    REFERENCES `guacamole_user` (`user_id`) ON DELETE CASCADE
+
+) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+
+--
+-- Table of connection group permissions. Each group permission grants a user
+-- specific access to a connection group.
+--
+
+CREATE TABLE `guacamole_connection_group_permission` (
+
+  `user_id`             int(11) NOT NULL,
+  `connection_group_id` int(11) NOT NULL,
+  `permission`          enum('READ',
+                             'UPDATE',
+                             'DELETE',
+                             'ADMINISTER') NOT NULL,
+
+  PRIMARY KEY (`user_id`,`connection_group_id`,`permission`),
+
+  CONSTRAINT `guacamole_connection_group_permission_ibfk_1`
+    FOREIGN KEY (`connection_group_id`)
+    REFERENCES `guacamole_connection_group` (`connection_group_id`) ON DELETE CASCADE,
+
+  CONSTRAINT `guacamole_connection_group_permission_ibfk_2`
+    FOREIGN KEY (`user_id`)
+    REFERENCES `guacamole_user` (`user_id`) ON DELETE CASCADE
+
+) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+
+--
+-- Table of system permissions. Each system permission grants a user a
+-- system-level privilege of some kind.
+--
+
+CREATE TABLE `guacamole_system_permission` (
+
+  `user_id`    int(11) NOT NULL,
+  `permission` enum('CREATE_CONNECTION',
+		    'CREATE_CONNECTION_GROUP',
+                    'CREATE_USER',
+                    'ADMINISTER') NOT NULL,
+
+  PRIMARY KEY (`user_id`,`permission`),
+
+  CONSTRAINT `guacamole_system_permission_ibfk_1`
+    FOREIGN KEY (`user_id`)
+    REFERENCES `guacamole_user` (`user_id`) ON DELETE CASCADE
+
+) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+
+--
+-- Table of user permissions. Each user permission grants a user access to
+-- another user (the "affected" user) for a specific type of operation.
+--
+
+CREATE TABLE `guacamole_user_permission` (
+
+  `user_id`          int(11) NOT NULL,
+  `affected_user_id` int(11) NOT NULL,
+  `permission`       enum('READ',
+                          'UPDATE',
+                          'DELETE',
+                          'ADMINISTER') NOT NULL,
+
+  PRIMARY KEY (`user_id`,`affected_user_id`,`permission`),
+
+  CONSTRAINT `guacamole_user_permission_ibfk_1`
+    FOREIGN KEY (`affected_user_id`)
+    REFERENCES `guacamole_user` (`user_id`) ON DELETE CASCADE,
+
+  CONSTRAINT `guacamole_user_permission_ibfk_2`
+    FOREIGN KEY (`user_id`)
+    REFERENCES `guacamole_user` (`user_id`) ON DELETE CASCADE
+
+) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+
+--
+-- Table of connection history records. Each record defines a specific user's
+-- session, including the connection used, the start time, and the end time
+-- (if any).
+--
+
+CREATE TABLE `guacamole_connection_history` (
+
+  `history_id`    int(11)  NOT NULL AUTO_INCREMENT,
+  `user_id`       int(11)  NOT NULL,
+  `connection_id` int(11)  NOT NULL,
+  `start_date`    datetime NOT NULL,
+  `end_date`      datetime DEFAULT NULL,
+
+  PRIMARY KEY (`history_id`),
+  KEY `user_id` (`user_id`),
+  KEY `connection_id` (`connection_id`),
+  KEY `start_date` (`start_date`),
+  KEY `end_date` (`end_date`),
+
+  CONSTRAINT `guacamole_connection_history_ibfk_1`
+    FOREIGN KEY (`user_id`)
+    REFERENCES `guacamole_user` (`user_id`) ON DELETE CASCADE,
+
+  CONSTRAINT `guacamole_connection_history_ibfk_2`
+    FOREIGN KEY (`connection_id`)
+    REFERENCES `guacamole_connection` (`connection_id`) ON DELETE CASCADE
+
+) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/schema/002-create-admin-user.sql b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/schema/002-create-admin-user.sql
new file mode 100644
index 0000000..2a2530b
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/schema/002-create-admin-user.sql
@@ -0,0 +1,50 @@
+--
+-- Copyright (C) 2015 Glyptodon LLC
+--
+-- Permission is hereby granted, free of charge, to any person obtaining a copy
+-- of this software and associated documentation files (the "Software"), to deal
+-- in the Software without restriction, including without limitation the rights
+-- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+-- copies of the Software, and to permit persons to whom the Software is
+-- furnished to do so, subject to the following conditions:
+--
+-- The above copyright notice and this permission notice shall be included in
+-- all copies or substantial portions of the Software.
+--
+-- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+-- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+-- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+-- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+-- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+-- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+-- THE SOFTWARE.
+--
+
+-- Create default user "guacadmin" with password "guacadmin"
+INSERT INTO guacamole_user (username, password_hash, password_salt)
+VALUES ('guacadmin',
+    x'CA458A7D494E3BE824F5E1E175A1556C0F8EEF2C2D7DF3633BEC4A29C4411960',  -- 'guacadmin'
+    x'FE24ADC5E11E2B25288D1704ABE67A79E342ECC26064CE69C5B3177795A82264');
+
+-- Grant this user all system permissions
+INSERT INTO guacamole_system_permission
+SELECT user_id, permission
+FROM (
+          SELECT 'guacadmin'  AS username, 'CREATE_CONNECTION'       AS permission
+    UNION SELECT 'guacadmin'  AS username, 'CREATE_CONNECTION_GROUP' AS permission
+    UNION SELECT 'guacadmin'  AS username, 'CREATE_USER'             AS permission
+    UNION SELECT 'guacadmin'  AS username, 'ADMINISTER'              AS permission
+) permissions
+JOIN guacamole_user ON permissions.username = guacamole_user.username;
+
+-- Grant admin permission to read/update/administer self
+INSERT INTO guacamole_user_permission
+SELECT guacamole_user.user_id, affected.user_id, permission
+FROM (
+          SELECT 'guacadmin' AS username, 'guacadmin' AS affected_username, 'READ'       AS permission
+    UNION SELECT 'guacadmin' AS username, 'guacadmin' AS affected_username, 'UPDATE'     AS permission
+    UNION SELECT 'guacadmin' AS username, 'guacadmin' AS affected_username, 'ADMINISTER' AS permission
+) permissions
+JOIN guacamole_user          ON permissions.username = guacamole_user.username
+JOIN guacamole_user affected ON permissions.affected_username = affected.username;
+
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/schema/upgrade/upgrade-pre-0.8.2.sql b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/schema/upgrade/upgrade-pre-0.8.2.sql
new file mode 100644
index 0000000..b9db2ad
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/schema/upgrade/upgrade-pre-0.8.2.sql
@@ -0,0 +1,89 @@
+--
+-- Copyright (C) 2013 Glyptodon LLC
+--
+-- Permission is hereby granted, free of charge, to any person obtaining a copy
+-- of this software and associated documentation files (the "Software"), to deal
+-- in the Software without restriction, including without limitation the rights
+-- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+-- copies of the Software, and to permit persons to whom the Software is
+-- furnished to do so, subject to the following conditions:
+--
+-- The above copyright notice and this permission notice shall be included in
+-- all copies or substantial portions of the Software.
+--
+-- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+-- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+-- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+-- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+-- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+-- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+-- THE SOFTWARE.
+--
+
+--
+-- Table of connection groups. Each connection group has a name.
+--
+
+CREATE TABLE `guacamole_connection_group` (
+
+  `connection_group_id`   int(11)      NOT NULL AUTO_INCREMENT,
+  `parent_id`             int(11),
+  `connection_group_name` varchar(128) NOT NULL,
+  `type`                  enum('ORGANIZATIONAL',
+                               'BALANCING') NOT NULL DEFAULT 'ORGANIZATIONAL',
+
+
+  PRIMARY KEY (`connection_group_id`),
+  UNIQUE KEY `connection_group_name_parent` (`connection_group_name`, `parent_id`),
+
+  CONSTRAINT `guacamole_connection_group_ibfk_1`
+    FOREIGN KEY (`parent_id`)
+    REFERENCES `guacamole_connection_group` (`connection_group_id`) ON DELETE CASCADE
+
+) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+
+
+--
+-- Changes to connection table to support grouping.
+--
+
+ALTER TABLE `guacamole_connection` ADD COLUMN `parent_id` int(11) AFTER `connection_name`;
+
+ALTER TABLE `guacamole_connection` DROP INDEX `connection_name`;
+ALTER TABLE `guacamole_connection` ADD UNIQUE KEY `connection_name_parent` (`connection_name`, `parent_id`);
+
+ALTER TABLE `guacamole_connection` ADD CONSTRAINT `guacamole_connection_ibfk_1`
+    FOREIGN KEY (`parent_id`)
+    REFERENCES `guacamole_connection_group` (`connection_group_id`) ON DELETE CASCADE;
+
+--
+-- Table of connection group permissions. Each group permission grants a user
+-- specific access to a connection group.
+--
+
+CREATE TABLE `guacamole_connection_group_permission` (
+
+  `user_id`             int(11) NOT NULL,
+  `connection_group_id` int(11) NOT NULL,
+  `permission`          enum('READ',
+                             'UPDATE',
+                             'DELETE',
+                             'ADMINISTER') NOT NULL,
+
+  PRIMARY KEY (`user_id`,`connection_group_id`,`permission`),
+
+  CONSTRAINT `guacamole_connection_group_permission_ibfk_1`
+    FOREIGN KEY (`connection_group_id`)
+    REFERENCES `guacamole_connection_group` (`connection_group_id`) ON DELETE CASCADE,
+
+  CONSTRAINT `guacamole_connection_group_permission_ibfk_2`
+    FOREIGN KEY (`user_id`)
+    REFERENCES `guacamole_user` (`user_id`) ON DELETE CASCADE
+
+) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+
+ALTER TABLE `guacamole_system_permission` MODIFY `permission` 
+    enum('CREATE_CONNECTION',
+         'CREATE_CONNECTION_GROUP',
+         'CREATE_USER',
+         'ADMINISTER') NOT NULL;
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/schema/upgrade/upgrade-pre-0.9.6.sql b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/schema/upgrade/upgrade-pre-0.9.6.sql
new file mode 100644
index 0000000..a963670
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/schema/upgrade/upgrade-pre-0.9.6.sql
@@ -0,0 +1,39 @@
+--
+-- Copyright (C) 2015 Glyptodon LLC
+--
+-- Permission is hereby granted, free of charge, to any person obtaining a copy
+-- of this software and associated documentation files (the "Software"), to deal
+-- in the Software without restriction, including without limitation the rights
+-- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+-- copies of the Software, and to permit persons to whom the Software is
+-- furnished to do so, subject to the following conditions:
+--
+-- The above copyright notice and this permission notice shall be included in
+-- all copies or substantial portions of the Software.
+--
+-- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+-- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+-- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+-- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+-- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+-- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+-- THE SOFTWARE.
+--
+
+--
+-- Explicitly add permission for each user to READ him/herself
+--
+
+INSERT INTO guacamole_user_permission
+      (user_id, affected_user_id, permission)
+SELECT user_id, user_id,          'READ'
+FROM guacamole_user
+WHERE
+    user_id NOT IN (
+        SELECT user_id
+        FROM guacamole_user_permission
+        WHERE
+            user_id = affected_user_id
+            AND permission = 'READ'
+    );
+
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/schema/upgrade/upgrade-pre-0.9.7.sql b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/schema/upgrade/upgrade-pre-0.9.7.sql
new file mode 100644
index 0000000..761d9be
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/schema/upgrade/upgrade-pre-0.9.7.sql
@@ -0,0 +1,34 @@
+--
+-- Copyright (C) 2015 Glyptodon LLC
+--
+-- Permission is hereby granted, free of charge, to any person obtaining a copy
+-- of this software and associated documentation files (the "Software"), to deal
+-- in the Software without restriction, including without limitation the rights
+-- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+-- copies of the Software, and to permit persons to whom the Software is
+-- furnished to do so, subject to the following conditions:
+--
+-- The above copyright notice and this permission notice shall be included in
+-- all copies or substantial portions of the Software.
+--
+-- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+-- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+-- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+-- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+-- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+-- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+-- THE SOFTWARE.
+--
+
+--
+-- Add per-user disable flag
+--
+
+ALTER TABLE guacamole_user ADD COLUMN disabled BOOLEAN NOT NULL DEFAULT 0;
+
+--
+-- Add per-user password expiration flag
+--
+
+ALTER TABLE guacamole_user ADD COLUMN expired BOOLEAN NOT NULL DEFAULT 0;
+
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/schema/upgrade/upgrade-pre-0.9.8.sql b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/schema/upgrade/upgrade-pre-0.9.8.sql
new file mode 100644
index 0000000..1f393f5
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/schema/upgrade/upgrade-pre-0.9.8.sql
@@ -0,0 +1,55 @@
+--
+-- Copyright (C) 2015 Glyptodon LLC
+--
+-- Permission is hereby granted, free of charge, to any person obtaining a copy
+-- of this software and associated documentation files (the "Software"), to deal
+-- in the Software without restriction, including without limitation the rights
+-- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+-- copies of the Software, and to permit persons to whom the Software is
+-- furnished to do so, subject to the following conditions:
+--
+-- The above copyright notice and this permission notice shall be included in
+-- all copies or substantial portions of the Software.
+--
+-- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+-- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+-- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+-- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+-- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+-- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+-- THE SOFTWARE.
+--
+
+--
+-- Add per-user time-based access restrictions.
+--
+
+ALTER TABLE guacamole_user ADD COLUMN access_window_start    TIME;
+ALTER TABLE guacamole_user ADD COLUMN access_window_end      TIME;
+
+--
+-- Add per-user date-based account validity restrictions.
+--
+
+ALTER TABLE guacamole_user ADD COLUMN valid_from  DATE;
+ALTER TABLE guacamole_user ADD COLUMN valid_until DATE;
+
+--
+-- Add per-user timezone for sake of time comparisons/interpretation.
+--
+
+ALTER TABLE guacamole_user ADD COLUMN timezone VARCHAR(64);
+
+--
+-- Add connection concurrency limits
+--
+
+ALTER TABLE guacamole_connection ADD COLUMN max_connections          INT(11);
+ALTER TABLE guacamole_connection ADD COLUMN max_connections_per_user INT(11);
+
+--
+-- Add connection group concurrency limits
+--
+
+ALTER TABLE guacamole_connection_group ADD COLUMN max_connections          INT(11);
+ALTER TABLE guacamole_connection_group ADD COLUMN max_connections_per_user INT(11);
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/schema/upgrade/upgrade-pre-0.9.9.sql b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/schema/upgrade/upgrade-pre-0.9.9.sql
new file mode 100644
index 0000000..d26684b
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/schema/upgrade/upgrade-pre-0.9.9.sql
@@ -0,0 +1,29 @@
+--
+-- Copyright (C) 2015 Glyptodon LLC
+--
+-- Permission is hereby granted, free of charge, to any person obtaining a copy
+-- of this software and associated documentation files (the "Software"), to deal
+-- in the Software without restriction, including without limitation the rights
+-- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+-- copies of the Software, and to permit persons to whom the Software is
+-- furnished to do so, subject to the following conditions:
+--
+-- The above copyright notice and this permission notice shall be included in
+-- all copies or substantial portions of the Software.
+--
+-- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+-- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+-- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+-- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+-- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+-- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+-- THE SOFTWARE.
+--
+
+--
+-- Ensure history entry start/end dates are indexed.
+--
+
+ALTER TABLE guacamole_connection_history ADD KEY (start_date);
+ALTER TABLE guacamole_connection_history ADD KEY (end_date);
+ALTER TABLE guacamole_connection_history ADD KEY search_index (start_date, connection_id, user_id);
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/MySQLAuthenticationProvider.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/MySQLAuthenticationProvider.java
new file mode 100644
index 0000000..303401b
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/MySQLAuthenticationProvider.java
@@ -0,0 +1,120 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package net.sourceforge.guacamole.net.auth.mysql;
+
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.net.auth.AuthenticationProvider;
+import org.glyptodon.guacamole.net.auth.Credentials;
+import org.glyptodon.guacamole.net.auth.UserContext;
+import org.glyptodon.guacamole.auth.jdbc.JDBCAuthenticationProviderModule;
+import org.glyptodon.guacamole.auth.jdbc.user.AuthenticationProviderService;
+import org.glyptodon.guacamole.net.auth.AuthenticatedUser;
+
+/**
+ * Provides a MySQL based implementation of the AuthenticationProvider
+ * functionality.
+ *
+ * @author James Muehlner
+ * @author Michael Jumper
+ */
+public class MySQLAuthenticationProvider implements AuthenticationProvider {
+
+    /**
+     * Injector which will manage the object graph of this authentication
+     * provider.
+     */
+    private final Injector injector;
+
+    /**
+     * Creates a new MySQLAuthenticationProvider that reads and writes
+     * authentication data to a MySQL database defined by properties in
+     * guacamole.properties.
+     *
+     * @throws GuacamoleException
+     *     If a required property is missing, or an error occurs while parsing
+     *     a property.
+     */
+    public MySQLAuthenticationProvider() throws GuacamoleException {
+
+        // Get local environment
+        MySQLEnvironment environment = new MySQLEnvironment();
+
+        // Set up Guice injector.
+        injector = Guice.createInjector(
+
+            // Configure MySQL-specific authentication
+            new MySQLAuthenticationProviderModule(environment),
+
+            // Configure JDBC authentication core
+            new JDBCAuthenticationProviderModule(this, environment)
+
+        );
+
+    }
+
+    @Override
+    public String getIdentifier() {
+        return "mysql";
+    }
+
+    @Override
+    public AuthenticatedUser authenticateUser(Credentials credentials)
+            throws GuacamoleException {
+
+        // Create AuthenticatedUser based on credentials, if valid
+        AuthenticationProviderService authProviderService = injector.getInstance(AuthenticationProviderService.class);
+        return authProviderService.authenticateUser(this, credentials);
+
+    }
+
+    @Override
+    public AuthenticatedUser updateAuthenticatedUser(AuthenticatedUser authenticatedUser,
+            Credentials credentials) throws GuacamoleException {
+
+        // No need to update authenticated users
+        return authenticatedUser;
+
+    }
+
+    @Override
+    public UserContext getUserContext(AuthenticatedUser authenticatedUser)
+            throws GuacamoleException {
+
+        // Create UserContext based on credentials, if valid
+        AuthenticationProviderService authProviderService = injector.getInstance(AuthenticationProviderService.class);
+        return authProviderService.getUserContext(authenticatedUser);
+
+    }
+
+    @Override
+    public UserContext updateUserContext(UserContext context,
+            AuthenticatedUser authenticatedUser) throws GuacamoleException {
+
+        // No need to update the context
+        return context;
+
+    }
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/MySQLAuthenticationProviderModule.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/MySQLAuthenticationProviderModule.java
new file mode 100644
index 0000000..a11c525
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/MySQLAuthenticationProviderModule.java
@@ -0,0 +1,97 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package net.sourceforge.guacamole.net.auth.mysql;
+
+import com.google.inject.Binder;
+import com.google.inject.Module;
+import com.google.inject.name.Names;
+import java.util.Properties;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.mybatis.guice.datasource.helper.JdbcHelper;
+
+/**
+ * Guice module which configures MySQL-specific injections.
+ *
+ * @author James Muehlner
+ */
+public class MySQLAuthenticationProviderModule implements Module {
+
+    /**
+     * MyBatis-specific configuration properties.
+     */
+    private final Properties myBatisProperties = new Properties();
+
+    /**
+     * MySQL-specific driver configuration properties.
+     */
+    private final Properties driverProperties = new Properties();
+    
+    /**
+     * Creates a new MySQL authentication provider module that configures
+     * driver and MyBatis properties using the given environment.
+     *
+     * @param environment
+     *     The environment to use when configuring MyBatis and the underlying
+     *     JDBC driver.
+     *
+     * @throws GuacamoleException
+     *     If a required property is missing, or an error occurs while parsing
+     *     a property.
+     */
+    public MySQLAuthenticationProviderModule(MySQLEnvironment environment)
+            throws GuacamoleException {
+
+        // Set the MySQL-specific properties for MyBatis.
+        myBatisProperties.setProperty("mybatis.environment.id", "guacamole");
+        myBatisProperties.setProperty("JDBC.host", environment.getMySQLHostname());
+        myBatisProperties.setProperty("JDBC.port", String.valueOf(environment.getMySQLPort()));
+        myBatisProperties.setProperty("JDBC.schema", environment.getMySQLDatabase());
+        myBatisProperties.setProperty("JDBC.username", environment.getMySQLUsername());
+        myBatisProperties.setProperty("JDBC.password", environment.getMySQLPassword());
+        myBatisProperties.setProperty("JDBC.autoCommit", "false");
+        myBatisProperties.setProperty("mybatis.pooled.pingEnabled", "true");
+        myBatisProperties.setProperty("mybatis.pooled.pingQuery", "SELECT 1");
+
+        // Use UTF-8 in database
+        driverProperties.setProperty("characterEncoding","UTF-8");
+
+
+    }
+
+    @Override
+    public void configure(Binder binder) {
+
+        // Bind MySQL-specific properties
+        JdbcHelper.MySQL.configure(binder);
+        
+        // Bind MyBatis properties
+        Names.bindProperties(binder, myBatisProperties);
+
+        // Bing JDBC driver properties
+        binder.bind(Properties.class)
+            .annotatedWith(Names.named("JDBC.driverProperties"))
+            .toInstance(driverProperties);
+
+    }
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/MySQLEnvironment.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/MySQLEnvironment.java
new file mode 100644
index 0000000..90a42af
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/MySQLEnvironment.java
@@ -0,0 +1,276 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package net.sourceforge.guacamole.net.auth.mysql;
+
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.auth.jdbc.JDBCEnvironment;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * A MySQL-specific implementation of JDBCEnvironment provides database
+ * properties specifically for MySQL.
+ *
+ * @author James Muehlner
+ * @author Michael Jumper
+ */
+public class MySQLEnvironment extends JDBCEnvironment {
+
+    /**
+     * Logger for this class.
+     */
+    private static final Logger logger = LoggerFactory.getLogger(MySQLEnvironment.class);
+    
+    /**
+     * The default host to connect to, if MYSQL_HOSTNAME is not specified.
+     */
+    private static final String DEFAULT_HOSTNAME = "localhost";
+
+    /**
+     * The default port to connect to, if MYSQL_PORT is not specified.
+     */
+    private static final int DEFAULT_PORT = 3306;
+
+    /**
+     * The default value for the default maximum number of connections to be
+     * allowed per user to any one connection. Note that, as long as the
+     * legacy "disallow duplicate" and "disallow simultaneous" properties are
+     * still supported, these cannot be constants, as the legacy properties
+     * dictate the values that should be used in the absence of the correct
+     * properties.
+     */
+    private int DEFAULT_MAX_CONNECTIONS_PER_USER = 1;
+
+    /**
+     * The default value for the default maximum number of connections to be
+     * allowed per user to any one connection group. Note that, as long as the
+     * legacy "disallow duplicate" and "disallow simultaneous" properties are
+     * still supported, these cannot be constants, as the legacy properties
+     * dictate the values that should be used in the absence of the correct
+     * properties.
+     */
+    private int DEFAULT_MAX_GROUP_CONNECTIONS_PER_USER = 1;
+
+    /**
+     * The default value for the default maximum number of connections to be
+     * allowed to any one connection. Note that, as long as the legacy
+     * "disallow duplicate" and "disallow simultaneous" properties are still
+     * supported, these cannot be constants, as the legacy properties dictate
+     * the values that should be used in the absence of the correct properties.
+     */
+    private int DEFAULT_MAX_CONNECTIONS = 0;
+
+    /**
+     * The default value for the default maximum number of connections to be
+     * allowed to any one connection group. Note that, as long as the legacy
+     * "disallow duplicate" and "disallow simultaneous" properties are still
+     * supported, these cannot be constants, as the legacy properties dictate
+     * the values that should be used in the absence of the correct properties.
+     */
+    private int DEFAULT_MAX_GROUP_CONNECTIONS = 0;
+
+    /**
+     * Constructs a new MySQLEnvironment, providing access to MySQL-specific
+     * configuration options.
+     * 
+     * @throws GuacamoleException 
+     *     If an error occurs while setting up the underlying JDBCEnvironment
+     *     or while parsing legacy MySQL configuration options.
+     */
+    public MySQLEnvironment() throws GuacamoleException {
+
+        // Init underlying JDBC environment
+        super();
+
+        // Read legacy concurrency-related property
+        Boolean disallowSimultaneous = getProperty(MySQLGuacamoleProperties.MYSQL_DISALLOW_SIMULTANEOUS_CONNECTIONS);
+        Boolean disallowDuplicate    = getProperty(MySQLGuacamoleProperties.MYSQL_DISALLOW_DUPLICATE_CONNECTIONS);
+
+        // Legacy "simultaneous" property dictates only the maximum number of
+        // connections per connection
+        if (disallowSimultaneous != null) {
+
+            // Translate legacy property
+            if (disallowSimultaneous) {
+                DEFAULT_MAX_CONNECTIONS       = 1;
+                DEFAULT_MAX_GROUP_CONNECTIONS = 0;
+            }
+            else {
+                DEFAULT_MAX_CONNECTIONS       = 0;
+                DEFAULT_MAX_GROUP_CONNECTIONS = 0;
+            }
+
+            // Warn of deprecation
+            logger.warn("The \"{}\" property is deprecated. Use \"{}\" and \"{}\" instead.",
+                    MySQLGuacamoleProperties.MYSQL_DISALLOW_SIMULTANEOUS_CONNECTIONS.getName(),
+                    MySQLGuacamoleProperties.MYSQL_DEFAULT_MAX_CONNECTIONS.getName(),
+                    MySQLGuacamoleProperties.MYSQL_DEFAULT_MAX_GROUP_CONNECTIONS.getName());
+
+            // Inform of new equivalent
+            logger.info("To achieve the same result of setting \"{}\" to \"{}\", set \"{}\" to \"{}\" and \"{}\" to \"{}\".",
+                    MySQLGuacamoleProperties.MYSQL_DISALLOW_SIMULTANEOUS_CONNECTIONS.getName(), disallowSimultaneous,
+                    MySQLGuacamoleProperties.MYSQL_DEFAULT_MAX_CONNECTIONS.getName(),           DEFAULT_MAX_CONNECTIONS,
+                    MySQLGuacamoleProperties.MYSQL_DEFAULT_MAX_GROUP_CONNECTIONS.getName(),     DEFAULT_MAX_GROUP_CONNECTIONS);
+
+        }
+
+        // Legacy "duplicate" property dictates whether connections and groups
+        // may be used concurrently only by different users
+        if (disallowDuplicate != null) {
+
+            // Translate legacy property
+            if (disallowDuplicate) {
+                DEFAULT_MAX_CONNECTIONS_PER_USER       = 1;
+                DEFAULT_MAX_GROUP_CONNECTIONS_PER_USER = 1;
+            }
+            else {
+                DEFAULT_MAX_CONNECTIONS_PER_USER       = 0;
+                DEFAULT_MAX_GROUP_CONNECTIONS_PER_USER = 0;
+            }
+
+            // Warn of deprecation
+            logger.warn("The \"{}\" property is deprecated. Use \"{}\" and \"{}\" instead.",
+                    MySQLGuacamoleProperties.MYSQL_DISALLOW_DUPLICATE_CONNECTIONS.getName(),
+                    MySQLGuacamoleProperties.MYSQL_DEFAULT_MAX_CONNECTIONS_PER_USER.getName(),
+                    MySQLGuacamoleProperties.MYSQL_DEFAULT_MAX_GROUP_CONNECTIONS.getName());
+
+            // Inform of new equivalent
+            logger.info("To achieve the same result of setting \"{}\" to \"{}\", set \"{}\" to \"{}\" and \"{}\" to \"{}\".",
+                    MySQLGuacamoleProperties.MYSQL_DISALLOW_DUPLICATE_CONNECTIONS.getName(),         disallowDuplicate,
+                    MySQLGuacamoleProperties.MYSQL_DEFAULT_MAX_CONNECTIONS_PER_USER.getName(),       DEFAULT_MAX_CONNECTIONS_PER_USER,
+                    MySQLGuacamoleProperties.MYSQL_DEFAULT_MAX_GROUP_CONNECTIONS_PER_USER.getName(), DEFAULT_MAX_GROUP_CONNECTIONS_PER_USER);
+
+        }
+
+    }
+
+    @Override
+    public int getDefaultMaxConnections() throws GuacamoleException {
+        return getProperty(
+            MySQLGuacamoleProperties.MYSQL_DEFAULT_MAX_CONNECTIONS,
+            DEFAULT_MAX_CONNECTIONS
+        );
+    }
+
+    @Override
+    public int getDefaultMaxGroupConnections() throws GuacamoleException {
+        return getProperty(
+            MySQLGuacamoleProperties.MYSQL_DEFAULT_MAX_GROUP_CONNECTIONS,
+            DEFAULT_MAX_GROUP_CONNECTIONS
+        );
+    }
+
+    @Override
+    public int getDefaultMaxConnectionsPerUser() throws GuacamoleException {
+        return getProperty(
+            MySQLGuacamoleProperties.MYSQL_DEFAULT_MAX_CONNECTIONS_PER_USER,
+            DEFAULT_MAX_CONNECTIONS_PER_USER
+        );
+    }
+
+    @Override
+    public int getDefaultMaxGroupConnectionsPerUser() throws GuacamoleException {
+        return getProperty(
+            MySQLGuacamoleProperties.MYSQL_DEFAULT_MAX_GROUP_CONNECTIONS_PER_USER,
+            DEFAULT_MAX_GROUP_CONNECTIONS_PER_USER
+        );
+    }
+
+    /**
+     * Returns the hostname of the MySQL server hosting the Guacamole
+     * authentication tables. If unspecified, this will be "localhost".
+     * 
+     * @return
+     *     The URL of the MySQL server.
+     *
+     * @throws GuacamoleException 
+     *     If an error occurs while retrieving the property value.
+     */
+    public String getMySQLHostname() throws GuacamoleException {
+        return getProperty(
+            MySQLGuacamoleProperties.MYSQL_HOSTNAME,
+            DEFAULT_HOSTNAME
+        );
+    }
+    
+    /**
+     * Returns the port number of the MySQL server hosting the Guacamole
+     * authentication tables. If unspecified, this will be the default MySQL
+     * port of 3306.
+     * 
+     * @return
+     *     The port number of the MySQL server.
+     *
+     * @throws GuacamoleException 
+     *     If an error occurs while retrieving the property value.
+     */
+    public int getMySQLPort() throws GuacamoleException {
+        return getProperty(MySQLGuacamoleProperties.MYSQL_PORT, DEFAULT_PORT);
+    }
+    
+    /**
+     * Returns the name of the MySQL database containing the Guacamole 
+     * authentication tables.
+     * 
+     * @return
+     *     The name of the MySQL database.
+     *
+     * @throws GuacamoleException 
+     *     If an error occurs while retrieving the property value, or if the
+     *     value was not set, as this property is required.
+     */
+    public String getMySQLDatabase() throws GuacamoleException {
+        return getRequiredProperty(MySQLGuacamoleProperties.MYSQL_DATABASE);
+    }
+    
+    /**
+     * Returns the username that should be used when authenticating with the
+     * MySQL database containing the Guacamole authentication tables.
+     * 
+     * @return
+     *     The username for the MySQL database.
+     *
+     * @throws GuacamoleException 
+     *     If an error occurs while retrieving the property value, or if the
+     *     value was not set, as this property is required.
+     */
+    public String getMySQLUsername() throws GuacamoleException {
+        return getRequiredProperty(MySQLGuacamoleProperties.MYSQL_USERNAME);
+    }
+    
+    /**
+     * Returns the password that should be used when authenticating with the
+     * MySQL database containing the Guacamole authentication tables.
+     * 
+     * @return
+     *     The password for the MySQL database.
+     *
+     * @throws GuacamoleException 
+     *     If an error occurs while retrieving the property value, or if the
+     *     value was not set, as this property is required.
+     */
+    public String getMySQLPassword() throws GuacamoleException {
+        return getRequiredProperty(MySQLGuacamoleProperties.MYSQL_PASSWORD);
+    }
+    
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/MySQLGuacamoleProperties.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/MySQLGuacamoleProperties.java
new file mode 100644
index 0000000..cb0c35b
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/MySQLGuacamoleProperties.java
@@ -0,0 +1,171 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package net.sourceforge.guacamole.net.auth.mysql;
+
+import org.glyptodon.guacamole.properties.BooleanGuacamoleProperty;
+import org.glyptodon.guacamole.properties.IntegerGuacamoleProperty;
+import org.glyptodon.guacamole.properties.StringGuacamoleProperty;
+
+/**
+ * Properties used by the MySQL Authentication plugin.
+ *
+ * @author James Muehlner
+ */
+public class MySQLGuacamoleProperties {
+
+    /**
+     * This class should not be instantiated.
+     */
+    private MySQLGuacamoleProperties() {}
+
+    /**
+     * The hostname of the MySQL server hosting the Guacamole authentication 
+     * tables.
+     */
+    public static final StringGuacamoleProperty MYSQL_HOSTNAME = new StringGuacamoleProperty() {
+
+        @Override
+        public String getName() { return "mysql-hostname"; }
+
+    };
+
+    /**
+     * The port number of the MySQL server hosting the Guacamole authentication 
+     * tables.
+     */
+    public static final IntegerGuacamoleProperty MYSQL_PORT = new IntegerGuacamoleProperty() {
+
+        @Override
+        public String getName() { return "mysql-port"; }
+
+    };
+
+    /**
+     * The name of the MySQL database containing the Guacamole authentication 
+     * tables.
+     */
+    public static final StringGuacamoleProperty MYSQL_DATABASE = new StringGuacamoleProperty() {
+
+        @Override
+        public String getName() { return "mysql-database"; }
+
+    };
+
+    /**
+     * The username that should be used when authenticating with the MySQL
+     * database containing the Guacamole authentication tables.
+     */
+    public static final StringGuacamoleProperty MYSQL_USERNAME = new StringGuacamoleProperty() {
+
+        @Override
+        public String getName() { return "mysql-username"; }
+
+    };
+
+    /**
+     * The password that should be used when authenticating with the MySQL
+     * database containing the Guacamole authentication tables.
+     */
+    public static final StringGuacamoleProperty MYSQL_PASSWORD = new StringGuacamoleProperty() {
+
+        @Override
+        public String getName() { return "mysql-password"; }
+
+    };
+
+    /**
+     * Whether or not multiple users accessing the same connection at the same 
+     * time should be disallowed.
+     */
+    public static final BooleanGuacamoleProperty MYSQL_DISALLOW_SIMULTANEOUS_CONNECTIONS = new BooleanGuacamoleProperty() {
+
+        @Override
+        public String getName() { return "mysql-disallow-simultaneous-connections"; }
+
+    };
+
+    /**
+     * Whether or not the same user accessing the same connection or connection 
+     * group at the same time should be disallowed.
+     */
+    public static final BooleanGuacamoleProperty MYSQL_DISALLOW_DUPLICATE_CONNECTIONS = new BooleanGuacamoleProperty() {
+
+        @Override
+        public String getName() { return "mysql-disallow-duplicate-connections"; }
+
+    };
+
+    /**
+     * The maximum number of concurrent connections to allow to any one
+     * connection. Zero denotes unlimited.
+     */
+    public static final IntegerGuacamoleProperty
+            MYSQL_DEFAULT_MAX_CONNECTIONS =
+            new IntegerGuacamoleProperty() {
+
+        @Override
+        public String getName() { return "mysql-default-max-connections"; }
+
+    };
+
+    /**
+     * The maximum number of concurrent connections to allow to any one
+     * connection group. Zero denotes unlimited.
+     */
+    public static final IntegerGuacamoleProperty
+            MYSQL_DEFAULT_MAX_GROUP_CONNECTIONS =
+            new IntegerGuacamoleProperty() {
+
+        @Override
+        public String getName() { return "mysql-default-max-group-connections"; }
+
+    };
+
+    /**
+     * The maximum number of concurrent connections to allow to any one
+     * connection by an individual user. Zero denotes unlimited.
+     */
+    public static final IntegerGuacamoleProperty
+            MYSQL_DEFAULT_MAX_CONNECTIONS_PER_USER =
+            new IntegerGuacamoleProperty() {
+
+        @Override
+        public String getName() { return "mysql-default-max-connections-per-user"; }
+
+    };
+
+    /**
+     * The maximum number of concurrent connections to allow to any one
+     * connection group by an individual user. Zero denotes
+     * unlimited.
+     */
+    public static final IntegerGuacamoleProperty
+            MYSQL_DEFAULT_MAX_GROUP_CONNECTIONS_PER_USER =
+            new IntegerGuacamoleProperty() {
+
+        @Override
+        public String getName() { return "mysql-default-max-group-connections-per-user"; }
+
+    };
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/package-info.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/package-info.java
new file mode 100644
index 0000000..65dc294
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/package-info.java
@@ -0,0 +1,27 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * The MySQL authentication provider. This package exists outside of
+ * org.glyptodon for backwards-compatibility.
+ */
+package net.sourceforge.guacamole.net.auth.mysql;
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/resources/guac-manifest.json b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/resources/guac-manifest.json
new file mode 100644
index 0000000..d219fb6
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/resources/guac-manifest.json
@@ -0,0 +1,19 @@
+{
+
+    "guacamoleVersion" : "0.9.9",
+
+    "name"      : "MySQL Authentication",
+    "namespace" : "guac-mysql",
+
+    "authProviders" : [
+        "net.sourceforge.guacamole.net.auth.mysql.MySQLAuthenticationProvider"
+    ],
+
+    "translations" : [
+        "translations/en.json",
+        "translations/fr.json",
+        "translations/ru.json"
+    ]
+
+}
+
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/resources/org/glyptodon/guacamole/auth/jdbc/connection/ConnectionMapper.xml b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/resources/org/glyptodon/guacamole/auth/jdbc/connection/ConnectionMapper.xml
new file mode 100644
index 0000000..8c85bf1
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/resources/org/glyptodon/guacamole/auth/jdbc/connection/ConnectionMapper.xml
@@ -0,0 +1,172 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+    "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
+
+<!--
+   Copyright (C) 2015 Glyptodon LLC
+
+   Permission is hereby granted, free of charge, to any person obtaining a copy
+   of this software and associated documentation files (the "Software"), to deal
+   in the Software without restriction, including without limitation the rights
+   to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+   copies of the Software, and to permit persons to whom the Software is
+   furnished to do so, subject to the following conditions:
+
+   The above copyright notice and this permission notice shall be included in
+   all copies or substantial portions of the Software.
+
+   THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+   IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+   FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+   AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+   LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+   OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+   THE SOFTWARE.
+-->
+
+<mapper namespace="org.glyptodon.guacamole.auth.jdbc.connection.ConnectionMapper" >
+
+    <!-- Result mapper for connection objects -->
+    <resultMap id="ConnectionResultMap" type="org.glyptodon.guacamole.auth.jdbc.connection.ConnectionModel" >
+        <id     column="connection_id"            property="objectID"              jdbcType="INTEGER"/>
+        <result column="connection_name"          property="name"                  jdbcType="VARCHAR"/>
+        <result column="parent_id"                property="parentIdentifier"      jdbcType="INTEGER"/>
+        <result column="protocol"                 property="protocol"              jdbcType="VARCHAR"/>
+        <result column="max_connections"          property="maxConnections"        jdbcType="INTEGER"/>
+        <result column="max_connections_per_user" property="maxConnectionsPerUser" jdbcType="INTEGER"/>
+    </resultMap>
+
+    <!-- Select all connection identifiers -->
+    <select id="selectIdentifiers" resultType="string">
+        SELECT connection_id 
+        FROM guacamole_connection
+    </select>
+
+    <!-- Select identifiers of all readable connections -->
+    <select id="selectReadableIdentifiers" resultType="string">
+        SELECT connection_id
+        FROM guacamole_connection_permission
+        WHERE
+            user_id = #{user.objectID,jdbcType=INTEGER}
+            AND permission = 'READ'
+    </select>
+
+    <!-- Select all connection identifiers within a particular connection group -->
+    <select id="selectIdentifiersWithin" resultType="string">
+        SELECT connection_id 
+        FROM guacamole_connection
+        WHERE
+            <if test="parentIdentifier != null">parent_id = #{parentIdentifier,jdbcType=VARCHAR}</if>
+            <if test="parentIdentifier == null">parent_id IS NULL</if>
+    </select>
+
+    <!-- Select identifiers of all readable connections within a particular connection group -->
+    <select id="selectReadableIdentifiersWithin" resultType="string">
+        SELECT guacamole_connection.connection_id
+        FROM guacamole_connection
+        JOIN guacamole_connection_permission ON guacamole_connection_permission.connection_id = guacamole_connection.connection_id
+        WHERE
+            <if test="parentIdentifier != null">parent_id = #{parentIdentifier,jdbcType=VARCHAR}</if>
+            <if test="parentIdentifier == null">parent_id IS NULL</if>
+            AND user_id = #{user.objectID,jdbcType=INTEGER}
+            AND permission = 'READ'
+    </select>
+
+    <!-- Select multiple connections by identifier -->
+    <select id="select" resultMap="ConnectionResultMap">
+
+        SELECT
+            connection_id,
+            connection_name,
+            parent_id,
+            protocol,
+            max_connections,
+            max_connections_per_user
+        FROM guacamole_connection
+        WHERE connection_id IN
+            <foreach collection="identifiers" item="identifier"
+                     open="(" separator="," close=")">
+                #{identifier,jdbcType=VARCHAR}
+            </foreach>
+
+    </select>
+
+    <!-- Select multiple connections by identifier only if readable -->
+    <select id="selectReadable" resultMap="ConnectionResultMap">
+
+        SELECT
+            guacamole_connection.connection_id,
+            connection_name,
+            parent_id,
+            protocol,
+            max_connections,
+            max_connections_per_user
+        FROM guacamole_connection
+        JOIN guacamole_connection_permission ON guacamole_connection_permission.connection_id = guacamole_connection.connection_id
+        WHERE guacamole_connection.connection_id IN
+            <foreach collection="identifiers" item="identifier"
+                     open="(" separator="," close=")">
+                #{identifier,jdbcType=VARCHAR}
+            </foreach>
+            AND user_id = #{user.objectID,jdbcType=INTEGER}
+            AND permission = 'READ'
+
+    </select>
+
+    <!-- Select single connection by name -->
+    <select id="selectOneByName" resultMap="ConnectionResultMap">
+
+        SELECT
+            connection_id,
+            connection_name,
+            parent_id,
+            protocol,
+            max_connections,
+            max_connections_per_user
+        FROM guacamole_connection
+        WHERE 
+            <if test="parentIdentifier != null">parent_id = #{parentIdentifier,jdbcType=VARCHAR}</if>
+            <if test="parentIdentifier == null">parent_id IS NULL</if>
+            AND connection_name = #{name,jdbcType=VARCHAR}
+
+    </select>
+
+    <!-- Delete single connection by identifier -->
+    <delete id="delete">
+        DELETE FROM guacamole_connection
+        WHERE connection_id = #{identifier,jdbcType=VARCHAR}
+    </delete>
+
+    <!-- Insert single connection -->
+    <insert id="insert" useGeneratedKeys="true" keyProperty="object.objectID"
+            parameterType="org.glyptodon.guacamole.auth.jdbc.connection.ConnectionModel">
+
+        INSERT INTO guacamole_connection (
+            connection_name,
+            parent_id,
+            protocol,
+            max_connections,
+            max_connections_per_user
+        )
+        VALUES (
+            #{object.name,jdbcType=VARCHAR},
+            #{object.parentIdentifier,jdbcType=VARCHAR},
+            #{object.protocol,jdbcType=VARCHAR},
+            #{object.maxConnections,jdbcType=INTEGER},
+            #{object.maxConnectionsPerUser,jdbcType=INTEGER}
+        )
+
+    </insert>
+
+    <!-- Update single connection -->
+    <update id="update" parameterType="org.glyptodon.guacamole.auth.jdbc.connection.ConnectionModel">
+        UPDATE guacamole_connection
+        SET connection_name          = #{object.name,jdbcType=VARCHAR},
+            parent_id                = #{object.parentIdentifier,jdbcType=VARCHAR},
+            protocol                 = #{object.protocol,jdbcType=VARCHAR},
+            max_connections          = #{object.maxConnections,jdbcType=INTEGER},
+            max_connections_per_user = #{object.maxConnectionsPerUser,jdbcType=INTEGER}
+        WHERE connection_id = #{object.objectID,jdbcType=INTEGER}
+    </update>
+
+</mapper>
\ No newline at end of file
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/resources/org/glyptodon/guacamole/auth/jdbc/connection/ConnectionRecordMapper.xml b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/resources/org/glyptodon/guacamole/auth/jdbc/connection/ConnectionRecordMapper.xml
new file mode 100644
index 0000000..91eec14
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/resources/org/glyptodon/guacamole/auth/jdbc/connection/ConnectionRecordMapper.xml
@@ -0,0 +1,200 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+    "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
+
+<!--
+   Copyright (C) 2015 Glyptodon LLC
+
+   Permission is hereby granted, free of charge, to any person obtaining a copy
+   of this software and associated documentation files (the "Software"), to deal
+   in the Software without restriction, including without limitation the rights
+   to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+   copies of the Software, and to permit persons to whom the Software is
+   furnished to do so, subject to the following conditions:
+
+   The above copyright notice and this permission notice shall be included in
+   all copies or substantial portions of the Software.
+
+   THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+   IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+   FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+   AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+   LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+   OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+   THE SOFTWARE.
+-->
+
+<mapper namespace="org.glyptodon.guacamole.auth.jdbc.connection.ConnectionRecordMapper" >
+
+    <!-- Result mapper for system permissions -->
+    <resultMap id="ConnectionRecordResultMap" type="org.glyptodon.guacamole.auth.jdbc.connection.ConnectionRecordModel">
+        <result column="connection_id"   property="connectionIdentifier" jdbcType="INTEGER"/>
+        <result column="connection_name" property="connectionName"       jdbcType="VARCHAR"/>
+        <result column="user_id"         property="userID"               jdbcType="INTEGER"/>
+        <result column="username"        property="username"             jdbcType="VARCHAR"/>
+        <result column="start_date"      property="startDate"            jdbcType="TIMESTAMP"/>
+        <result column="end_date"        property="endDate"              jdbcType="TIMESTAMP"/>
+    </resultMap>
+
+    <!-- Select all connection records from a given connection -->
+    <select id="select" resultMap="ConnectionRecordResultMap">
+
+        SELECT
+            guacamole_connection.connection_id,
+            guacamole_connection.connection_name,
+            guacamole_user.user_id,
+            guacamole_user.username,
+            guacamole_connection_history.start_date,
+            guacamole_connection_history.end_date
+        FROM guacamole_connection_history
+        JOIN guacamole_connection ON guacamole_connection_history.connection_id = guacamole_connection.connection_id
+        JOIN guacamole_user ON guacamole_connection_history.user_id = guacamole_user.user_id
+        WHERE
+            guacamole_connection.connection_id = #{identifier,jdbcType=VARCHAR}
+        ORDER BY
+            guacamole_connection_history.start_date DESC,
+            guacamole_connection_history.end_date DESC
+
+    </select>
+
+    <!-- Insert the given connection record -->
+    <insert id="insert" parameterType="org.glyptodon.guacamole.auth.jdbc.connection.ConnectionRecordModel">
+
+        INSERT INTO guacamole_connection_history (
+            connection_id,
+            user_id,
+            start_date,
+            end_date
+        )
+        VALUES (
+            #{record.connectionIdentifier,jdbcType=VARCHAR},
+            #{record.userID,jdbcType=INTEGER},
+            #{record.startDate,jdbcType=TIMESTAMP},
+            #{record.endDate,jdbcType=TIMESTAMP}
+        )
+
+    </insert>
+
+    <!-- Search for specific connection records -->
+    <select id="search" resultMap="ConnectionRecordResultMap">
+
+        SELECT
+            guacamole_connection_history.connection_id,
+            guacamole_connection.connection_name,
+            guacamole_connection_history.user_id,
+            guacamole_user.username,
+            guacamole_connection_history.start_date,
+            guacamole_connection_history.end_date
+        FROM guacamole_connection_history
+        LEFT JOIN guacamole_connection ON guacamole_connection_history.connection_id = guacamole_connection.connection_id
+        LEFT JOIN guacamole_user       ON guacamole_connection_history.user_id       = guacamole_user.user_id
+
+        <!-- Search terms -->
+        <foreach collection="terms" item="term"
+                 open="WHERE " separator=" AND ">
+            (
+
+                guacamole_connection_history.user_id IN (
+                    SELECT user_id
+                    FROM guacamole_user
+                    WHERE POSITION(#{term.term,jdbcType=VARCHAR} IN username) > 0
+                )
+
+                OR guacamole_connection_history.connection_id IN (
+                    SELECT connection_id
+                    FROM guacamole_connection
+                    WHERE POSITION(#{term.term,jdbcType=VARCHAR} IN connection_name) > 0
+                )
+
+                <if test="term.startDate != null and term.endDate != null">
+                    OR start_date BETWEEN #{term.startDate,jdbcType=TIMESTAMP} AND #{term.endDate,jdbcType=TIMESTAMP}
+                </if>
+
+            )
+        </foreach>
+
+        <!-- Bind sort property enum values for sake of readability -->
+        <bind name="START_DATE"      value="@org.glyptodon.guacamole.net.auth.ConnectionRecordSet$SortableProperty at START_DATE"/>
+
+        <!-- Sort predicates -->
+        <foreach collection="sortPredicates" item="sortPredicate"
+                 open="ORDER BY " separator=", ">
+            <choose>
+                <when test="sortPredicate.property == START_DATE">guacamole_connection_history.start_date</when>
+                <otherwise>1</otherwise>
+            </choose>
+            <if test="sortPredicate.descending">DESC</if>
+        </foreach>
+
+        LIMIT #{limit,jdbcType=INTEGER}
+
+    </select>
+
+    <!-- Search for specific connection records -->
+    <select id="searchReadable" resultMap="ConnectionRecordResultMap">
+
+        SELECT
+            guacamole_connection_history.connection_id,
+            guacamole_connection.connection_name,
+            guacamole_connection_history.user_id,
+            guacamole_user.username,
+            guacamole_connection_history.start_date,
+            guacamole_connection_history.end_date
+        FROM guacamole_connection_history
+        LEFT JOIN guacamole_connection ON guacamole_connection_history.connection_id = guacamole_connection.connection_id
+        LEFT JOIN guacamole_user       ON guacamole_connection_history.user_id       = guacamole_user.user_id
+
+        <!-- Restrict to readable connections -->
+        JOIN guacamole_connection_permission ON
+                guacamole_connection_history.connection_id = guacamole_connection_permission.connection_id
+            AND guacamole_connection_permission.user_id    = #{user.objectID,jdbcType=INTEGER}
+            AND guacamole_connection_permission.permission = 'READ'
+
+        <!-- Restrict to readable users -->
+        JOIN guacamole_user_permission ON
+                guacamole_connection_history.user_id = guacamole_user_permission.affected_user_id
+            AND guacamole_user_permission.user_id    = #{user.objectID,jdbcType=INTEGER}
+            AND guacamole_user_permission.permission = 'READ'
+
+        <!-- Search terms -->
+        <foreach collection="terms" item="term"
+                 open="WHERE " separator=" AND ">
+            (
+
+                guacamole_connection_history.user_id IN (
+                    SELECT user_id
+                    FROM guacamole_user
+                    WHERE POSITION(#{term.term,jdbcType=VARCHAR} IN username) > 0
+                )
+
+                OR guacamole_connection_history.connection_id IN (
+                    SELECT connection_id
+                    FROM guacamole_connection
+                    WHERE POSITION(#{term.term,jdbcType=VARCHAR} IN connection_name) > 0
+                )
+
+                <if test="term.startDate != null and term.endDate != null">
+                    OR start_date BETWEEN #{term.startDate,jdbcType=TIMESTAMP} AND #{term.endDate,jdbcType=TIMESTAMP}
+                </if>
+
+            )
+        </foreach>
+
+        <!-- Bind sort property enum values for sake of readability -->
+        <bind name="START_DATE"      value="@org.glyptodon.guacamole.net.auth.ConnectionRecordSet$SortableProperty at START_DATE"/>
+
+        <!-- Sort predicates -->
+        <foreach collection="sortPredicates" item="sortPredicate"
+                 open="ORDER BY " separator=", ">
+            <choose>
+                <when test="sortPredicate.property == START_DATE">guacamole_connection_history.start_date</when>
+                <otherwise>1</otherwise>
+            </choose>
+            <if test="sortPredicate.descending">DESC</if>
+        </foreach>
+        
+        LIMIT #{limit,jdbcType=INTEGER}
+
+    </select>
+
+</mapper>
\ No newline at end of file
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/resources/org/glyptodon/guacamole/auth/jdbc/connection/ParameterMapper.xml b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/resources/org/glyptodon/guacamole/auth/jdbc/connection/ParameterMapper.xml
new file mode 100644
index 0000000..ccd386c
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/resources/org/glyptodon/guacamole/auth/jdbc/connection/ParameterMapper.xml
@@ -0,0 +1,71 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+    "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
+
+<!--
+   Copyright (C) 2015 Glyptodon LLC
+
+   Permission is hereby granted, free of charge, to any person obtaining a copy
+   of this software and associated documentation files (the "Software"), to deal
+   in the Software without restriction, including without limitation the rights
+   to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+   copies of the Software, and to permit persons to whom the Software is
+   furnished to do so, subject to the following conditions:
+
+   The above copyright notice and this permission notice shall be included in
+   all copies or substantial portions of the Software.
+
+   THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+   IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+   FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+   AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+   LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+   OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+   THE SOFTWARE.
+-->
+
+<mapper namespace="org.glyptodon.guacamole.auth.jdbc.connection.ParameterMapper">
+
+    <!-- Result mapper for connection parameters -->
+    <resultMap id="ParameterResultMap" type="org.glyptodon.guacamole.auth.jdbc.connection.ParameterModel">
+        <result column="connection_id"   property="connectionIdentifier" jdbcType="INTEGER"/>
+        <result column="parameter_name"  property="name"                 jdbcType="VARCHAR"/>
+        <result column="parameter_value" property="value"                jdbcType="VARCHAR"/>
+    </resultMap>
+
+    <!-- Select all parameters of a given connection -->
+    <select id="select" resultMap="ParameterResultMap">
+        SELECT
+            connection_id,
+            parameter_name,
+            parameter_value
+        FROM guacamole_connection_parameter
+        WHERE
+            connection_id = #{identifier,jdbcType=VARCHAR}
+    </select>
+
+    <!-- Delete all parameters of a given connection -->
+    <delete id="delete">
+        DELETE FROM guacamole_connection_parameter
+        WHERE connection_id = #{identifier,jdbcType=VARCHAR}
+    </delete>
+
+    <!-- Insert all given parameters -->
+    <insert id="insert" parameterType="org.glyptodon.guacamole.auth.jdbc.connection.ParameterModel">
+
+        INSERT INTO guacamole_connection_parameter (
+            connection_id,
+            parameter_name,
+            parameter_value
+        )
+        VALUES 
+            <foreach collection="parameters" item="parameter" separator=",">
+                (#{parameter.connectionIdentifier,jdbcType=VARCHAR},
+                 #{parameter.name,jdbcType=VARCHAR},
+                 #{parameter.value,jdbcType=VARCHAR})
+            </foreach>
+
+    </insert>
+
+
+</mapper>
\ No newline at end of file
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/resources/org/glyptodon/guacamole/auth/jdbc/connectiongroup/ConnectionGroupMapper.xml b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/resources/org/glyptodon/guacamole/auth/jdbc/connectiongroup/ConnectionGroupMapper.xml
new file mode 100644
index 0000000..75acaab
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/resources/org/glyptodon/guacamole/auth/jdbc/connectiongroup/ConnectionGroupMapper.xml
@@ -0,0 +1,173 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+    "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
+
+<!--
+   Copyright (C) 2015 Glyptodon LLC
+
+   Permission is hereby granted, free of charge, to any person obtaining a copy
+   of this software and associated documentation files (the "Software"), to deal
+   in the Software without restriction, including without limitation the rights
+   to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+   copies of the Software, and to permit persons to whom the Software is
+   furnished to do so, subject to the following conditions:
+
+   The above copyright notice and this permission notice shall be included in
+   all copies or substantial portions of the Software.
+
+   THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+   IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+   FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+   AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+   LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+   OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+   THE SOFTWARE.
+-->
+
+<mapper namespace="org.glyptodon.guacamole.auth.jdbc.connectiongroup.ConnectionGroupMapper" >
+
+    <!-- Result mapper for connection objects -->
+    <resultMap id="ConnectionGroupResultMap" type="org.glyptodon.guacamole.auth.jdbc.connectiongroup.ConnectionGroupModel" >
+        <id     column="connection_group_id"      property="objectID"              jdbcType="INTEGER"/>
+        <result column="connection_group_name"    property="name"                  jdbcType="VARCHAR"/>
+        <result column="parent_id"                property="parentIdentifier"      jdbcType="INTEGER"/>
+        <result column="type"                     property="type"                  jdbcType="VARCHAR"
+                javaType="org.glyptodon.guacamole.net.auth.ConnectionGroup$Type"/>
+        <result column="max_connections"          property="maxConnections"        jdbcType="INTEGER"/>
+        <result column="max_connections_per_user" property="maxConnectionsPerUser" jdbcType="INTEGER"/>
+    </resultMap>
+
+    <!-- Select all connection group identifiers -->
+    <select id="selectIdentifiers" resultType="string">
+        SELECT connection_group_id 
+        FROM guacamole_connection_group
+    </select>
+
+    <!-- Select identifiers of all readable connection groups -->
+    <select id="selectReadableIdentifiers" resultType="string">
+        SELECT connection_group_id
+        FROM guacamole_connection_group_permission
+        WHERE
+            user_id = #{user.objectID,jdbcType=INTEGER}
+            AND permission = 'READ'
+    </select>
+
+    <!-- Select all connection identifiers within a particular connection group -->
+    <select id="selectIdentifiersWithin" resultType="string">
+        SELECT connection_group_id 
+        FROM guacamole_connection_group
+        WHERE
+            <if test="parentIdentifier != null">parent_id = #{parentIdentifier,jdbcType=VARCHAR}</if>
+            <if test="parentIdentifier == null">parent_id IS NULL</if>
+    </select>
+
+    <!-- Select identifiers of all readable connection groups within a particular connection group -->
+    <select id="selectReadableIdentifiersWithin" resultType="string">
+        SELECT guacamole_connection_group.connection_group_id
+        FROM guacamole_connection_group
+        JOIN guacamole_connection_group_permission ON guacamole_connection_group_permission.connection_group_id = guacamole_connection_group.connection_group_id
+        WHERE
+            <if test="parentIdentifier != null">parent_id = #{parentIdentifier,jdbcType=VARCHAR}</if>
+            <if test="parentIdentifier == null">parent_id IS NULL</if>
+            AND user_id = #{user.objectID,jdbcType=INTEGER}
+            AND permission = 'READ'
+    </select>
+
+    <!-- Select multiple connection groups by identifier -->
+    <select id="select" resultMap="ConnectionGroupResultMap">
+
+        SELECT
+            connection_group_id,
+            connection_group_name,
+            parent_id,
+            type,
+            max_connections,
+            max_connections_per_user
+        FROM guacamole_connection_group
+        WHERE connection_group_id IN
+            <foreach collection="identifiers" item="identifier"
+                     open="(" separator="," close=")">
+                #{identifier,jdbcType=VARCHAR}
+            </foreach>
+
+    </select>
+
+    <!-- Select multiple connection groups by identifier only if readable -->
+    <select id="selectReadable" resultMap="ConnectionGroupResultMap">
+
+        SELECT
+            guacamole_connection_group.connection_group_id,
+            connection_group_name,
+            parent_id,
+            type,
+            max_connections,
+            max_connections_per_user
+        FROM guacamole_connection_group
+        JOIN guacamole_connection_group_permission ON guacamole_connection_group_permission.connection_group_id = guacamole_connection_group.connection_group_id
+        WHERE guacamole_connection_group.connection_group_id IN
+            <foreach collection="identifiers" item="identifier"
+                     open="(" separator="," close=")">
+                #{identifier,jdbcType=VARCHAR}
+            </foreach>
+            AND user_id = #{user.objectID,jdbcType=INTEGER}
+            AND permission = 'READ'
+
+    </select>
+
+    <!-- Select single connection group by name -->
+    <select id="selectOneByName" resultMap="ConnectionGroupResultMap">
+
+        SELECT
+            connection_group_id,
+            connection_group_name,
+            parent_id,
+            type,
+            max_connections,
+            max_connections_per_user
+        FROM guacamole_connection_group
+        WHERE 
+            <if test="parentIdentifier != null">parent_id = #{parentIdentifier,jdbcType=VARCHAR}</if>
+            <if test="parentIdentifier == null">parent_id IS NULL</if>
+            AND connection_group_name = #{name,jdbcType=VARCHAR}
+
+    </select>
+
+    <!-- Delete single connection group by identifier -->
+    <delete id="delete">
+        DELETE FROM guacamole_connection_group
+        WHERE connection_group_id = #{identifier,jdbcType=VARCHAR}
+    </delete>
+
+    <!-- Insert single connection -->
+    <insert id="insert" useGeneratedKeys="true" keyProperty="object.objectID"
+            parameterType="org.glyptodon.guacamole.auth.jdbc.connectiongroup.ConnectionGroupModel">
+
+        INSERT INTO guacamole_connection_group (
+            connection_group_name,
+            parent_id,
+            type,
+            max_connections,
+            max_connections_per_user
+        )
+        VALUES (
+            #{object.name,jdbcType=VARCHAR},
+            #{object.parentIdentifier,jdbcType=VARCHAR},
+            #{object.type,jdbcType=VARCHAR},
+            #{object.maxConnections,jdbcType=INTEGER},
+            #{object.maxConnectionsPerUser,jdbcType=INTEGER}
+        )
+
+    </insert>
+
+    <!-- Update single connection group -->
+    <update id="update" parameterType="org.glyptodon.guacamole.auth.jdbc.connectiongroup.ConnectionGroupModel">
+        UPDATE guacamole_connection_group
+        SET connection_group_name    = #{object.name,jdbcType=VARCHAR},
+            parent_id                = #{object.parentIdentifier,jdbcType=VARCHAR},
+            type                     = #{object.type,jdbcType=VARCHAR},
+            max_connections          = #{object.maxConnections,jdbcType=INTEGER},
+            max_connections_per_user = #{object.maxConnectionsPerUser,jdbcType=INTEGER}
+        WHERE connection_group_id = #{object.objectID,jdbcType=INTEGER}
+    </update>
+
+</mapper>
\ No newline at end of file
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/resources/org/glyptodon/guacamole/auth/jdbc/permission/ConnectionGroupPermissionMapper.xml b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/resources/org/glyptodon/guacamole/auth/jdbc/permission/ConnectionGroupPermissionMapper.xml
new file mode 100644
index 0000000..40ada12
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/resources/org/glyptodon/guacamole/auth/jdbc/permission/ConnectionGroupPermissionMapper.xml
@@ -0,0 +1,120 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+    "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
+
+<!--
+   Copyright (C) 2015 Glyptodon LLC
+
+   Permission is hereby granted, free of charge, to any person obtaining a copy
+   of this software and associated documentation files (the "Software"), to deal
+   in the Software without restriction, including without limitation the rights
+   to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+   copies of the Software, and to permit persons to whom the Software is
+   furnished to do so, subject to the following conditions:
+
+   The above copyright notice and this permission notice shall be included in
+   all copies or substantial portions of the Software.
+
+   THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+   IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+   FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+   AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+   LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+   OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+   THE SOFTWARE.
+-->
+
+<mapper namespace="org.glyptodon.guacamole.auth.jdbc.permission.ConnectionGroupPermissionMapper" >
+
+    <!-- Result mapper for connection permissions -->
+    <resultMap id="ConnectionGroupPermissionResultMap" type="org.glyptodon.guacamole.auth.jdbc.permission.ObjectPermissionModel">
+        <result column="user_id"             property="userID"           jdbcType="INTEGER"/>
+        <result column="username"            property="username"         jdbcType="VARCHAR"/>
+        <result column="permission"          property="type"             jdbcType="VARCHAR"
+                javaType="org.glyptodon.guacamole.net.auth.permission.ObjectPermission$Type"/>
+        <result column="connection_group_id" property="objectIdentifier" jdbcType="INTEGER"/>
+    </resultMap>
+
+    <!-- Select all permissions for a given user -->
+    <select id="select" resultMap="ConnectionGroupPermissionResultMap">
+
+        SELECT
+            guacamole_connection_group_permission.user_id,
+            username,
+            permission,
+            connection_group_id
+        FROM guacamole_connection_group_permission
+        JOIN guacamole_user ON guacamole_connection_group_permission.user_id = guacamole_user.user_id
+        WHERE guacamole_connection_group_permission.user_id = #{user.objectID,jdbcType=INTEGER}
+
+    </select>
+
+    <!-- Select the single permission matching the given criteria -->
+    <select id="selectOne" resultMap="ConnectionGroupPermissionResultMap">
+
+        SELECT
+            guacamole_connection_group_permission.user_id,
+            username,
+            permission,
+            connection_group_id
+        FROM guacamole_connection_group_permission
+        JOIN guacamole_user ON guacamole_connection_group_permission.user_id = guacamole_user.user_id
+        WHERE
+            guacamole_connection_group_permission.user_id = #{user.objectID,jdbcType=INTEGER}
+            AND permission = #{type,jdbcType=VARCHAR}
+            AND connection_group_id = #{identifier,jdbcType=VARCHAR}
+
+    </select>
+
+    <!-- Select identifiers accessible by the given user for the given permissions -->
+    <select id="selectAccessibleIdentifiers" resultType="string">
+
+        SELECT DISTINCT connection_group_id 
+        FROM guacamole_connection_group_permission
+        WHERE
+            user_id = #{user.objectID,jdbcType=INTEGER}
+            AND connection_group_id IN
+                <foreach collection="identifiers" item="identifier"
+                         open="(" separator="," close=")">
+                    #{identifier,jdbcType=VARCHAR}
+                </foreach>
+            AND permission IN
+                <foreach collection="permissions" item="permission"
+                         open="(" separator="," close=")">
+                    #{permission,jdbcType=VARCHAR}
+                </foreach>
+
+    </select>
+
+    <!-- Delete all given permissions -->
+    <delete id="delete" parameterType="org.glyptodon.guacamole.auth.jdbc.permission.ObjectPermissionModel">
+
+        DELETE FROM guacamole_connection_group_permission
+        WHERE (user_id, permission, connection_group_id) IN
+            <foreach collection="permissions" item="permission"
+                     open="(" separator="," close=")">
+                (#{permission.userID,jdbcType=INTEGER},
+                 #{permission.type,jdbcType=VARCHAR},
+                 #{permission.objectIdentifier,jdbcType=VARCHAR})
+            </foreach>
+
+    </delete>
+
+    <!-- Insert all given permissions -->
+    <insert id="insert" parameterType="org.glyptodon.guacamole.auth.jdbc.permission.ObjectPermissionModel">
+
+        INSERT IGNORE INTO guacamole_connection_group_permission (
+            user_id,
+            permission,
+            connection_group_id
+        )
+        VALUES
+            <foreach collection="permissions" item="permission" separator=",">
+                (#{permission.userID,jdbcType=INTEGER},
+                 #{permission.type,jdbcType=VARCHAR},
+                 #{permission.objectIdentifier,jdbcType=VARCHAR})
+            </foreach>
+
+    </insert>
+
+</mapper>
\ No newline at end of file
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/resources/org/glyptodon/guacamole/auth/jdbc/permission/ConnectionPermissionMapper.xml b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/resources/org/glyptodon/guacamole/auth/jdbc/permission/ConnectionPermissionMapper.xml
new file mode 100644
index 0000000..9935f3c
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/resources/org/glyptodon/guacamole/auth/jdbc/permission/ConnectionPermissionMapper.xml
@@ -0,0 +1,120 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+    "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
+
+<!--
+   Copyright (C) 2015 Glyptodon LLC
+
+   Permission is hereby granted, free of charge, to any person obtaining a copy
+   of this software and associated documentation files (the "Software"), to deal
+   in the Software without restriction, including without limitation the rights
+   to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+   copies of the Software, and to permit persons to whom the Software is
+   furnished to do so, subject to the following conditions:
+
+   The above copyright notice and this permission notice shall be included in
+   all copies or substantial portions of the Software.
+
+   THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+   IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+   FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+   AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+   LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+   OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+   THE SOFTWARE.
+-->
+
+<mapper namespace="org.glyptodon.guacamole.auth.jdbc.permission.ConnectionPermissionMapper" >
+
+    <!-- Result mapper for connection permissions -->
+    <resultMap id="ConnectionPermissionResultMap" type="org.glyptodon.guacamole.auth.jdbc.permission.ObjectPermissionModel">
+        <result column="user_id"       property="userID"           jdbcType="INTEGER"/>
+        <result column="username"      property="username"         jdbcType="VARCHAR"/>
+        <result column="permission"    property="type"             jdbcType="VARCHAR"
+                javaType="org.glyptodon.guacamole.net.auth.permission.ObjectPermission$Type"/>
+        <result column="connection_id" property="objectIdentifier" jdbcType="INTEGER"/>
+    </resultMap>
+
+    <!-- Select all permissions for a given user -->
+    <select id="select" resultMap="ConnectionPermissionResultMap">
+
+        SELECT
+            guacamole_connection_permission.user_id,
+            username,
+            permission,
+            connection_id
+        FROM guacamole_connection_permission
+        JOIN guacamole_user ON guacamole_connection_permission.user_id = guacamole_user.user_id
+        WHERE guacamole_connection_permission.user_id = #{user.objectID,jdbcType=INTEGER}
+
+    </select>
+
+    <!-- Select the single permission matching the given criteria -->
+    <select id="selectOne" resultMap="ConnectionPermissionResultMap">
+
+        SELECT
+            guacamole_connection_permission.user_id,
+            username,
+            permission,
+            connection_id
+        FROM guacamole_connection_permission
+        JOIN guacamole_user ON guacamole_connection_permission.user_id = guacamole_user.user_id
+        WHERE
+            guacamole_connection_permission.user_id = #{user.objectID,jdbcType=INTEGER}
+            AND permission = #{type,jdbcType=VARCHAR}
+            AND connection_id = #{identifier,jdbcType=VARCHAR}
+
+    </select>
+
+    <!-- Select identifiers accessible by the given user for the given permissions -->
+    <select id="selectAccessibleIdentifiers" resultType="string">
+
+        SELECT DISTINCT connection_id 
+        FROM guacamole_connection_permission
+        WHERE
+            user_id = #{user.objectID,jdbcType=INTEGER}
+            AND connection_id IN
+                <foreach collection="identifiers" item="identifier"
+                         open="(" separator="," close=")">
+                    #{identifier,jdbcType=VARCHAR}
+                </foreach>
+            AND permission IN
+                <foreach collection="permissions" item="permission"
+                         open="(" separator="," close=")">
+                    #{permission,jdbcType=VARCHAR}
+                </foreach>
+
+    </select>
+
+    <!-- Delete all given permissions -->
+    <delete id="delete" parameterType="org.glyptodon.guacamole.auth.jdbc.permission.ObjectPermissionModel">
+
+        DELETE FROM guacamole_connection_permission
+        WHERE (user_id, permission, connection_id) IN
+            <foreach collection="permissions" item="permission"
+                     open="(" separator="," close=")">
+                (#{permission.userID,jdbcType=INTEGER},
+                 #{permission.type,jdbcType=VARCHAR},
+                 #{permission.objectIdentifier,jdbcType=VARCHAR})
+            </foreach>
+
+    </delete>
+
+    <!-- Insert all given permissions -->
+    <insert id="insert" parameterType="org.glyptodon.guacamole.auth.jdbc.permission.ObjectPermissionModel">
+
+        INSERT IGNORE INTO guacamole_connection_permission (
+            user_id,
+            permission,
+            connection_id
+        )
+        VALUES
+            <foreach collection="permissions" item="permission" separator=",">
+                (#{permission.userID,jdbcType=INTEGER},
+                 #{permission.type,jdbcType=VARCHAR},
+                 #{permission.objectIdentifier,jdbcType=VARCHAR})
+            </foreach>
+
+    </insert>
+
+</mapper>
\ No newline at end of file
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/resources/org/glyptodon/guacamole/auth/jdbc/permission/SystemPermissionMapper.xml b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/resources/org/glyptodon/guacamole/auth/jdbc/permission/SystemPermissionMapper.xml
new file mode 100644
index 0000000..55eacd0
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/resources/org/glyptodon/guacamole/auth/jdbc/permission/SystemPermissionMapper.xml
@@ -0,0 +1,93 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+    "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
+
+<!--
+   Copyright (C) 2015 Glyptodon LLC
+
+   Permission is hereby granted, free of charge, to any person obtaining a copy
+   of this software and associated documentation files (the "Software"), to deal
+   in the Software without restriction, including without limitation the rights
+   to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+   copies of the Software, and to permit persons to whom the Software is
+   furnished to do so, subject to the following conditions:
+
+   The above copyright notice and this permission notice shall be included in
+   all copies or substantial portions of the Software.
+
+   THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+   IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+   FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+   AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+   LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+   OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+   THE SOFTWARE.
+-->
+
+<mapper namespace="org.glyptodon.guacamole.auth.jdbc.permission.SystemPermissionMapper" >
+
+    <!-- Result mapper for system permissions -->
+    <resultMap id="SystemPermissionResultMap" type="org.glyptodon.guacamole.auth.jdbc.permission.SystemPermissionModel">
+        <result column="user_id"    property="userID"   jdbcType="INTEGER"/>
+        <result column="username"   property="username" jdbcType="VARCHAR"/>
+        <result column="permission" property="type"     jdbcType="VARCHAR"
+                javaType="org.glyptodon.guacamole.net.auth.permission.SystemPermission$Type"/>
+    </resultMap>
+
+    <!-- Select all permissions for a given user -->
+    <select id="select" resultMap="SystemPermissionResultMap">
+
+        SELECT
+            guacamole_system_permission.user_id,
+            username,
+            permission
+        FROM guacamole_system_permission
+        JOIN guacamole_user ON guacamole_system_permission.user_id = guacamole_user.user_id
+        WHERE guacamole_system_permission.user_id = #{user.objectID,jdbcType=INTEGER}
+
+    </select>
+
+    <!-- Select the single permission matching the given criteria -->
+    <select id="selectOne" resultMap="SystemPermissionResultMap">
+
+        SELECT
+            guacamole_system_permission.user_id,
+            username,
+            permission
+        FROM guacamole_system_permission
+        JOIN guacamole_user ON guacamole_system_permission.user_id = guacamole_user.user_id
+        WHERE
+            guacamole_system_permission.user_id = #{user.objectID,jdbcType=INTEGER}
+            AND permission = #{type,jdbcType=VARCHAR}
+
+    </select>
+
+    <!-- Delete all given permissions -->
+    <delete id="delete" parameterType="org.glyptodon.guacamole.auth.jdbc.permission.SystemPermissionModel">
+
+        DELETE FROM guacamole_system_permission
+        WHERE (user_id, permission) IN
+            <foreach collection="permissions" item="permission"
+                     open="(" separator="," close=")">
+                (#{permission.userID,jdbcType=INTEGER},
+                 #{permission.type,jdbcType=VARCHAR})
+            </foreach>
+
+    </delete>
+
+    <!-- Insert all given permissions -->
+    <insert id="insert" parameterType="org.glyptodon.guacamole.auth.jdbc.permission.SystemPermissionModel">
+
+        INSERT IGNORE INTO guacamole_system_permission (
+            user_id,
+            permission
+        )
+        VALUES
+            <foreach collection="permissions" item="permission" separator=",">
+                (#{permission.userID,jdbcType=INTEGER},
+                 #{permission.type,jdbcType=VARCHAR})
+            </foreach>
+
+    </insert>
+
+</mapper>
\ No newline at end of file
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/resources/org/glyptodon/guacamole/auth/jdbc/permission/UserPermissionMapper.xml b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/resources/org/glyptodon/guacamole/auth/jdbc/permission/UserPermissionMapper.xml
new file mode 100644
index 0000000..038bb81
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/resources/org/glyptodon/guacamole/auth/jdbc/permission/UserPermissionMapper.xml
@@ -0,0 +1,129 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+    "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
+
+<!--
+   Copyright (C) 2015 Glyptodon LLC
+
+   Permission is hereby granted, free of charge, to any person obtaining a copy
+   of this software and associated documentation files (the "Software"), to deal
+   in the Software without restriction, including without limitation the rights
+   to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+   copies of the Software, and to permit persons to whom the Software is
+   furnished to do so, subject to the following conditions:
+
+   The above copyright notice and this permission notice shall be included in
+   all copies or substantial portions of the Software.
+
+   THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+   IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+   FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+   AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+   LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+   OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+   THE SOFTWARE.
+-->
+
+<mapper namespace="org.glyptodon.guacamole.auth.jdbc.permission.UserPermissionMapper" >
+
+    <!-- Result mapper for user permissions -->
+    <resultMap id="UserPermissionResultMap" type="org.glyptodon.guacamole.auth.jdbc.permission.ObjectPermissionModel">
+        <result column="user_id"           property="userID"           jdbcType="INTEGER"/>
+        <result column="username"          property="username"         jdbcType="VARCHAR"/>
+        <result column="permission"        property="type"             jdbcType="VARCHAR"
+                javaType="org.glyptodon.guacamole.net.auth.permission.ObjectPermission$Type"/>
+        <result column="affected_username" property="objectIdentifier" jdbcType="INTEGER"/>
+    </resultMap>
+
+    <!-- Select all permissions for a given user -->
+    <select id="select" resultMap="UserPermissionResultMap">
+
+        SELECT
+            guacamole_user_permission.user_id,
+            guacamole_user.username,
+            permission,
+            affected.username AS affected_username
+        FROM guacamole_user_permission
+        JOIN guacamole_user          ON guacamole_user_permission.user_id          = guacamole_user.user_id
+        JOIN guacamole_user affected ON guacamole_user_permission.affected_user_id = affected.user_id
+        WHERE guacamole_user_permission.user_id = #{user.objectID,jdbcType=INTEGER}
+
+    </select>
+
+    <!-- Select the single permission matching the given criteria -->
+    <select id="selectOne" resultMap="UserPermissionResultMap">
+
+        SELECT
+            guacamole_user_permission.user_id,
+            guacamole_user.username,
+            permission,
+            affected.username AS affected_username
+        FROM guacamole_user_permission
+        JOIN guacamole_user          ON guacamole_user_permission.user_id          = guacamole_user.user_id
+        JOIN guacamole_user affected ON guacamole_user_permission.affected_user_id = affected.user_id
+        WHERE
+            guacamole_user_permission.user_id = #{user.objectID,jdbcType=INTEGER}
+            AND permission = #{type,jdbcType=VARCHAR}
+            AND affected.username = #{identifier,jdbcType=VARCHAR}
+
+    </select>
+
+    <!-- Select identifiers accessible by the given user for the given permissions -->
+    <select id="selectAccessibleIdentifiers" resultType="string">
+
+        SELECT DISTINCT username
+        FROM guacamole_user_permission
+        JOIN guacamole_user ON guacamole_user_permission.affected_user_id = guacamole_user.user_id
+        WHERE
+            guacamole_user_permission.user_id = #{user.objectID,jdbcType=INTEGER}
+            AND username IN
+                <foreach collection="identifiers" item="identifier"
+                         open="(" separator="," close=")">
+                    #{identifier,jdbcType=VARCHAR}
+                </foreach>
+            AND permission IN
+                <foreach collection="permissions" item="permission"
+                         open="(" separator="," close=")">
+                    #{permission,jdbcType=VARCHAR}
+                </foreach>
+
+    </select>
+
+    <!-- Delete all given permissions -->
+    <delete id="delete" parameterType="org.glyptodon.guacamole.auth.jdbc.permission.ObjectPermissionModel">
+
+        DELETE FROM guacamole_user_permission
+        USING guacamole_user_permission
+        JOIN guacamole_user affected ON guacamole_user_permission.affected_user_id = affected.user_id
+        WHERE
+            (guacamole_user_permission.user_id, permission, affected.username) IN
+                <foreach collection="permissions" item="permission"
+                         open="(" separator="," close=")">
+                    (#{permission.userID,jdbcType=INTEGER},
+                     #{permission.type,jdbcType=VARCHAR},
+                     #{permission.objectIdentifier,jdbcType=VARCHAR})
+                </foreach>
+
+    </delete>
+
+    <!-- Insert all given permissions -->
+    <insert id="insert" parameterType="org.glyptodon.guacamole.auth.jdbc.permission.ObjectPermissionModel">
+
+        INSERT IGNORE INTO guacamole_user_permission (
+            user_id,
+            permission,
+            affected_user_id
+        )
+        SELECT permissions.user_id, permissions.permission, guacamole_user.user_id FROM
+            <foreach collection="permissions" item="permission"
+                     open="(" separator="UNION ALL" close=")">
+                SELECT #{permission.userID,jdbcType=INTEGER}           AS user_id,
+                       #{permission.type,jdbcType=VARCHAR}             AS permission,
+                       #{permission.objectIdentifier,jdbcType=VARCHAR} AS username
+            </foreach>
+        AS permissions
+        JOIN guacamole_user ON guacamole_user.username = permissions.username; 
+
+    </insert>
+
+</mapper>
\ No newline at end of file
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/resources/org/glyptodon/guacamole/auth/jdbc/user/UserMapper.xml b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/resources/org/glyptodon/guacamole/auth/jdbc/user/UserMapper.xml
new file mode 100644
index 0000000..6f3734c
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/resources/org/glyptodon/guacamole/auth/jdbc/user/UserMapper.xml
@@ -0,0 +1,183 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+    "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
+
+<!--
+   Copyright (C) 2015 Glyptodon LLC
+
+   Permission is hereby granted, free of charge, to any person obtaining a copy
+   of this software and associated documentation files (the "Software"), to deal
+   in the Software without restriction, including without limitation the rights
+   to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+   copies of the Software, and to permit persons to whom the Software is
+   furnished to do so, subject to the following conditions:
+
+   The above copyright notice and this permission notice shall be included in
+   all copies or substantial portions of the Software.
+
+   THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+   IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+   FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+   AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+   LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+   OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+   THE SOFTWARE.
+-->
+
+<mapper namespace="org.glyptodon.guacamole.auth.jdbc.user.UserMapper" >
+
+    <!-- Result mapper for user objects -->
+    <resultMap id="UserResultMap" type="org.glyptodon.guacamole.auth.jdbc.user.UserModel" >
+        <id     column="user_id"             property="objectID"          jdbcType="INTEGER"/>
+        <result column="username"            property="identifier"        jdbcType="VARCHAR"/>
+        <result column="password_hash"       property="passwordHash"      jdbcType="BINARY"/>
+        <result column="password_salt"       property="passwordSalt"      jdbcType="BINARY"/>
+        <result column="disabled"            property="disabled"          jdbcType="BOOLEAN"/>
+        <result column="access_window_start" property="accessWindowStart" jdbcType="TIME"/>
+        <result column="access_window_end"   property="accessWindowEnd"   jdbcType="TIME"/>
+        <result column="valid_from"          property="validFrom"         jdbcType="DATE"/>
+        <result column="valid_until"         property="validUntil"        jdbcType="DATE"/>
+        <result column="timezone"            property="timeZone"          jdbcType="VARCHAR"/>
+    </resultMap>
+
+    <!-- Select all usernames -->
+    <select id="selectIdentifiers" resultType="string">
+        SELECT username
+        FROM guacamole_user
+    </select>
+
+    <!-- Select usernames of all readable users -->
+    <select id="selectReadableIdentifiers" resultType="string">
+        SELECT username
+        FROM guacamole_user
+        JOIN guacamole_user_permission ON affected_user_id = guacamole_user.user_id
+        WHERE
+            guacamole_user_permission.user_id = #{user.objectID,jdbcType=INTEGER}
+            AND permission = 'READ'
+    </select>
+
+    <!-- Select multiple users by username -->
+    <select id="select" resultMap="UserResultMap">
+
+        SELECT
+            user_id,
+            username,
+            password_hash,
+            password_salt,
+            disabled,
+            expired,
+            access_window_start,
+            access_window_end,
+            valid_from,
+            valid_until,
+            timezone
+        FROM guacamole_user
+        WHERE username IN
+            <foreach collection="identifiers" item="identifier"
+                     open="(" separator="," close=")">
+                #{identifier,jdbcType=VARCHAR}
+            </foreach>
+
+    </select>
+
+    <!-- Select multiple users by username only if readable -->
+    <select id="selectReadable" resultMap="UserResultMap">
+
+        SELECT
+            guacamole_user.user_id,
+            username,
+            password_hash,
+            password_salt,
+            disabled,
+            expired,
+            access_window_start,
+            access_window_end,
+            valid_from,
+            valid_until,
+            timezone
+        FROM guacamole_user
+        JOIN guacamole_user_permission ON affected_user_id = guacamole_user.user_id
+        WHERE username IN
+            <foreach collection="identifiers" item="identifier"
+                     open="(" separator="," close=")">
+                #{identifier,jdbcType=VARCHAR}
+            </foreach>
+            AND guacamole_user_permission.user_id = #{user.objectID,jdbcType=INTEGER}
+            AND permission = 'READ'
+
+    </select>
+
+    <!-- Select single user by username -->
+    <select id="selectOne" resultMap="UserResultMap">
+
+        SELECT
+            user_id,
+            username,
+            password_hash,
+            password_salt,
+            disabled,
+            expired,
+            access_window_start,
+            access_window_end,
+            valid_from,
+            valid_until,
+            timezone
+        FROM guacamole_user
+        WHERE
+            username = #{username,jdbcType=VARCHAR}
+
+    </select>
+
+    <!-- Delete single user by username -->
+    <delete id="delete">
+        DELETE FROM guacamole_user
+        WHERE username = #{identifier,jdbcType=VARCHAR}
+    </delete>
+
+    <!-- Insert single user -->
+    <insert id="insert" useGeneratedKeys="true" keyProperty="object.objectID"
+            parameterType="org.glyptodon.guacamole.auth.jdbc.user.UserModel">
+
+        INSERT INTO guacamole_user (
+            username,
+            password_hash,
+            password_salt,
+            disabled,
+            expired,
+            access_window_start,
+            access_window_end,
+            valid_from,
+            valid_until,
+            timezone
+        )
+        VALUES (
+            #{object.identifier,jdbcType=VARCHAR},
+            #{object.passwordHash,jdbcType=BINARY},
+            #{object.passwordSalt,jdbcType=BINARY},
+            #{object.disabled,jdbcType=BOOLEAN},
+            #{object.expired,jdbcType=BOOLEAN},
+            #{object.accessWindowStart,jdbcType=TIME},
+            #{object.accessWindowEnd,jdbcType=TIME},
+            #{object.validFrom,jdbcType=DATE},
+            #{object.validUntil,jdbcType=DATE},
+            #{object.timeZone,jdbcType=VARCHAR}
+        )
+
+    </insert>
+
+    <!-- Update single user -->
+    <update id="update" parameterType="org.glyptodon.guacamole.auth.jdbc.user.UserModel">
+        UPDATE guacamole_user
+        SET password_hash = #{object.passwordHash,jdbcType=BINARY},
+            password_salt = #{object.passwordSalt,jdbcType=BINARY},
+            disabled = #{object.disabled,jdbcType=BOOLEAN},
+            expired = #{object.expired,jdbcType=BOOLEAN},
+            access_window_start = #{object.accessWindowStart,jdbcType=TIME},
+            access_window_end = #{object.accessWindowEnd,jdbcType=TIME},
+            valid_from = #{object.validFrom,jdbcType=DATE},
+            valid_until = #{object.validUntil,jdbcType=DATE},
+            timezone = #{object.timeZone,jdbcType=VARCHAR}
+        WHERE user_id = #{object.objectID,jdbcType=VARCHAR}
+    </update>
+
+</mapper>
\ No newline at end of file
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/pom.xml b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/pom.xml
new file mode 100644
index 0000000..2dd28a0
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/pom.xml
@@ -0,0 +1,82 @@
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
+
+    <modelVersion>4.0.0</modelVersion>
+    <groupId>org.glyptodon.guacamole</groupId>
+    <artifactId>guacamole-auth-jdbc-postgresql</artifactId>
+    <packaging>jar</packaging>
+    <name>guacamole-auth-jdbc-postgresql</name>
+    <url>http://guac-dev.org/</url>
+
+    <properties>
+        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+    </properties>
+
+    <parent>
+        <groupId>org.glyptodon.guacamole</groupId>
+        <artifactId>guacamole-auth-jdbc</artifactId>
+        <version>0.9.9</version>
+        <relativePath>../../</relativePath>
+    </parent>
+
+    <build>
+        <plugins>
+
+            <!-- Written for 1.6 -->
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-compiler-plugin</artifactId>
+                <version>3.3</version>
+                <configuration>
+                    <source>1.6</source>
+                    <target>1.6</target>
+                    <compilerArgs>
+                        <arg>-Xlint:all</arg>
+                        <arg>-Werror</arg>
+                    </compilerArgs>
+                    <fork>true</fork>
+                </configuration>
+            </plugin>
+
+            <!-- Copy dependencies prior to packaging -->
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-dependency-plugin</artifactId>
+                <version>2.10</version>
+                <executions>
+                    <execution>
+                        <id>unpack-dependencies</id>
+                        <phase>prepare-package</phase>
+                        <goals>
+                            <goal>unpack-dependencies</goal>
+                        </goals>
+                        <configuration>
+                            <includeScope>runtime</includeScope>
+                            <outputDirectory>${project.build.directory}/classes</outputDirectory>
+                        </configuration>
+                    </execution>
+                </executions>
+            </plugin>
+
+        </plugins>
+    </build>
+
+    <dependencies>
+
+        <!-- Guacamole Extension API -->
+        <dependency>
+            <groupId>org.glyptodon.guacamole</groupId>
+            <artifactId>guacamole-ext</artifactId>
+            <scope>provided</scope>
+        </dependency>
+
+        <!-- Guacamole JDBC Authentication -->
+        <dependency>
+            <groupId>org.glyptodon.guacamole</groupId>
+            <artifactId>guacamole-auth-jdbc-base</artifactId>
+            <version>0.9.9</version>
+        </dependency>
+
+    </dependencies>
+
+</project>
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/schema/001-create-schema.sql b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/schema/001-create-schema.sql
new file mode 100644
index 0000000..b1512d6
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/schema/001-create-schema.sql
@@ -0,0 +1,301 @@
+--
+-- Copyright (C) 2015 Glyptodon LLC
+--
+-- Permission is hereby granted, free of charge, to any person obtaining a copy
+-- of this software and associated documentation files (the "Software"), to deal
+-- in the Software without restriction, including without limitation the rights
+-- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+-- copies of the Software, and to permit persons to whom the Software is
+-- furnished to do so, subject to the following conditions:
+--
+-- The above copyright notice and this permission notice shall be included in
+-- all copies or substantial portions of the Software.
+--
+-- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+-- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+-- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+-- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+-- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+-- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+-- THE SOFTWARE.
+--
+
+--
+-- Connection group types
+--
+
+CREATE TYPE guacamole_connection_group_type AS ENUM(
+    'ORGANIZATIONAL',
+    'BALANCING'
+);
+
+--
+-- Object permission types
+--
+
+CREATE TYPE guacamole_object_permission_type AS ENUM(
+    'READ',
+    'UPDATE',
+    'DELETE',
+    'ADMINISTER'
+);
+
+--
+-- System permission types
+--
+
+CREATE TYPE guacamole_system_permission_type AS ENUM(
+    'CREATE_CONNECTION',
+    'CREATE_CONNECTION_GROUP',
+    'CREATE_USER',
+    'ADMINISTER'
+);
+
+--
+-- Table of connection groups. Each connection group has a name.
+--
+
+CREATE TABLE guacamole_connection_group (
+
+  connection_group_id   serial       NOT NULL,
+  parent_id             integer,
+  connection_group_name varchar(128) NOT NULL,
+  type                  guacamole_connection_group_type
+                        NOT NULL DEFAULT 'ORGANIZATIONAL',
+
+  -- Concurrency limits
+  max_connections          integer,
+  max_connections_per_user integer,
+
+  PRIMARY KEY (connection_group_id),
+
+  CONSTRAINT connection_group_name_parent
+    UNIQUE (connection_group_name, parent_id),
+
+  CONSTRAINT guacamole_connection_group_ibfk_1
+    FOREIGN KEY (parent_id)
+    REFERENCES guacamole_connection_group (connection_group_id)
+    ON DELETE CASCADE
+
+);
+
+CREATE INDEX ON guacamole_connection_group(parent_id);
+
+--
+-- Table of connections. Each connection has a name, protocol, and
+-- associated set of parameters.
+-- A connection may belong to a connection group.
+--
+
+CREATE TABLE guacamole_connection (
+
+  connection_id       serial       NOT NULL,
+  connection_name     varchar(128) NOT NULL,
+  parent_id           integer,
+  protocol            varchar(32)  NOT NULL,
+  
+  -- Concurrency limits
+  max_connections          integer,
+  max_connections_per_user integer,
+
+  PRIMARY KEY (connection_id),
+
+  CONSTRAINT connection_name_parent
+    UNIQUE (connection_name, parent_id),
+
+  CONSTRAINT guacamole_connection_ibfk_1
+    FOREIGN KEY (parent_id)
+    REFERENCES guacamole_connection_group (connection_group_id)
+    ON DELETE CASCADE
+
+);
+
+CREATE INDEX ON guacamole_connection(parent_id);
+
+--
+-- Table of users. Each user has a unique username and a hashed password
+-- with corresponding salt. Although the authentication system will always set
+-- salted passwords, other systems may set unsalted passwords by simply not
+-- providing the salt.
+--
+
+CREATE TABLE guacamole_user (
+
+  user_id       serial       NOT NULL,
+
+  -- Username and optionally-salted password
+  username      varchar(128) NOT NULL,
+  password_hash bytea        NOT NULL,
+  password_salt bytea,
+
+  -- Account disabled/expired status
+  disabled      boolean      NOT NULL DEFAULT FALSE,
+  expired       boolean      NOT NULL DEFAULT FALSE,
+
+  -- Time-based access restriction
+  access_window_start    time,
+  access_window_end      time,
+
+  -- Date-based access restriction
+  valid_from  date,
+  valid_until date,
+
+  -- Timezone used for all date/time comparisons and interpretation
+  timezone varchar(64),
+
+  PRIMARY KEY (user_id),
+
+  CONSTRAINT username
+    UNIQUE (username)
+
+);
+
+--
+-- Table of connection parameters. Each parameter is simply a name/value pair
+-- associated with a connection.
+--
+
+CREATE TABLE guacamole_connection_parameter (
+
+  connection_id   integer       NOT NULL,
+  parameter_name  varchar(128)  NOT NULL,
+  parameter_value varchar(4096) NOT NULL,
+
+  PRIMARY KEY (connection_id,parameter_name),
+
+  CONSTRAINT guacamole_connection_parameter_ibfk_1
+    FOREIGN KEY (connection_id)
+    REFERENCES guacamole_connection (connection_id) ON DELETE CASCADE
+
+);
+
+CREATE INDEX ON guacamole_connection_parameter(connection_id);
+
+--
+-- Table of connection permissions. Each connection permission grants a user
+-- specific access to a connection.
+--
+
+CREATE TABLE guacamole_connection_permission (
+
+  user_id       integer NOT NULL,
+  connection_id integer NOT NULL,
+  permission    guacamole_object_permission_type NOT NULL,
+
+  PRIMARY KEY (user_id,connection_id,permission),
+
+  CONSTRAINT guacamole_connection_permission_ibfk_1
+    FOREIGN KEY (connection_id)
+    REFERENCES guacamole_connection (connection_id) ON DELETE CASCADE,
+
+  CONSTRAINT guacamole_connection_permission_ibfk_2
+    FOREIGN KEY (user_id)
+    REFERENCES guacamole_user (user_id) ON DELETE CASCADE
+
+);
+
+CREATE INDEX ON guacamole_connection_permission(connection_id);
+CREATE INDEX ON guacamole_connection_permission(user_id);
+
+--
+-- Table of connection group permissions. Each group permission grants a user
+-- specific access to a connection group.
+--
+
+CREATE TABLE guacamole_connection_group_permission (
+
+  user_id             integer NOT NULL,
+  connection_group_id integer NOT NULL,
+  permission          guacamole_object_permission_type NOT NULL,
+
+  PRIMARY KEY (user_id,connection_group_id,permission),
+
+  CONSTRAINT guacamole_connection_group_permission_ibfk_1
+    FOREIGN KEY (connection_group_id)
+    REFERENCES guacamole_connection_group (connection_group_id) ON DELETE CASCADE,
+
+  CONSTRAINT guacamole_connection_group_permission_ibfk_2
+    FOREIGN KEY (user_id)
+    REFERENCES guacamole_user (user_id) ON DELETE CASCADE
+
+);
+
+CREATE INDEX ON guacamole_connection_group_permission(connection_group_id);
+CREATE INDEX ON guacamole_connection_group_permission(user_id);
+
+--
+-- Table of system permissions. Each system permission grants a user a
+-- system-level privilege of some kind.
+--
+
+CREATE TABLE guacamole_system_permission (
+
+  user_id    integer NOT NULL,
+  permission guacamole_system_permission_type NOT NULL,
+
+  PRIMARY KEY (user_id,permission),
+
+  CONSTRAINT guacamole_system_permission_ibfk_1
+    FOREIGN KEY (user_id)
+    REFERENCES guacamole_user (user_id) ON DELETE CASCADE
+
+);
+
+CREATE INDEX ON guacamole_system_permission(user_id);
+
+--
+-- Table of user permissions. Each user permission grants a user access to
+-- another user (the "affected" user) for a specific type of operation.
+--
+
+CREATE TABLE guacamole_user_permission (
+
+  user_id          integer NOT NULL,
+  affected_user_id integer NOT NULL,
+  permission       guacamole_object_permission_type NOT NULL,
+
+  PRIMARY KEY (user_id,affected_user_id,permission),
+
+  CONSTRAINT guacamole_user_permission_ibfk_1
+    FOREIGN KEY (affected_user_id)
+    REFERENCES guacamole_user (user_id) ON DELETE CASCADE,
+
+  CONSTRAINT guacamole_user_permission_ibfk_2
+    FOREIGN KEY (user_id)
+    REFERENCES guacamole_user (user_id) ON DELETE CASCADE
+
+);
+
+CREATE INDEX ON guacamole_user_permission(affected_user_id);
+CREATE INDEX ON guacamole_user_permission(user_id);
+
+--
+-- Table of connection history records. Each record defines a specific user's
+-- session, including the connection used, the start time, and the end time
+-- (if any).
+--
+
+CREATE TABLE guacamole_connection_history (
+
+  history_id    serial      NOT NULL,
+  user_id       integer     NOT NULL,
+  connection_id integer     NOT NULL,
+  start_date    timestamptz NOT NULL,
+  end_date      timestamptz DEFAULT NULL,
+
+  PRIMARY KEY (history_id),
+
+  CONSTRAINT guacamole_connection_history_ibfk_1
+    FOREIGN KEY (user_id)
+    REFERENCES guacamole_user (user_id) ON DELETE CASCADE,
+
+  CONSTRAINT guacamole_connection_history_ibfk_2
+    FOREIGN KEY (connection_id)
+    REFERENCES guacamole_connection (connection_id) ON DELETE CASCADE
+
+);
+
+CREATE INDEX ON guacamole_connection_history(user_id);
+CREATE INDEX ON guacamole_connection_history(connection_id);
+CREATE INDEX ON guacamole_connection_history(start_date);
+CREATE INDEX ON guacamole_connection_history(end_date);
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/schema/002-create-admin-user.sql b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/schema/002-create-admin-user.sql
new file mode 100644
index 0000000..16eafbe
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/schema/002-create-admin-user.sql
@@ -0,0 +1,53 @@
+--
+-- Copyright (C) 2015 Glyptodon LLC
+--
+-- Permission is hereby granted, free of charge, to any person obtaining a copy
+-- of this software and associated documentation files (the "Software"), to deal
+-- in the Software without restriction, including without limitation the rights
+-- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+-- copies of the Software, and to permit persons to whom the Software is
+-- furnished to do so, subject to the following conditions:
+--
+-- The above copyright notice and this permission notice shall be included in
+-- all copies or substantial portions of the Software.
+--
+-- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+-- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+-- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+-- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+-- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+-- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+-- THE SOFTWARE.
+--
+
+
+-- Create default user "guacadmin" with password "guacadmin"
+INSERT INTO guacamole_user (username, password_hash, password_salt)
+VALUES ('guacadmin',
+    E'\\xCA458A7D494E3BE824F5E1E175A1556C0F8EEF2C2D7DF3633BEC4A29C4411960',  -- 'guacadmin'
+    E'\\xFE24ADC5E11E2B25288D1704ABE67A79E342ECC26064CE69C5B3177795A82264');
+
+-- Grant this user all system permissions
+INSERT INTO guacamole_system_permission
+SELECT user_id, permission::guacamole_system_permission_type
+FROM (
+    VALUES
+        ('guacadmin', 'CREATE_CONNECTION'),
+        ('guacadmin', 'CREATE_CONNECTION_GROUP'),
+        ('guacadmin', 'CREATE_USER'),
+        ('guacadmin', 'ADMINISTER')
+) permissions (username, permission)
+JOIN guacamole_user ON permissions.username = guacamole_user.username;
+
+-- Grant admin permission to read/update/administer self
+INSERT INTO guacamole_user_permission
+SELECT guacamole_user.user_id, affected.user_id, permission::guacamole_object_permission_type
+FROM (
+    VALUES
+        ('guacadmin', 'guacadmin', 'READ'),
+        ('guacadmin', 'guacadmin', 'UPDATE'),
+        ('guacadmin', 'guacadmin', 'ADMINISTER')
+) permissions (username, affected_username, permission)
+JOIN guacamole_user          ON permissions.username = guacamole_user.username
+JOIN guacamole_user affected ON permissions.affected_username = affected.username;
+
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/schema/upgrade/upgrade-pre-0.9.7.sql b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/schema/upgrade/upgrade-pre-0.9.7.sql
new file mode 100644
index 0000000..0853e90
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/schema/upgrade/upgrade-pre-0.9.7.sql
@@ -0,0 +1,34 @@
+--
+-- Copyright (C) 2015 Glyptodon LLC
+--
+-- Permission is hereby granted, free of charge, to any person obtaining a copy
+-- of this software and associated documentation files (the "Software"), to deal
+-- in the Software without restriction, including without limitation the rights
+-- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+-- copies of the Software, and to permit persons to whom the Software is
+-- furnished to do so, subject to the following conditions:
+--
+-- The above copyright notice and this permission notice shall be included in
+-- all copies or substantial portions of the Software.
+--
+-- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+-- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+-- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+-- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+-- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+-- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+-- THE SOFTWARE.
+--
+
+--
+-- Add per-user disable flag
+--
+
+ALTER TABLE guacamole_user ADD COLUMN disabled boolean NOT NULL DEFAULT FALSE;
+
+--
+-- Add per-user password expiration flag
+--
+
+ALTER TABLE guacamole_user ADD COLUMN expired boolean NOT NULL DEFAULT FALSE;
+
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/schema/upgrade/upgrade-pre-0.9.8.sql b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/schema/upgrade/upgrade-pre-0.9.8.sql
new file mode 100644
index 0000000..da4cff4
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/schema/upgrade/upgrade-pre-0.9.8.sql
@@ -0,0 +1,55 @@
+--
+-- Copyright (C) 2015 Glyptodon LLC
+--
+-- Permission is hereby granted, free of charge, to any person obtaining a copy
+-- of this software and associated documentation files (the "Software"), to deal
+-- in the Software without restriction, including without limitation the rights
+-- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+-- copies of the Software, and to permit persons to whom the Software is
+-- furnished to do so, subject to the following conditions:
+--
+-- The above copyright notice and this permission notice shall be included in
+-- all copies or substantial portions of the Software.
+--
+-- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+-- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+-- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+-- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+-- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+-- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+-- THE SOFTWARE.
+--
+
+--
+-- Add per-user time-based access restrictions.
+--
+
+ALTER TABLE guacamole_user ADD COLUMN access_window_start    time;
+ALTER TABLE guacamole_user ADD COLUMN access_window_end      time;
+
+--
+-- Add per-user date-based account validity restrictions.
+--
+
+ALTER TABLE guacamole_user ADD COLUMN valid_from  date;
+ALTER TABLE guacamole_user ADD COLUMN valid_until date;
+
+--
+-- Add per-user timezone for sake of time comparisons/interpretation.
+--
+
+ALTER TABLE guacamole_user ADD COLUMN timezone varchar(64);
+
+--
+-- Add connection concurrency limits
+--
+
+ALTER TABLE guacamole_connection ADD COLUMN max_connections          integer;
+ALTER TABLE guacamole_connection ADD COLUMN max_connections_per_user integer;
+
+--
+-- Add connection group concurrency limits
+--
+
+ALTER TABLE guacamole_connection_group ADD COLUMN max_connections          integer;
+ALTER TABLE guacamole_connection_group ADD COLUMN max_connections_per_user integer;
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/schema/upgrade/upgrade-pre-0.9.9.sql b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/schema/upgrade/upgrade-pre-0.9.9.sql
new file mode 100644
index 0000000..f4f58b5
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/schema/upgrade/upgrade-pre-0.9.9.sql
@@ -0,0 +1,29 @@
+--
+-- Copyright (C) 2015 Glyptodon LLC
+--
+-- Permission is hereby granted, free of charge, to any person obtaining a copy
+-- of this software and associated documentation files (the "Software"), to deal
+-- in the Software without restriction, including without limitation the rights
+-- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+-- copies of the Software, and to permit persons to whom the Software is
+-- furnished to do so, subject to the following conditions:
+--
+-- The above copyright notice and this permission notice shall be included in
+-- all copies or substantial portions of the Software.
+--
+-- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+-- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+-- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+-- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+-- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+-- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+-- THE SOFTWARE.
+--
+
+--
+-- Ensure history entry start/end dates are indexed.
+--
+
+CREATE INDEX ON guacamole_connection_history(start_date);
+CREATE INDEX ON guacamole_connection_history(end_date);
+CREATE INDEX guacamole_connection_history_search_index ON guacamole_connection_history(start_date, connection_id, user_id);
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/java/org/glyptodon/guacamole/auth/postgresql/PostgreSQLAuthenticationProvider.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/java/org/glyptodon/guacamole/auth/postgresql/PostgreSQLAuthenticationProvider.java
new file mode 100644
index 0000000..602784b
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/java/org/glyptodon/guacamole/auth/postgresql/PostgreSQLAuthenticationProvider.java
@@ -0,0 +1,128 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.postgresql;
+
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.net.auth.AuthenticationProvider;
+import org.glyptodon.guacamole.net.auth.Credentials;
+import org.glyptodon.guacamole.net.auth.UserContext;
+import org.glyptodon.guacamole.auth.jdbc.JDBCAuthenticationProviderModule;
+import org.glyptodon.guacamole.auth.jdbc.JDBCEnvironment;
+import org.glyptodon.guacamole.auth.jdbc.user.AuthenticationProviderService;
+import org.glyptodon.guacamole.net.auth.AuthenticatedUser;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Provides a PostgreSQL-based implementation of the AuthenticationProvider
+ * functionality.
+ *
+ * @author James Muehlner
+ * @author Michael Jumper
+ */
+public class PostgreSQLAuthenticationProvider implements AuthenticationProvider {
+
+    /**
+     * Logger for this class.
+     */
+    private static final Logger logger = LoggerFactory.getLogger(PostgreSQLAuthenticationProvider.class);
+
+    /**
+     * Injector which will manage the object graph of this authentication
+     * provider.
+     */
+    private final Injector injector;
+
+    /**
+     * Creates a new PostgreSQLAuthenticationProvider that reads and writes
+     * authentication data to a PostgreSQL database defined by properties in
+     * guacamole.properties.
+     *
+     * @throws GuacamoleException
+     *     If a required property is missing, or an error occurs while parsing
+     *     a property.
+     */
+    public PostgreSQLAuthenticationProvider() throws GuacamoleException {
+
+        // Get local environment
+        PostgreSQLEnvironment environment = new PostgreSQLEnvironment();
+
+        // Set up Guice injector.
+        injector = Guice.createInjector(
+
+            // Configure PostgreSQL-specific authentication
+            new PostgreSQLAuthenticationProviderModule(environment),
+
+            // Configure JDBC authentication core
+            new JDBCAuthenticationProviderModule(this, environment)
+
+        );
+
+    }
+
+    @Override
+    public String getIdentifier() {
+        return "postgresql";
+    }
+
+    @Override
+    public AuthenticatedUser authenticateUser(Credentials credentials)
+            throws GuacamoleException {
+
+        // Create AuthenticatedUser based on credentials, if valid
+        AuthenticationProviderService authProviderService = injector.getInstance(AuthenticationProviderService.class);
+        return authProviderService.authenticateUser(this, credentials);
+
+    }
+
+    @Override
+    public AuthenticatedUser updateAuthenticatedUser(AuthenticatedUser authenticatedUser,
+            Credentials credentials) throws GuacamoleException {
+
+        // No need to update authenticated users
+        return authenticatedUser;
+
+    }
+
+    @Override
+    public UserContext getUserContext(AuthenticatedUser authenticatedUser)
+            throws GuacamoleException {
+
+        // Create UserContext based on credentials, if valid
+        AuthenticationProviderService authProviderService = injector.getInstance(AuthenticationProviderService.class);
+        return authProviderService.getUserContext(authenticatedUser);
+
+    }
+
+    @Override
+    public UserContext updateUserContext(UserContext context,
+            AuthenticatedUser authenticatedUser) throws GuacamoleException {
+
+        // No need to update the context
+        return context;
+
+    }
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/java/org/glyptodon/guacamole/auth/postgresql/PostgreSQLAuthenticationProviderModule.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/java/org/glyptodon/guacamole/auth/postgresql/PostgreSQLAuthenticationProviderModule.java
new file mode 100644
index 0000000..efd7b9f
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/java/org/glyptodon/guacamole/auth/postgresql/PostgreSQLAuthenticationProviderModule.java
@@ -0,0 +1,98 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.postgresql;
+
+import com.google.inject.Binder;
+import com.google.inject.Module;
+import com.google.inject.name.Names;
+import java.util.Properties;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.mybatis.guice.datasource.helper.JdbcHelper;
+
+/**
+ * Guice module which configures PostgreSQL-specific injections.
+ *
+ * @author James Muehlner
+ * @author Michael Jumper
+ */
+public class PostgreSQLAuthenticationProviderModule implements Module {
+
+    /**
+     * MyBatis-specific configuration properties.
+     */
+    private final Properties myBatisProperties = new Properties();
+
+    /**
+     * PostgreSQL-specific driver configuration properties.
+     */
+    private final Properties driverProperties = new Properties();
+    
+    /**
+     * Creates a new PostgreSQL authentication provider module that configures
+     * driver and MyBatis properties using the given environment.
+     *
+     * @param environment
+     *     The environment to use when configuring MyBatis and the underlying
+     *     JDBC driver.
+     *
+     * @throws GuacamoleException
+     *     If a required property is missing, or an error occurs while parsing
+     *     a property.
+     */
+    public PostgreSQLAuthenticationProviderModule(PostgreSQLEnvironment environment)
+            throws GuacamoleException {
+
+        // Set the PostgreSQL-specific properties for MyBatis.
+        myBatisProperties.setProperty("mybatis.environment.id", "guacamole");
+        myBatisProperties.setProperty("JDBC.host", environment.getPostgreSQLHostname());
+        myBatisProperties.setProperty("JDBC.port", String.valueOf(environment.getPostgreSQLPort()));
+        myBatisProperties.setProperty("JDBC.schema", environment.getPostgreSQLDatabase());
+        myBatisProperties.setProperty("JDBC.username", environment.getPostgreSQLUsername());
+        myBatisProperties.setProperty("JDBC.password", environment.getPostgreSQLPassword());
+        myBatisProperties.setProperty("JDBC.autoCommit", "false");
+        myBatisProperties.setProperty("mybatis.pooled.pingEnabled", "true");
+        myBatisProperties.setProperty("mybatis.pooled.pingQuery", "SELECT 1");
+
+        // Use UTF-8 in database
+        driverProperties.setProperty("characterEncoding","UTF-8");
+
+
+    }
+
+    @Override
+    public void configure(Binder binder) {
+
+        // Bind PostgreSQL-specific properties
+        JdbcHelper.PostgreSQL.configure(binder);
+        
+        // Bind MyBatis properties
+        Names.bindProperties(binder, myBatisProperties);
+
+        // Bing JDBC driver properties
+        binder.bind(Properties.class)
+            .annotatedWith(Names.named("JDBC.driverProperties"))
+            .toInstance(driverProperties);
+
+    }
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/java/org/glyptodon/guacamole/auth/postgresql/PostgreSQLEnvironment.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/java/org/glyptodon/guacamole/auth/postgresql/PostgreSQLEnvironment.java
new file mode 100644
index 0000000..83c139e
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/java/org/glyptodon/guacamole/auth/postgresql/PostgreSQLEnvironment.java
@@ -0,0 +1,278 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.postgresql;
+
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.auth.jdbc.JDBCEnvironment;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * A PostgreSQL-specific implementation of JDBCEnvironment provides database
+ * properties specifically for PostgreSQL.
+ *
+ * @author Michael Jumper
+ */
+public class PostgreSQLEnvironment extends JDBCEnvironment {
+
+    /**
+     * Logger for this class.
+     */
+    private static final Logger logger = LoggerFactory.getLogger(PostgreSQLEnvironment.class);
+
+    /**
+     * The default host to connect to, if POSTGRESQL_HOSTNAME is not specified.
+     */
+    private static final String DEFAULT_HOSTNAME = "localhost";
+
+    /**
+     * The default port to connect to, if POSTGRESQL_PORT is not specified.
+     */
+    private static final int DEFAULT_PORT = 5432;
+
+    /**
+     * The default value for the default maximum number of connections to be
+     * allowed per user to any one connection. Note that, as long as the
+     * legacy "disallow duplicate" and "disallow simultaneous" properties are
+     * still supported, these cannot be constants, as the legacy properties
+     * dictate the values that should be used in the absence of the correct
+     * properties.
+     */
+    private int DEFAULT_MAX_CONNECTIONS_PER_USER = 1;
+
+    /**
+     * The default value for the default maximum number of connections to be
+     * allowed per user to any one connection group. Note that, as long as the
+     * legacy "disallow duplicate" and "disallow simultaneous" properties are
+     * still supported, these cannot be constants, as the legacy properties
+     * dictate the values that should be used in the absence of the correct
+     * properties.
+     */
+    private int DEFAULT_MAX_GROUP_CONNECTIONS_PER_USER = 1;
+
+    /**
+     * The default value for the default maximum number of connections to be
+     * allowed to any one connection. Note that, as long as the legacy
+     * "disallow duplicate" and "disallow simultaneous" properties are still
+     * supported, these cannot be constants, as the legacy properties dictate
+     * the values that should be used in the absence of the correct properties.
+     */
+    private int DEFAULT_MAX_CONNECTIONS = 0;
+
+    /**
+     * The default value for the default maximum number of connections to be
+     * allowed to any one connection group. Note that, as long as the legacy
+     * "disallow duplicate" and "disallow simultaneous" properties are still
+     * supported, these cannot be constants, as the legacy properties dictate
+     * the values that should be used in the absence of the correct properties.
+     */
+    private int DEFAULT_MAX_GROUP_CONNECTIONS = 0;
+
+    /**
+     * Constructs a new PostgreSQLEnvironment, providing access to PostgreSQL-specific
+     * configuration options.
+     * 
+     * @throws GuacamoleException 
+     *     If an error occurs while setting up the underlying JDBCEnvironment
+     *     or while parsing legacy PostgreSQL configuration options.
+     */
+    public PostgreSQLEnvironment() throws GuacamoleException {
+
+        // Init underlying JDBC environment
+        super();
+
+        // Read legacy concurrency-related property
+        Boolean disallowSimultaneous = getProperty(PostgreSQLGuacamoleProperties.POSTGRESQL_DISALLOW_SIMULTANEOUS_CONNECTIONS);
+        Boolean disallowDuplicate    = getProperty(PostgreSQLGuacamoleProperties.POSTGRESQL_DISALLOW_DUPLICATE_CONNECTIONS);
+
+        // Legacy "simultaneous" property dictates only the maximum number of
+        // connections per connection
+        if (disallowSimultaneous != null) {
+
+            // Translate legacy property
+            if (disallowSimultaneous) {
+                DEFAULT_MAX_CONNECTIONS       = 1;
+                DEFAULT_MAX_GROUP_CONNECTIONS = 0;
+            }
+            else {
+                DEFAULT_MAX_CONNECTIONS       = 0;
+                DEFAULT_MAX_GROUP_CONNECTIONS = 0;
+            }
+
+            // Warn of deprecation
+            logger.warn("The \"{}\" property is deprecated. Use \"{}\" and \"{}\" instead.",
+                    PostgreSQLGuacamoleProperties.POSTGRESQL_DISALLOW_SIMULTANEOUS_CONNECTIONS.getName(),
+                    PostgreSQLGuacamoleProperties.POSTGRESQL_DEFAULT_MAX_CONNECTIONS.getName(),
+                    PostgreSQLGuacamoleProperties.POSTGRESQL_DEFAULT_MAX_GROUP_CONNECTIONS.getName());
+
+            // Inform of new equivalent
+            logger.info("To achieve the same result of setting \"{}\" to \"{}\", set \"{}\" to \"{}\" and \"{}\" to \"{}\".",
+                    PostgreSQLGuacamoleProperties.POSTGRESQL_DISALLOW_SIMULTANEOUS_CONNECTIONS.getName(), disallowSimultaneous,
+                    PostgreSQLGuacamoleProperties.POSTGRESQL_DEFAULT_MAX_CONNECTIONS.getName(),           DEFAULT_MAX_CONNECTIONS,
+                    PostgreSQLGuacamoleProperties.POSTGRESQL_DEFAULT_MAX_GROUP_CONNECTIONS.getName(),     DEFAULT_MAX_GROUP_CONNECTIONS);
+
+        }
+
+        // Legacy "duplicate" property dictates whether connections and groups
+        // may be used concurrently only by different users
+        if (disallowDuplicate != null) {
+
+            // Translate legacy property
+            if (disallowDuplicate) {
+                DEFAULT_MAX_CONNECTIONS_PER_USER       = 1;
+                DEFAULT_MAX_GROUP_CONNECTIONS_PER_USER = 1;
+            }
+            else {
+                DEFAULT_MAX_CONNECTIONS_PER_USER       = 0;
+                DEFAULT_MAX_GROUP_CONNECTIONS_PER_USER = 0;
+            }
+
+            // Warn of deprecation
+            logger.warn("The \"{}\" property is deprecated. Use \"{}\" and \"{}\" instead.",
+                    PostgreSQLGuacamoleProperties.POSTGRESQL_DISALLOW_DUPLICATE_CONNECTIONS.getName(),
+                    PostgreSQLGuacamoleProperties.POSTGRESQL_DEFAULT_MAX_CONNECTIONS_PER_USER.getName(),
+                    PostgreSQLGuacamoleProperties.POSTGRESQL_DEFAULT_MAX_GROUP_CONNECTIONS.getName());
+
+            // Inform of new equivalent
+            logger.info("To achieve the same result of setting \"{}\" to \"{}\", set \"{}\" to \"{}\" and \"{}\" to \"{}\".",
+                    PostgreSQLGuacamoleProperties.POSTGRESQL_DISALLOW_DUPLICATE_CONNECTIONS.getName(),         disallowDuplicate,
+                    PostgreSQLGuacamoleProperties.POSTGRESQL_DEFAULT_MAX_CONNECTIONS_PER_USER.getName(),       DEFAULT_MAX_CONNECTIONS_PER_USER,
+                    PostgreSQLGuacamoleProperties.POSTGRESQL_DEFAULT_MAX_GROUP_CONNECTIONS_PER_USER.getName(), DEFAULT_MAX_GROUP_CONNECTIONS_PER_USER);
+
+        }
+
+    }
+
+    @Override
+    public int getDefaultMaxConnections() throws GuacamoleException {
+        return getProperty(
+            PostgreSQLGuacamoleProperties.POSTGRESQL_DEFAULT_MAX_CONNECTIONS,
+            DEFAULT_MAX_CONNECTIONS
+        );
+    }
+
+    @Override
+    public int getDefaultMaxGroupConnections() throws GuacamoleException {
+        return getProperty(
+            PostgreSQLGuacamoleProperties.POSTGRESQL_DEFAULT_MAX_GROUP_CONNECTIONS,
+            DEFAULT_MAX_GROUP_CONNECTIONS
+        );
+    }
+
+    @Override
+    public int getDefaultMaxConnectionsPerUser() throws GuacamoleException {
+        return getProperty(
+            PostgreSQLGuacamoleProperties.POSTGRESQL_DEFAULT_MAX_CONNECTIONS_PER_USER,
+            DEFAULT_MAX_CONNECTIONS_PER_USER
+        );
+    }
+
+    @Override
+    public int getDefaultMaxGroupConnectionsPerUser() throws GuacamoleException {
+        return getProperty(
+            PostgreSQLGuacamoleProperties.POSTGRESQL_DEFAULT_MAX_GROUP_CONNECTIONS_PER_USER,
+            DEFAULT_MAX_GROUP_CONNECTIONS_PER_USER
+        );
+    }
+
+    /**
+     * Returns the hostname of the PostgreSQL server hosting the Guacamole
+     * authentication tables. If unspecified, this will be "localhost".
+     * 
+     * @return
+     *     The URL of the PostgreSQL server.
+     *
+     * @throws GuacamoleException 
+     *     If an error occurs while retrieving the property value.
+     */
+    public String getPostgreSQLHostname() throws GuacamoleException {
+        return getProperty(
+            PostgreSQLGuacamoleProperties.POSTGRESQL_HOSTNAME,
+            DEFAULT_HOSTNAME
+        );
+    }
+    
+    /**
+     * Returns the port number of the PostgreSQL server hosting the Guacamole
+     * authentication tables. If unspecified, this will be the default
+     * PostgreSQL port of 5432.
+     * 
+     * @return
+     *     The port number of the PostgreSQL server.
+     *
+     * @throws GuacamoleException 
+     *     If an error occurs while retrieving the property value.
+     */
+    public int getPostgreSQLPort() throws GuacamoleException {
+        return getProperty(
+            PostgreSQLGuacamoleProperties.POSTGRESQL_PORT,
+            DEFAULT_PORT
+        );
+    }
+    
+    /**
+     * Returns the name of the PostgreSQL database containing the Guacamole
+     * authentication tables.
+     * 
+     * @return
+     *     The name of the PostgreSQL database.
+     *
+     * @throws GuacamoleException 
+     *     If an error occurs while retrieving the property value, or if the
+     *     value was not set, as this property is required.
+     */
+    public String getPostgreSQLDatabase() throws GuacamoleException {
+        return getRequiredProperty(PostgreSQLGuacamoleProperties.POSTGRESQL_DATABASE);
+    }
+    
+    /**
+     * Returns the username that should be used when authenticating with the
+     * PostgreSQL database containing the Guacamole authentication tables.
+     * 
+     * @return
+     *     The username for the PostgreSQL database.
+     *
+     * @throws GuacamoleException 
+     *     If an error occurs while retrieving the property value, or if the
+     *     value was not set, as this property is required.
+     */
+    public String getPostgreSQLUsername() throws GuacamoleException {
+        return getRequiredProperty(PostgreSQLGuacamoleProperties.POSTGRESQL_USERNAME);
+    }
+    
+    /**
+     * Returns the password that should be used when authenticating with the
+     * PostgreSQL database containing the Guacamole authentication tables.
+     * 
+     * @return
+     *     The password for the PostgreSQL database.
+     *
+     * @throws GuacamoleException 
+     *     If an error occurs while retrieving the property value, or if the
+     *     value was not set, as this property is required.
+     */
+    public String getPostgreSQLPassword() throws GuacamoleException {
+        return getRequiredProperty(PostgreSQLGuacamoleProperties.POSTGRESQL_PASSWORD);
+    }
+    
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/java/org/glyptodon/guacamole/auth/postgresql/PostgreSQLGuacamoleProperties.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/java/org/glyptodon/guacamole/auth/postgresql/PostgreSQLGuacamoleProperties.java
new file mode 100644
index 0000000..cf41038
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/java/org/glyptodon/guacamole/auth/postgresql/PostgreSQLGuacamoleProperties.java
@@ -0,0 +1,180 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.postgresql;
+
+import org.glyptodon.guacamole.properties.BooleanGuacamoleProperty;
+import org.glyptodon.guacamole.properties.IntegerGuacamoleProperty;
+import org.glyptodon.guacamole.properties.StringGuacamoleProperty;
+
+/**
+ * Properties used by the PostgreSQL Authentication plugin.
+ *
+ * @author James Muehlner
+ * @author Michael Jumper
+ */
+public class PostgreSQLGuacamoleProperties {
+
+    /**
+     * This class should not be instantiated.
+     */
+    private PostgreSQLGuacamoleProperties() {}
+
+    /**
+     * The URL of the PostgreSQL server hosting the Guacamole authentication tables.
+     */
+    public static final StringGuacamoleProperty POSTGRESQL_HOSTNAME =
+            new StringGuacamoleProperty() {
+
+        @Override
+        public String getName() { return "postgresql-hostname"; }
+
+    };
+
+    /**
+     * The port of the PostgreSQL server hosting the Guacamole authentication
+     * tables.
+     */
+    public static final IntegerGuacamoleProperty POSTGRESQL_PORT =
+            new IntegerGuacamoleProperty() {
+
+        @Override
+        public String getName() { return "postgresql-port"; }
+
+    };
+
+    /**
+     * The name of the PostgreSQL database containing the Guacamole
+     * authentication tables.
+     */
+    public static final StringGuacamoleProperty POSTGRESQL_DATABASE =
+            new StringGuacamoleProperty() {
+
+        @Override
+        public String getName() { return "postgresql-database"; }
+
+    };
+
+    /**
+     * The username used to authenticate to the PostgreSQL database containing
+     * the Guacamole authentication tables.
+     */
+    public static final StringGuacamoleProperty POSTGRESQL_USERNAME =
+            new StringGuacamoleProperty() {
+
+        @Override
+        public String getName() { return "postgresql-username"; }
+
+    };
+
+    /**
+     * The password used to authenticate to the PostgreSQL database containing
+     * the Guacamole authentication tables.
+     */
+    public static final StringGuacamoleProperty POSTGRESQL_PASSWORD =
+            new StringGuacamoleProperty() {
+
+        @Override
+        public String getName() { return "postgresql-password"; }
+
+    };
+
+    /**
+     * Whether or not multiple users accessing the same connection at the same
+     * time should be disallowed.
+     */
+    public static final BooleanGuacamoleProperty
+            POSTGRESQL_DISALLOW_SIMULTANEOUS_CONNECTIONS =
+            new BooleanGuacamoleProperty() {
+
+        @Override
+        public String getName() { return "postgresql-disallow-simultaneous-connections"; }
+
+    };
+
+    /**
+     * Whether or not the same user accessing the same connection or connection
+     * group at the same time should be disallowed.
+     */
+    public static final BooleanGuacamoleProperty
+            POSTGRESQL_DISALLOW_DUPLICATE_CONNECTIONS =
+            new BooleanGuacamoleProperty() {
+
+        @Override
+        public String getName() { return "postgresql-disallow-duplicate-connections"; }
+
+    };
+
+    /**
+     * The maximum number of concurrent connections to allow to any one
+     * connection. Zero denotes unlimited.
+     */
+    public static final IntegerGuacamoleProperty
+            POSTGRESQL_DEFAULT_MAX_CONNECTIONS =
+            new IntegerGuacamoleProperty() {
+
+        @Override
+        public String getName() { return "postgresql-default-max-connections"; }
+
+    };
+
+    /**
+     * The maximum number of concurrent connections to allow to any one
+     * connection group. Zero denotes unlimited.
+     */
+    public static final IntegerGuacamoleProperty
+            POSTGRESQL_DEFAULT_MAX_GROUP_CONNECTIONS =
+            new IntegerGuacamoleProperty() {
+
+        @Override
+        public String getName() { return "postgresql-default-max-group-connections"; }
+
+    };
+
+    /**
+     * The maximum number of concurrent connections to allow to any one
+     * connection by an individual user. Zero denotes unlimited.
+     */
+    public static final IntegerGuacamoleProperty
+            POSTGRESQL_DEFAULT_MAX_CONNECTIONS_PER_USER =
+            new IntegerGuacamoleProperty() {
+
+        @Override
+        public String getName() { return "postgresql-default-max-connections-per-user"; }
+
+    };
+
+    /**
+     * The maximum number of concurrent connections to allow to any one
+     * connection group by an individual user. Zero denotes
+     * unlimited.
+     */
+    public static final IntegerGuacamoleProperty
+            POSTGRESQL_DEFAULT_MAX_GROUP_CONNECTIONS_PER_USER =
+            new IntegerGuacamoleProperty() {
+
+        @Override
+        public String getName() { return "postgresql-default-max-group-connections-per-user"; }
+
+    };
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/java/org/glyptodon/guacamole/auth/postgresql/package-info.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/java/org/glyptodon/guacamole/auth/postgresql/package-info.java
new file mode 100644
index 0000000..1a939e1
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/java/org/glyptodon/guacamole/auth/postgresql/package-info.java
@@ -0,0 +1,26 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * The PostgreSQL authentication provider.
+ */
+package org.glyptodon.guacamole.auth.postgresql;
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/resources/guac-manifest.json b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/resources/guac-manifest.json
new file mode 100644
index 0000000..77f1f0b
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/resources/guac-manifest.json
@@ -0,0 +1,19 @@
+{
+
+    "guacamoleVersion" : "0.9.9",
+
+    "name"      : "PostgreSQL Authentication",
+    "namespace" : "guac-postgresql",
+
+    "authProviders" : [
+        "org.glyptodon.guacamole.auth.postgresql.PostgreSQLAuthenticationProvider"
+    ],
+
+    "translations" : [
+        "translations/en.json",
+        "translations/fr.json",
+        "translations/ru.json"
+    ]
+
+}
+
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/resources/org/glyptodon/guacamole/auth/jdbc/connection/ConnectionMapper.xml b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/resources/org/glyptodon/guacamole/auth/jdbc/connection/ConnectionMapper.xml
new file mode 100644
index 0000000..7bc96e4
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/resources/org/glyptodon/guacamole/auth/jdbc/connection/ConnectionMapper.xml
@@ -0,0 +1,172 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+    "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
+
+<!--
+   Copyright (C) 2015 Glyptodon LLC
+
+   Permission is hereby granted, free of charge, to any person obtaining a copy
+   of this software and associated documentation files (the "Software"), to deal
+   in the Software without restriction, including without limitation the rights
+   to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+   copies of the Software, and to permit persons to whom the Software is
+   furnished to do so, subject to the following conditions:
+
+   The above copyright notice and this permission notice shall be included in
+   all copies or substantial portions of the Software.
+
+   THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+   IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+   FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+   AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+   LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+   OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+   THE SOFTWARE.
+-->
+
+<mapper namespace="org.glyptodon.guacamole.auth.jdbc.connection.ConnectionMapper" >
+
+    <!-- Result mapper for connection objects -->
+    <resultMap id="ConnectionResultMap" type="org.glyptodon.guacamole.auth.jdbc.connection.ConnectionModel" >
+        <id     column="connection_id"            property="objectID"              jdbcType="INTEGER"/>
+        <result column="connection_name"          property="name"                  jdbcType="VARCHAR"/>
+        <result column="parent_id"                property="parentIdentifier"      jdbcType="INTEGER"/>
+        <result column="protocol"                 property="protocol"              jdbcType="VARCHAR"/>
+        <result column="max_connections"          property="maxConnections"        jdbcType="INTEGER"/>
+        <result column="max_connections_per_user" property="maxConnectionsPerUser" jdbcType="INTEGER"/>
+    </resultMap>
+
+    <!-- Select all connection identifiers -->
+    <select id="selectIdentifiers" resultType="string">
+        SELECT connection_id 
+        FROM guacamole_connection
+    </select>
+
+    <!-- Select identifiers of all readable connections -->
+    <select id="selectReadableIdentifiers" resultType="string">
+        SELECT connection_id
+        FROM guacamole_connection_permission
+        WHERE
+            user_id = #{user.objectID,jdbcType=INTEGER}
+            AND permission = 'READ'
+    </select>
+
+    <!-- Select all connection identifiers within a particular connection group -->
+    <select id="selectIdentifiersWithin" resultType="string">
+        SELECT connection_id 
+        FROM guacamole_connection
+        WHERE
+            <if test="parentIdentifier != null">parent_id = #{parentIdentifier,jdbcType=INTEGER}::integer</if>
+            <if test="parentIdentifier == null">parent_id IS NULL</if>
+    </select>
+
+    <!-- Select identifiers of all readable connections within a particular connection group -->
+    <select id="selectReadableIdentifiersWithin" resultType="string">
+        SELECT guacamole_connection.connection_id
+        FROM guacamole_connection
+        JOIN guacamole_connection_permission ON guacamole_connection_permission.connection_id = guacamole_connection.connection_id
+        WHERE
+            <if test="parentIdentifier != null">parent_id = #{parentIdentifier,jdbcType=INTEGER}::integer</if>
+            <if test="parentIdentifier == null">parent_id IS NULL</if>
+            AND user_id = #{user.objectID,jdbcType=INTEGER}
+            AND permission = 'READ'
+    </select>
+
+    <!-- Select multiple connections by identifier -->
+    <select id="select" resultMap="ConnectionResultMap">
+
+        SELECT
+            connection_id,
+            connection_name,
+            parent_id,
+            protocol,
+            max_connections,
+            max_connections_per_user
+        FROM guacamole_connection
+        WHERE connection_id IN
+            <foreach collection="identifiers" item="identifier"
+                     open="(" separator="," close=")">
+                #{identifier,jdbcType=INTEGER}::integer
+            </foreach>
+
+    </select>
+
+    <!-- Select multiple connections by identifier only if readable -->
+    <select id="selectReadable" resultMap="ConnectionResultMap">
+
+        SELECT
+            guacamole_connection.connection_id,
+            connection_name,
+            parent_id,
+            protocol,
+            max_connections,
+            max_connections_per_user
+        FROM guacamole_connection
+        JOIN guacamole_connection_permission ON guacamole_connection_permission.connection_id = guacamole_connection.connection_id
+        WHERE guacamole_connection.connection_id IN
+            <foreach collection="identifiers" item="identifier"
+                     open="(" separator="," close=")">
+                #{identifier,jdbcType=INTEGER}::integer
+            </foreach>
+            AND user_id = #{user.objectID,jdbcType=INTEGER}
+            AND permission = 'READ'
+
+    </select>
+
+    <!-- Select single connection by name -->
+    <select id="selectOneByName" resultMap="ConnectionResultMap">
+
+        SELECT
+            connection_id,
+            connection_name,
+            parent_id,
+            protocol,
+            max_connections,
+            max_connections_per_user
+        FROM guacamole_connection
+        WHERE 
+            <if test="parentIdentifier != null">parent_id = #{parentIdentifier,jdbcType=INTEGER}::integer</if>
+            <if test="parentIdentifier == null">parent_id IS NULL</if>
+            AND connection_name = #{name,jdbcType=VARCHAR}
+
+    </select>
+
+    <!-- Delete single connection by identifier -->
+    <delete id="delete">
+        DELETE FROM guacamole_connection
+        WHERE connection_id = #{identifier,jdbcType=INTEGER}::integer
+    </delete>
+
+    <!-- Insert single connection -->
+    <insert id="insert" useGeneratedKeys="true" keyProperty="object.objectID"
+            parameterType="org.glyptodon.guacamole.auth.jdbc.connection.ConnectionModel">
+
+        INSERT INTO guacamole_connection (
+            connection_name,
+            parent_id,
+            protocol,
+            max_connections,
+            max_connections_per_user
+        )
+        VALUES (
+            #{object.name,jdbcType=VARCHAR},
+            #{object.parentIdentifier,jdbcType=INTEGER}::integer,
+            #{object.protocol,jdbcType=VARCHAR},
+            #{object.maxConnections,jdbcType=INTEGER},
+            #{object.maxConnectionsPerUser,jdbcType=INTEGER}
+        )
+
+    </insert>
+
+    <!-- Update single connection -->
+    <update id="update" parameterType="org.glyptodon.guacamole.auth.jdbc.connection.ConnectionModel">
+        UPDATE guacamole_connection
+        SET connection_name          = #{object.name,jdbcType=VARCHAR},
+            parent_id                = #{object.parentIdentifier,jdbcType=INTEGER}::integer,
+            protocol                 = #{object.protocol,jdbcType=VARCHAR},
+            max_connections          = #{object.maxConnections,jdbcType=INTEGER},
+            max_connections_per_user = #{object.maxConnectionsPerUser,jdbcType=INTEGER}
+        WHERE connection_id = #{object.objectID,jdbcType=INTEGER}::integer
+    </update>
+
+</mapper>
\ No newline at end of file
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/resources/org/glyptodon/guacamole/auth/jdbc/connection/ConnectionRecordMapper.xml b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/resources/org/glyptodon/guacamole/auth/jdbc/connection/ConnectionRecordMapper.xml
new file mode 100644
index 0000000..87dbee4
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/resources/org/glyptodon/guacamole/auth/jdbc/connection/ConnectionRecordMapper.xml
@@ -0,0 +1,200 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+    "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
+
+<!--
+   Copyright (C) 2015 Glyptodon LLC
+
+   Permission is hereby granted, free of charge, to any person obtaining a copy
+   of this software and associated documentation files (the "Software"), to deal
+   in the Software without restriction, including without limitation the rights
+   to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+   copies of the Software, and to permit persons to whom the Software is
+   furnished to do so, subject to the following conditions:
+
+   The above copyright notice and this permission notice shall be included in
+   all copies or substantial portions of the Software.
+
+   THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+   IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+   FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+   AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+   LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+   OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+   THE SOFTWARE.
+-->
+
+<mapper namespace="org.glyptodon.guacamole.auth.jdbc.connection.ConnectionRecordMapper" >
+
+    <!-- Result mapper for system permissions -->
+    <resultMap id="ConnectionRecordResultMap" type="org.glyptodon.guacamole.auth.jdbc.connection.ConnectionRecordModel">
+        <result column="connection_id"   property="connectionIdentifier" jdbcType="INTEGER"/>
+        <result column="connection_name" property="connectionName"       jdbcType="VARCHAR"/>
+        <result column="user_id"         property="userID"               jdbcType="INTEGER"/>
+        <result column="username"        property="username"             jdbcType="VARCHAR"/>
+        <result column="start_date"      property="startDate"            jdbcType="TIMESTAMP"/>
+        <result column="end_date"        property="endDate"              jdbcType="TIMESTAMP"/>
+    </resultMap>
+
+    <!-- Select all connection records from a given connection -->
+    <select id="select" resultMap="ConnectionRecordResultMap">
+
+        SELECT
+            guacamole_connection.connection_id,
+            guacamole_connection.connection_name,
+            guacamole_user.user_id,
+            guacamole_user.username,
+            guacamole_connection_history.start_date,
+            guacamole_connection_history.end_date
+        FROM guacamole_connection_history
+        JOIN guacamole_connection ON guacamole_connection_history.connection_id = guacamole_connection.connection_id
+        JOIN guacamole_user ON guacamole_connection_history.user_id = guacamole_user.user_id
+        WHERE
+            guacamole_connection.connection_id = #{identifier,jdbcType=INTEGER}::integer
+        ORDER BY
+            guacamole_connection_history.start_date DESC,
+            guacamole_connection_history.end_date DESC
+
+    </select>
+
+    <!-- Insert the given connection record -->
+    <insert id="insert" parameterType="org.glyptodon.guacamole.auth.jdbc.connection.ConnectionRecordModel">
+
+        INSERT INTO guacamole_connection_history (
+            connection_id,
+            user_id,
+            start_date,
+            end_date
+        )
+        VALUES (
+            #{record.connectionIdentifier,jdbcType=INTEGER}::integer,
+            #{record.userID,jdbcType=INTEGER},
+            #{record.startDate,jdbcType=TIMESTAMP},
+            #{record.endDate,jdbcType=TIMESTAMP}
+        )
+
+    </insert>
+
+    <!-- Search for specific connection records -->
+    <select id="search" resultMap="ConnectionRecordResultMap">
+
+        SELECT
+            guacamole_connection_history.connection_id,
+            guacamole_connection.connection_name,
+            guacamole_connection_history.user_id,
+            guacamole_user.username,
+            guacamole_connection_history.start_date,
+            guacamole_connection_history.end_date
+        FROM guacamole_connection_history
+        LEFT JOIN guacamole_connection ON guacamole_connection_history.connection_id = guacamole_connection.connection_id
+        LEFT JOIN guacamole_user       ON guacamole_connection_history.user_id       = guacamole_user.user_id
+
+        <!-- Search terms -->
+        <foreach collection="terms" item="term"
+                 open="WHERE " separator=" AND ">
+            (
+
+                guacamole_connection_history.user_id IN (
+                    SELECT user_id
+                    FROM guacamole_user
+                    WHERE POSITION(#{term.term,jdbcType=VARCHAR} IN username) > 0
+                )
+
+                OR guacamole_connection_history.connection_id IN (
+                    SELECT connection_id
+                    FROM guacamole_connection
+                    WHERE POSITION(#{term.term,jdbcType=VARCHAR} IN connection_name) > 0
+                )
+
+                <if test="term.startDate != null and term.endDate != null">
+                    OR start_date BETWEEN #{term.startDate,jdbcType=TIMESTAMP} AND #{term.endDate,jdbcType=TIMESTAMP}
+                </if>
+
+            )
+        </foreach>
+
+        <!-- Bind sort property enum values for sake of readability -->
+        <bind name="START_DATE"      value="@org.glyptodon.guacamole.net.auth.ConnectionRecordSet$SortableProperty at START_DATE"/>
+
+        <!-- Sort predicates -->
+        <foreach collection="sortPredicates" item="sortPredicate"
+                 open="ORDER BY " separator=", ">
+            <choose>
+                <when test="sortPredicate.property == START_DATE">guacamole_connection_history.start_date</when>
+                <otherwise>1</otherwise>
+            </choose>
+            <if test="sortPredicate.descending">DESC</if>
+        </foreach>
+
+        LIMIT #{limit,jdbcType=INTEGER}
+
+    </select>
+
+    <!-- Search for specific connection records -->
+    <select id="searchReadable" resultMap="ConnectionRecordResultMap">
+
+        SELECT
+            guacamole_connection_history.connection_id,
+            guacamole_connection.connection_name,
+            guacamole_connection_history.user_id,
+            guacamole_user.username,
+            guacamole_connection_history.start_date,
+            guacamole_connection_history.end_date
+        FROM guacamole_connection_history
+        LEFT JOIN guacamole_connection            ON guacamole_connection_history.connection_id = guacamole_connection.connection_id
+        LEFT JOIN guacamole_user                  ON guacamole_connection_history.user_id       = guacamole_user.user_id
+
+        <!-- Restrict to readable connections -->
+        JOIN guacamole_connection_permission ON
+                guacamole_connection_history.connection_id = guacamole_connection_permission.connection_id
+            AND guacamole_connection_permission.user_id    = #{user.objectID,jdbcType=INTEGER}
+            AND guacamole_connection_permission.permission = 'READ'
+
+        <!-- Restrict to readable users -->
+        JOIN guacamole_user_permission ON
+                guacamole_connection_history.user_id = guacamole_user_permission.affected_user_id
+            AND guacamole_user_permission.user_id    = #{user.objectID,jdbcType=INTEGER}
+            AND guacamole_user_permission.permission = 'READ'
+
+        <!-- Search terms -->
+        <foreach collection="terms" item="term"
+                 open="WHERE " separator=" AND ">
+            (
+
+                guacamole_connection_history.user_id IN (
+                    SELECT user_id
+                    FROM guacamole_user
+                    WHERE POSITION(#{term.term,jdbcType=VARCHAR} IN username) > 0
+                )
+
+                OR guacamole_connection_history.connection_id IN (
+                    SELECT connection_id
+                    FROM guacamole_connection
+                    WHERE POSITION(#{term.term,jdbcType=VARCHAR} IN connection_name) > 0
+                )
+
+                <if test="term.startDate != null and term.endDate != null">
+                    OR start_date BETWEEN #{term.startDate,jdbcType=TIMESTAMP} AND #{term.endDate,jdbcType=TIMESTAMP}
+                </if>
+
+            )
+        </foreach>
+
+        <!-- Bind sort property enum values for sake of readability -->
+        <bind name="START_DATE"      value="@org.glyptodon.guacamole.net.auth.ConnectionRecordSet$SortableProperty at START_DATE"/>
+
+        <!-- Sort predicates -->
+        <foreach collection="sortPredicates" item="sortPredicate"
+                 open="ORDER BY " separator=", ">
+            <choose>
+                <when test="sortPredicate.property == START_DATE">guacamole_connection_history.start_date</when>
+                <otherwise>1</otherwise>
+            </choose>
+            <if test="sortPredicate.descending">DESC</if>
+        </foreach>
+
+        LIMIT #{limit,jdbcType=INTEGER}
+
+    </select>
+
+</mapper>
\ No newline at end of file
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/resources/org/glyptodon/guacamole/auth/jdbc/connection/ParameterMapper.xml b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/resources/org/glyptodon/guacamole/auth/jdbc/connection/ParameterMapper.xml
new file mode 100644
index 0000000..e3fe5d8
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/resources/org/glyptodon/guacamole/auth/jdbc/connection/ParameterMapper.xml
@@ -0,0 +1,71 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+    "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
+
+<!--
+   Copyright (C) 2015 Glyptodon LLC
+
+   Permission is hereby granted, free of charge, to any person obtaining a copy
+   of this software and associated documentation files (the "Software"), to deal
+   in the Software without restriction, including without limitation the rights
+   to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+   copies of the Software, and to permit persons to whom the Software is
+   furnished to do so, subject to the following conditions:
+
+   The above copyright notice and this permission notice shall be included in
+   all copies or substantial portions of the Software.
+
+   THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+   IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+   FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+   AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+   LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+   OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+   THE SOFTWARE.
+-->
+
+<mapper namespace="org.glyptodon.guacamole.auth.jdbc.connection.ParameterMapper">
+
+    <!-- Result mapper for connection parameters -->
+    <resultMap id="ParameterResultMap" type="org.glyptodon.guacamole.auth.jdbc.connection.ParameterModel">
+        <result column="connection_id"   property="connectionIdentifier" jdbcType="INTEGER"/>
+        <result column="parameter_name"  property="name"                 jdbcType="VARCHAR"/>
+        <result column="parameter_value" property="value"                jdbcType="VARCHAR"/>
+    </resultMap>
+
+    <!-- Select all parameters of a given connection -->
+    <select id="select" resultMap="ParameterResultMap">
+        SELECT
+            connection_id,
+            parameter_name,
+            parameter_value
+        FROM guacamole_connection_parameter
+        WHERE
+            connection_id = #{identifier,jdbcType=INTEGER}::integer
+    </select>
+
+    <!-- Delete all parameters of a given connection -->
+    <delete id="delete">
+        DELETE FROM guacamole_connection_parameter
+        WHERE connection_id = #{identifier,jdbcType=INTEGER}::integer
+    </delete>
+
+    <!-- Insert all given parameters -->
+    <insert id="insert" parameterType="org.glyptodon.guacamole.auth.jdbc.connection.ParameterModel">
+
+        INSERT INTO guacamole_connection_parameter (
+            connection_id,
+            parameter_name,
+            parameter_value
+        )
+        VALUES 
+            <foreach collection="parameters" item="parameter" separator=",">
+                (#{parameter.connectionIdentifier,jdbcType=INTEGER}::integer,
+                 #{parameter.name,jdbcType=VARCHAR},
+                 #{parameter.value,jdbcType=VARCHAR})
+            </foreach>
+
+    </insert>
+
+
+</mapper>
\ No newline at end of file
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/resources/org/glyptodon/guacamole/auth/jdbc/connectiongroup/ConnectionGroupMapper.xml b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/resources/org/glyptodon/guacamole/auth/jdbc/connectiongroup/ConnectionGroupMapper.xml
new file mode 100644
index 0000000..75005a6
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/resources/org/glyptodon/guacamole/auth/jdbc/connectiongroup/ConnectionGroupMapper.xml
@@ -0,0 +1,173 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+    "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
+
+<!--
+   Copyright (C) 2015 Glyptodon LLC
+
+   Permission is hereby granted, free of charge, to any person obtaining a copy
+   of this software and associated documentation files (the "Software"), to deal
+   in the Software without restriction, including without limitation the rights
+   to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+   copies of the Software, and to permit persons to whom the Software is
+   furnished to do so, subject to the following conditions:
+
+   The above copyright notice and this permission notice shall be included in
+   all copies or substantial portions of the Software.
+
+   THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+   IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+   FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+   AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+   LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+   OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+   THE SOFTWARE.
+-->
+
+<mapper namespace="org.glyptodon.guacamole.auth.jdbc.connectiongroup.ConnectionGroupMapper" >
+
+    <!-- Result mapper for connection objects -->
+    <resultMap id="ConnectionGroupResultMap" type="org.glyptodon.guacamole.auth.jdbc.connectiongroup.ConnectionGroupModel" >
+        <id     column="connection_group_id"      property="objectID"              jdbcType="INTEGER"/>
+        <result column="connection_group_name"    property="name"                  jdbcType="VARCHAR"/>
+        <result column="parent_id"                property="parentIdentifier"      jdbcType="INTEGER"/>
+        <result column="type"                     property="type"                  jdbcType="VARCHAR"
+                javaType="org.glyptodon.guacamole.net.auth.ConnectionGroup$Type"/>
+        <result column="max_connections"          property="maxConnections"        jdbcType="INTEGER"/>
+        <result column="max_connections_per_user" property="maxConnectionsPerUser" jdbcType="INTEGER"/>
+    </resultMap>
+
+    <!-- Select all connection group identifiers -->
+    <select id="selectIdentifiers" resultType="string">
+        SELECT connection_group_id 
+        FROM guacamole_connection_group
+    </select>
+
+    <!-- Select identifiers of all readable connection groups -->
+    <select id="selectReadableIdentifiers" resultType="string">
+        SELECT connection_group_id
+        FROM guacamole_connection_group_permission
+        WHERE
+            user_id = #{user.objectID,jdbcType=INTEGER}
+            AND permission = 'READ'
+    </select>
+
+    <!-- Select all connection identifiers within a particular connection group -->
+    <select id="selectIdentifiersWithin" resultType="string">
+        SELECT connection_group_id 
+        FROM guacamole_connection_group
+        WHERE
+            <if test="parentIdentifier != null">parent_id = #{parentIdentifier,jdbcType=INTEGER}::integer</if>
+            <if test="parentIdentifier == null">parent_id IS NULL</if>
+    </select>
+
+    <!-- Select identifiers of all readable connection groups within a particular connection group -->
+    <select id="selectReadableIdentifiersWithin" resultType="string">
+        SELECT guacamole_connection_group.connection_group_id
+        FROM guacamole_connection_group
+        JOIN guacamole_connection_group_permission ON guacamole_connection_group_permission.connection_group_id = guacamole_connection_group.connection_group_id
+        WHERE
+            <if test="parentIdentifier != null">parent_id = #{parentIdentifier,jdbcType=INTEGER}::integer</if>
+            <if test="parentIdentifier == null">parent_id IS NULL</if>
+            AND user_id = #{user.objectID,jdbcType=INTEGER}
+            AND permission = 'READ'
+    </select>
+
+    <!-- Select multiple connection groups by identifier -->
+    <select id="select" resultMap="ConnectionGroupResultMap">
+
+        SELECT
+            connection_group_id,
+            connection_group_name,
+            parent_id,
+            type,
+            max_connections,
+            max_connections_per_user
+        FROM guacamole_connection_group
+        WHERE connection_group_id IN
+            <foreach collection="identifiers" item="identifier"
+                     open="(" separator="," close=")">
+                #{identifier,jdbcType=INTEGER}::integer
+            </foreach>
+
+    </select>
+
+    <!-- Select multiple connection groups by identifier only if readable -->
+    <select id="selectReadable" resultMap="ConnectionGroupResultMap">
+
+        SELECT
+            guacamole_connection_group.connection_group_id,
+            connection_group_name,
+            parent_id,
+            type,
+            max_connections,
+            max_connections_per_user
+        FROM guacamole_connection_group
+        JOIN guacamole_connection_group_permission ON guacamole_connection_group_permission.connection_group_id = guacamole_connection_group.connection_group_id
+        WHERE guacamole_connection_group.connection_group_id IN
+            <foreach collection="identifiers" item="identifier"
+                     open="(" separator="," close=")">
+                #{identifier,jdbcType=INTEGER}::integer
+            </foreach>
+            AND user_id = #{user.objectID,jdbcType=INTEGER}
+            AND permission = 'READ'
+
+    </select>
+
+    <!-- Select single connection group by name -->
+    <select id="selectOneByName" resultMap="ConnectionGroupResultMap">
+
+        SELECT
+            connection_group_id,
+            connection_group_name,
+            parent_id,
+            type,
+            max_connections,
+            max_connections_per_user
+        FROM guacamole_connection_group
+        WHERE 
+            <if test="parentIdentifier != null">parent_id = #{parentIdentifier,jdbcType=INTEGER}::integer</if>
+            <if test="parentIdentifier == null">parent_id IS NULL</if>
+            AND connection_group_name = #{name,jdbcType=VARCHAR}
+
+    </select>
+
+    <!-- Delete single connection group by identifier -->
+    <delete id="delete">
+        DELETE FROM guacamole_connection_group
+        WHERE connection_group_id = #{identifier,jdbcType=INTEGER}::integer
+    </delete>
+
+    <!-- Insert single connection -->
+    <insert id="insert" useGeneratedKeys="true" keyProperty="object.objectID"
+            parameterType="org.glyptodon.guacamole.auth.jdbc.connectiongroup.ConnectionGroupModel">
+
+        INSERT INTO guacamole_connection_group (
+            connection_group_name,
+            parent_id,
+            type,
+            max_connections,
+            max_connections_per_user
+        )
+        VALUES (
+            #{object.name,jdbcType=VARCHAR},
+            #{object.parentIdentifier,jdbcType=INTEGER}::integer,
+            #{object.type,jdbcType=VARCHAR}::guacamole_connection_group_type,
+            #{object.maxConnections,jdbcType=INTEGER},
+            #{object.maxConnectionsPerUser,jdbcType=INTEGER}
+        )
+
+    </insert>
+
+    <!-- Update single connection group -->
+    <update id="update" parameterType="org.glyptodon.guacamole.auth.jdbc.connectiongroup.ConnectionGroupModel">
+        UPDATE guacamole_connection_group
+        SET connection_group_name    = #{object.name,jdbcType=VARCHAR},
+            parent_id                = #{object.parentIdentifier,jdbcType=INTEGER}::integer,
+            type                     = #{object.type,jdbcType=VARCHAR}::guacamole_connection_group_type,
+            max_connections          = #{object.maxConnections,jdbcType=INTEGER},
+            max_connections_per_user = #{object.maxConnectionsPerUser,jdbcType=INTEGER}
+        WHERE connection_group_id = #{object.objectID,jdbcType=INTEGER}::integer
+    </update>
+
+</mapper>
\ No newline at end of file
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/resources/org/glyptodon/guacamole/auth/jdbc/permission/ConnectionGroupPermissionMapper.xml b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/resources/org/glyptodon/guacamole/auth/jdbc/permission/ConnectionGroupPermissionMapper.xml
new file mode 100644
index 0000000..33b3562
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/resources/org/glyptodon/guacamole/auth/jdbc/permission/ConnectionGroupPermissionMapper.xml
@@ -0,0 +1,120 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+    "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
+
+<!--
+   Copyright (C) 2015 Glyptodon LLC
+
+   Permission is hereby granted, free of charge, to any person obtaining a copy
+   of this software and associated documentation files (the "Software"), to deal
+   in the Software without restriction, including without limitation the rights
+   to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+   copies of the Software, and to permit persons to whom the Software is
+   furnished to do so, subject to the following conditions:
+
+   The above copyright notice and this permission notice shall be included in
+   all copies or substantial portions of the Software.
+
+   THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+   IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+   FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+   AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+   LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+   OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+   THE SOFTWARE.
+-->
+
+<mapper namespace="org.glyptodon.guacamole.auth.jdbc.permission.ConnectionGroupPermissionMapper" >
+
+    <!-- Result mapper for connection permissions -->
+    <resultMap id="ConnectionGroupPermissionResultMap" type="org.glyptodon.guacamole.auth.jdbc.permission.ObjectPermissionModel">
+        <result column="user_id"             property="userID"           jdbcType="INTEGER"/>
+        <result column="username"            property="username"         jdbcType="VARCHAR"/>
+        <result column="permission"          property="type"             jdbcType="VARCHAR"
+                javaType="org.glyptodon.guacamole.net.auth.permission.ObjectPermission$Type"/>
+        <result column="connection_group_id" property="objectIdentifier" jdbcType="INTEGER"/>
+    </resultMap>
+
+    <!-- Select all permissions for a given user -->
+    <select id="select" resultMap="ConnectionGroupPermissionResultMap">
+
+        SELECT
+            guacamole_connection_group_permission.user_id,
+            username,
+            permission,
+            connection_group_id
+        FROM guacamole_connection_group_permission
+        JOIN guacamole_user ON guacamole_connection_group_permission.user_id = guacamole_user.user_id
+        WHERE guacamole_connection_group_permission.user_id = #{user.objectID,jdbcType=INTEGER}
+
+    </select>
+
+    <!-- Select the single permission matching the given criteria -->
+    <select id="selectOne" resultMap="ConnectionGroupPermissionResultMap">
+
+        SELECT
+            guacamole_connection_group_permission.user_id,
+            username,
+            permission,
+            connection_group_id
+        FROM guacamole_connection_group_permission
+        JOIN guacamole_user ON guacamole_connection_group_permission.user_id = guacamole_user.user_id
+        WHERE
+            guacamole_connection_group_permission.user_id = #{user.objectID,jdbcType=INTEGER}
+            AND permission = #{type,jdbcType=VARCHAR}::guacamole_object_permission_type
+            AND connection_group_id = #{identifier,jdbcType=INTEGER}::integer
+
+    </select>
+
+    <!-- Select identifiers accessible by the given user for the given permissions -->
+    <select id="selectAccessibleIdentifiers" resultType="string">
+
+        SELECT DISTINCT connection_group_id 
+        FROM guacamole_connection_group_permission
+        WHERE
+            user_id = #{user.objectID,jdbcType=INTEGER}
+            AND connection_group_id IN
+                <foreach collection="identifiers" item="identifier"
+                         open="(" separator="," close=")">
+                    #{identifier,jdbcType=INTEGER}::integer
+                </foreach>
+            AND permission IN
+                <foreach collection="permissions" item="permission"
+                         open="(" separator="," close=")">
+                    #{permission,jdbcType=VARCHAR}::guacamole_object_permission_type
+                </foreach>
+
+    </select>
+
+    <!-- Delete all given permissions -->
+    <delete id="delete" parameterType="org.glyptodon.guacamole.auth.jdbc.permission.ObjectPermissionModel">
+
+        DELETE FROM guacamole_connection_group_permission
+        WHERE (user_id, permission, connection_group_id) IN
+            <foreach collection="permissions" item="permission"
+                     open="(" separator="," close=")">
+                (#{permission.userID,jdbcType=INTEGER},
+                 #{permission.type,jdbcType=VARCHAR}::guacamole_object_permission_type,
+                 #{permission.objectIdentifier,jdbcType=INTEGER}::integer)
+            </foreach>
+
+    </delete>
+
+    <!-- Insert all given permissions -->
+    <insert id="insert" parameterType="org.glyptodon.guacamole.auth.jdbc.permission.ObjectPermissionModel">
+
+        INSERT INTO guacamole_connection_group_permission (
+            user_id,
+            permission,
+            connection_group_id
+        )
+        VALUES
+            <foreach collection="permissions" item="permission" separator=",">
+                (#{permission.userID,jdbcType=INTEGER},
+                 #{permission.type,jdbcType=VARCHAR}::guacamole_object_permission_type,
+                 #{permission.objectIdentifier,jdbcType=INTEGER}::integer)
+            </foreach>
+
+    </insert>
+
+</mapper>
\ No newline at end of file
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/resources/org/glyptodon/guacamole/auth/jdbc/permission/ConnectionPermissionMapper.xml b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/resources/org/glyptodon/guacamole/auth/jdbc/permission/ConnectionPermissionMapper.xml
new file mode 100644
index 0000000..4ed1ad7
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/resources/org/glyptodon/guacamole/auth/jdbc/permission/ConnectionPermissionMapper.xml
@@ -0,0 +1,120 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+    "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
+
+<!--
+   Copyright (C) 2015 Glyptodon LLC
+
+   Permission is hereby granted, free of charge, to any person obtaining a copy
+   of this software and associated documentation files (the "Software"), to deal
+   in the Software without restriction, including without limitation the rights
+   to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+   copies of the Software, and to permit persons to whom the Software is
+   furnished to do so, subject to the following conditions:
+
+   The above copyright notice and this permission notice shall be included in
+   all copies or substantial portions of the Software.
+
+   THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+   IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+   FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+   AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+   LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+   OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+   THE SOFTWARE.
+-->
+
+<mapper namespace="org.glyptodon.guacamole.auth.jdbc.permission.ConnectionPermissionMapper" >
+
+    <!-- Result mapper for connection permissions -->
+    <resultMap id="ConnectionPermissionResultMap" type="org.glyptodon.guacamole.auth.jdbc.permission.ObjectPermissionModel">
+        <result column="user_id"       property="userID"           jdbcType="INTEGER"/>
+        <result column="username"      property="username"         jdbcType="VARCHAR"/>
+        <result column="permission"    property="type"             jdbcType="VARCHAR"
+                javaType="org.glyptodon.guacamole.net.auth.permission.ObjectPermission$Type"/>
+        <result column="connection_id" property="objectIdentifier" jdbcType="INTEGER"/>
+    </resultMap>
+
+    <!-- Select all permissions for a given user -->
+    <select id="select" resultMap="ConnectionPermissionResultMap">
+
+        SELECT
+            guacamole_connection_permission.user_id,
+            username,
+            permission,
+            connection_id
+        FROM guacamole_connection_permission
+        JOIN guacamole_user ON guacamole_connection_permission.user_id = guacamole_user.user_id
+        WHERE guacamole_connection_permission.user_id = #{user.objectID,jdbcType=INTEGER}
+
+    </select>
+
+    <!-- Select the single permission matching the given criteria -->
+    <select id="selectOne" resultMap="ConnectionPermissionResultMap">
+
+        SELECT
+            guacamole_connection_permission.user_id,
+            username,
+            permission,
+            connection_id
+        FROM guacamole_connection_permission
+        JOIN guacamole_user ON guacamole_connection_permission.user_id = guacamole_user.user_id
+        WHERE
+            guacamole_connection_permission.user_id = #{user.objectID,jdbcType=INTEGER}
+            AND permission = #{type,jdbcType=VARCHAR}::guacamole_object_permission_type
+            AND connection_id = #{identifier,jdbcType=INTEGER}::integer
+
+    </select>
+
+    <!-- Select identifiers accessible by the given user for the given permissions -->
+    <select id="selectAccessibleIdentifiers" resultType="string">
+
+        SELECT DISTINCT connection_id 
+        FROM guacamole_connection_permission
+        WHERE
+            user_id = #{user.objectID,jdbcType=INTEGER}
+            AND connection_id IN
+                <foreach collection="identifiers" item="identifier"
+                         open="(" separator="," close=")">
+                    #{identifier,jdbcType=INTEGER}::integer
+                </foreach>
+            AND permission IN
+                <foreach collection="permissions" item="permission"
+                         open="(" separator="," close=")">
+                    #{permission,jdbcType=VARCHAR}::guacamole_object_permission_type
+                </foreach>
+
+    </select>
+
+    <!-- Delete all given permissions -->
+    <delete id="delete" parameterType="org.glyptodon.guacamole.auth.jdbc.permission.ObjectPermissionModel">
+
+        DELETE FROM guacamole_connection_permission
+        WHERE (user_id, permission, connection_id) IN
+            <foreach collection="permissions" item="permission"
+                     open="(" separator="," close=")">
+                (#{permission.userID,jdbcType=INTEGER},
+                 #{permission.type,jdbcType=VARCHAR}::guacamole_object_permission_type,
+                 #{permission.objectIdentifier,jdbcType=INTEGER}::integer)
+            </foreach>
+
+    </delete>
+
+    <!-- Insert all given permissions -->
+    <insert id="insert" parameterType="org.glyptodon.guacamole.auth.jdbc.permission.ObjectPermissionModel">
+
+        INSERT INTO guacamole_connection_permission (
+            user_id,
+            permission,
+            connection_id
+        )
+        VALUES
+            <foreach collection="permissions" item="permission" separator=",">
+                (#{permission.userID,jdbcType=INTEGER},
+                 #{permission.type,jdbcType=VARCHAR}::guacamole_object_permission_type,
+                 #{permission.objectIdentifier,jdbcType=INTEGER}::integer)
+            </foreach>
+
+    </insert>
+
+</mapper>
\ No newline at end of file
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/resources/org/glyptodon/guacamole/auth/jdbc/permission/SystemPermissionMapper.xml b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/resources/org/glyptodon/guacamole/auth/jdbc/permission/SystemPermissionMapper.xml
new file mode 100644
index 0000000..f122201
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/resources/org/glyptodon/guacamole/auth/jdbc/permission/SystemPermissionMapper.xml
@@ -0,0 +1,93 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+    "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
+
+<!--
+   Copyright (C) 2015 Glyptodon LLC
+
+   Permission is hereby granted, free of charge, to any person obtaining a copy
+   of this software and associated documentation files (the "Software"), to deal
+   in the Software without restriction, including without limitation the rights
+   to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+   copies of the Software, and to permit persons to whom the Software is
+   furnished to do so, subject to the following conditions:
+
+   The above copyright notice and this permission notice shall be included in
+   all copies or substantial portions of the Software.
+
+   THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+   IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+   FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+   AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+   LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+   OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+   THE SOFTWARE.
+-->
+
+<mapper namespace="org.glyptodon.guacamole.auth.jdbc.permission.SystemPermissionMapper" >
+
+    <!-- Result mapper for system permissions -->
+    <resultMap id="SystemPermissionResultMap" type="org.glyptodon.guacamole.auth.jdbc.permission.SystemPermissionModel">
+        <result column="user_id"    property="userID"   jdbcType="INTEGER"/>
+        <result column="username"   property="username" jdbcType="VARCHAR"/>
+        <result column="permission" property="type"     jdbcType="VARCHAR"
+                javaType="org.glyptodon.guacamole.net.auth.permission.SystemPermission$Type"/>
+    </resultMap>
+
+    <!-- Select all permissions for a given user -->
+    <select id="select" resultMap="SystemPermissionResultMap">
+
+        SELECT
+            guacamole_system_permission.user_id,
+            username,
+            permission
+        FROM guacamole_system_permission
+        JOIN guacamole_user ON guacamole_system_permission.user_id = guacamole_user.user_id
+        WHERE guacamole_system_permission.user_id = #{user.objectID,jdbcType=INTEGER}
+
+    </select>
+
+    <!-- Select the single permission matching the given criteria -->
+    <select id="selectOne" resultMap="SystemPermissionResultMap">
+
+        SELECT
+            guacamole_system_permission.user_id,
+            username,
+            permission
+        FROM guacamole_system_permission
+        JOIN guacamole_user ON guacamole_system_permission.user_id = guacamole_user.user_id
+        WHERE
+            guacamole_system_permission.user_id = #{user.objectID,jdbcType=INTEGER}
+            AND permission = #{type,jdbcType=VARCHAR}::guacamole_system_permission_type
+
+    </select>
+
+    <!-- Delete all given permissions -->
+    <delete id="delete" parameterType="org.glyptodon.guacamole.auth.jdbc.permission.SystemPermissionModel">
+
+        DELETE FROM guacamole_system_permission
+        WHERE (user_id, permission) IN
+            <foreach collection="permissions" item="permission"
+                     open="(" separator="," close=")">
+                (#{permission.userID,jdbcType=INTEGER},
+                 #{permission.type,jdbcType=VARCHAR}::guacamole_system_permission_type)
+            </foreach>
+
+    </delete>
+
+    <!-- Insert all given permissions -->
+    <insert id="insert" parameterType="org.glyptodon.guacamole.auth.jdbc.permission.SystemPermissionModel">
+
+        INSERT INTO guacamole_system_permission (
+            user_id,
+            permission
+        )
+        VALUES
+            <foreach collection="permissions" item="permission" separator=",">
+                (#{permission.userID,jdbcType=INTEGER},
+                 #{permission.type,jdbcType=VARCHAR}::guacamole_system_permission_type)
+            </foreach>
+
+    </insert>
+
+</mapper>
\ No newline at end of file
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/resources/org/glyptodon/guacamole/auth/jdbc/permission/UserPermissionMapper.xml b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/resources/org/glyptodon/guacamole/auth/jdbc/permission/UserPermissionMapper.xml
new file mode 100644
index 0000000..ce529d0
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/resources/org/glyptodon/guacamole/auth/jdbc/permission/UserPermissionMapper.xml
@@ -0,0 +1,129 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+    "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
+
+<!--
+   Copyright (C) 2015 Glyptodon LLC
+
+   Permission is hereby granted, free of charge, to any person obtaining a copy
+   of this software and associated documentation files (the "Software"), to deal
+   in the Software without restriction, including without limitation the rights
+   to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+   copies of the Software, and to permit persons to whom the Software is
+   furnished to do so, subject to the following conditions:
+
+   The above copyright notice and this permission notice shall be included in
+   all copies or substantial portions of the Software.
+
+   THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+   IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+   FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+   AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+   LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+   OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+   THE SOFTWARE.
+-->
+
+<mapper namespace="org.glyptodon.guacamole.auth.jdbc.permission.UserPermissionMapper" >
+
+    <!-- Result mapper for user permissions -->
+    <resultMap id="UserPermissionResultMap" type="org.glyptodon.guacamole.auth.jdbc.permission.ObjectPermissionModel">
+        <result column="user_id"           property="userID"           jdbcType="INTEGER"/>
+        <result column="username"          property="username"         jdbcType="VARCHAR"/>
+        <result column="permission"        property="type"             jdbcType="VARCHAR"
+                javaType="org.glyptodon.guacamole.net.auth.permission.ObjectPermission$Type"/>
+        <result column="affected_username" property="objectIdentifier" jdbcType="INTEGER"/>
+    </resultMap>
+
+    <!-- Select all permissions for a given user -->
+    <select id="select" resultMap="UserPermissionResultMap">
+
+        SELECT
+            guacamole_user_permission.user_id,
+            guacamole_user.username,
+            permission,
+            affected.username AS affected_username
+        FROM guacamole_user_permission
+        JOIN guacamole_user          ON guacamole_user_permission.user_id          = guacamole_user.user_id
+        JOIN guacamole_user affected ON guacamole_user_permission.affected_user_id = affected.user_id
+        WHERE guacamole_user_permission.user_id = #{user.objectID,jdbcType=INTEGER}
+
+    </select>
+
+    <!-- Select the single permission matching the given criteria -->
+    <select id="selectOne" resultMap="UserPermissionResultMap">
+
+        SELECT
+            guacamole_user_permission.user_id,
+            guacamole_user.username,
+            permission,
+            affected.username AS affected_username
+        FROM guacamole_user_permission
+        JOIN guacamole_user          ON guacamole_user_permission.user_id          = guacamole_user.user_id
+        JOIN guacamole_user affected ON guacamole_user_permission.affected_user_id = affected.user_id
+        WHERE
+            guacamole_user_permission.user_id = #{user.objectID,jdbcType=INTEGER}
+            AND permission = #{type,jdbcType=VARCHAR}::guacamole_object_permission_type
+            AND affected.username = #{identifier,jdbcType=INTEGER}
+
+    </select>
+
+    <!-- Select identifiers accessible by the given user for the given permissions -->
+    <select id="selectAccessibleIdentifiers" resultType="string">
+
+        SELECT DISTINCT username
+        FROM guacamole_user_permission
+        JOIN guacamole_user ON guacamole_user_permission.affected_user_id = guacamole_user.user_id
+        WHERE
+            guacamole_user_permission.user_id = #{user.objectID,jdbcType=INTEGER}
+            AND username IN
+                <foreach collection="identifiers" item="identifier"
+                         open="(" separator="," close=")">
+                    #{identifier,jdbcType=INTEGER}
+                </foreach>
+            AND permission IN
+                <foreach collection="permissions" item="permission"
+                         open="(" separator="," close=")">
+                    #{permission,jdbcType=VARCHAR}::guacamole_object_permission_type
+                </foreach>
+
+    </select>
+
+    <!-- Delete all given permissions -->
+    <delete id="delete" parameterType="org.glyptodon.guacamole.auth.jdbc.permission.ObjectPermissionModel">
+
+        DELETE FROM guacamole_user_permission
+        USING guacamole_user affected
+        WHERE
+            guacamole_user_permission.affected_user_id = affected.user_id
+            AND (guacamole_user_permission.user_id, permission, affected.username) IN
+                <foreach collection="permissions" item="permission"
+                         open="(" separator="," close=")">
+                    (#{permission.userID,jdbcType=INTEGER},
+                     #{permission.type,jdbcType=VARCHAR}::guacamole_object_permission_type,
+                     #{permission.objectIdentifier,jdbcType=INTEGER})
+                </foreach>
+
+    </delete>
+
+    <!-- Insert all given permissions -->
+    <insert id="insert" parameterType="org.glyptodon.guacamole.auth.jdbc.permission.ObjectPermissionModel">
+
+        INSERT INTO guacamole_user_permission (
+            user_id,
+            permission,
+            affected_user_id
+        )
+        SELECT permissions.user_id, permissions.permission, guacamole_user.user_id FROM
+            <foreach collection="permissions" item="permission"
+                     open="(" separator="UNION ALL" close=")">
+                SELECT #{permission.userID,jdbcType=INTEGER}                                 AS user_id,
+                       #{permission.type,jdbcType=VARCHAR}::guacamole_object_permission_type AS permission,
+                       #{permission.objectIdentifier,jdbcType=INTEGER}                       AS username
+            </foreach>
+        AS permissions
+        JOIN guacamole_user ON guacamole_user.username = permissions.username; 
+
+    </insert>
+
+</mapper>
\ No newline at end of file
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/resources/org/glyptodon/guacamole/auth/jdbc/user/UserMapper.xml b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/resources/org/glyptodon/guacamole/auth/jdbc/user/UserMapper.xml
new file mode 100644
index 0000000..fd9138c
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/resources/org/glyptodon/guacamole/auth/jdbc/user/UserMapper.xml
@@ -0,0 +1,184 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+    "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
+
+<!--
+   Copyright (C) 2015 Glyptodon LLC
+
+   Permission is hereby granted, free of charge, to any person obtaining a copy
+   of this software and associated documentation files (the "Software"), to deal
+   in the Software without restriction, including without limitation the rights
+   to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+   copies of the Software, and to permit persons to whom the Software is
+   furnished to do so, subject to the following conditions:
+
+   The above copyright notice and this permission notice shall be included in
+   all copies or substantial portions of the Software.
+
+   THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+   IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+   FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+   AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+   LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+   OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+   THE SOFTWARE.
+-->
+
+<mapper namespace="org.glyptodon.guacamole.auth.jdbc.user.UserMapper" >
+
+    <!-- Result mapper for user objects -->
+    <resultMap id="UserResultMap" type="org.glyptodon.guacamole.auth.jdbc.user.UserModel" >
+        <id     column="user_id"             property="objectID"          jdbcType="INTEGER"/>
+        <result column="username"            property="identifier"        jdbcType="VARCHAR"/>
+        <result column="password_hash"       property="passwordHash"      jdbcType="BINARY"/>
+        <result column="password_salt"       property="passwordSalt"      jdbcType="BINARY"/>
+        <result column="disabled"            property="disabled"          jdbcType="BOOLEAN"/>
+        <result column="expired"             property="expired"           jdbcType="BOOLEAN"/>
+        <result column="access_window_start" property="accessWindowStart" jdbcType="TIME"/>
+        <result column="access_window_end"   property="accessWindowEnd"   jdbcType="TIME"/>
+        <result column="valid_from"          property="validFrom"         jdbcType="DATE"/>
+        <result column="valid_until"         property="validUntil"        jdbcType="DATE"/>
+        <result column="timezone"            property="timeZone"          jdbcType="VARCHAR"/>
+    </resultMap>
+
+    <!-- Select all usernames -->
+    <select id="selectIdentifiers" resultType="string">
+        SELECT username
+        FROM guacamole_user
+    </select>
+
+    <!-- Select usernames of all readable users -->
+    <select id="selectReadableIdentifiers" resultType="string">
+        SELECT username
+        FROM guacamole_user
+        JOIN guacamole_user_permission ON affected_user_id = guacamole_user.user_id
+        WHERE
+            guacamole_user_permission.user_id = #{user.objectID,jdbcType=INTEGER}
+            AND permission = 'READ'
+    </select>
+
+    <!-- Select multiple users by username -->
+    <select id="select" resultMap="UserResultMap">
+
+        SELECT
+            user_id,
+            username,
+            password_hash,
+            password_salt,
+            disabled,
+            expired,
+            access_window_start,
+            access_window_end,
+            valid_from,
+            valid_until,
+            timezone
+        FROM guacamole_user
+        WHERE username IN
+            <foreach collection="identifiers" item="identifier"
+                     open="(" separator="," close=")">
+                #{identifier,jdbcType=VARCHAR}
+            </foreach>
+
+    </select>
+
+    <!-- Select multiple users by username only if readable -->
+    <select id="selectReadable" resultMap="UserResultMap">
+
+        SELECT
+            guacamole_user.user_id,
+            username,
+            password_hash,
+            password_salt,
+            disabled,
+            expired,
+            access_window_start,
+            access_window_end,
+            valid_from,
+            valid_until,
+            timezone
+        FROM guacamole_user
+        JOIN guacamole_user_permission ON affected_user_id = guacamole_user.user_id
+        WHERE username IN
+            <foreach collection="identifiers" item="identifier"
+                     open="(" separator="," close=")">
+                #{identifier,jdbcType=VARCHAR}
+            </foreach>
+            AND guacamole_user_permission.user_id = #{user.objectID,jdbcType=INTEGER}
+            AND permission = 'READ'
+
+    </select>
+
+    <!-- Select single user by username -->
+    <select id="selectOne" resultMap="UserResultMap">
+
+        SELECT
+            user_id,
+            username,
+            password_hash,
+            password_salt,
+            disabled,
+            expired,
+            access_window_start,
+            access_window_end,
+            valid_from,
+            valid_until,
+            timezone
+        FROM guacamole_user
+        WHERE
+            username = #{username,jdbcType=VARCHAR}
+
+    </select>
+
+    <!-- Delete single user by username -->
+    <delete id="delete">
+        DELETE FROM guacamole_user
+        WHERE username = #{identifier,jdbcType=VARCHAR}
+    </delete>
+
+    <!-- Insert single user -->
+    <insert id="insert" useGeneratedKeys="true" keyProperty="object.objectID"
+            parameterType="org.glyptodon.guacamole.auth.jdbc.user.UserModel">
+
+        INSERT INTO guacamole_user (
+            username,
+            password_hash,
+            password_salt,
+            disabled,
+            expired,
+            access_window_start,
+            access_window_end,
+            valid_from,
+            valid_until,
+            timezone
+        )
+        VALUES (
+            #{object.identifier,jdbcType=VARCHAR},
+            #{object.passwordHash,jdbcType=BINARY},
+            #{object.passwordSalt,jdbcType=BINARY},
+            #{object.disabled,jdbcType=BOOLEAN},
+            #{object.expired,jdbcType=BOOLEAN},
+            #{object.accessWindowStart,jdbcType=TIME},
+            #{object.accessWindowEnd,jdbcType=TIME},
+            #{object.validFrom,jdbcType=DATE},
+            #{object.validUntil,jdbcType=DATE},
+            #{object.timeZone,jdbcType=VARCHAR}
+        )
+
+    </insert>
+
+    <!-- Update single user -->
+    <update id="update" parameterType="org.glyptodon.guacamole.auth.jdbc.user.UserModel">
+        UPDATE guacamole_user
+        SET password_hash = #{object.passwordHash,jdbcType=BINARY},
+            password_salt = #{object.passwordSalt,jdbcType=BINARY},
+            disabled = #{object.disabled,jdbcType=BOOLEAN},
+            expired = #{object.expired,jdbcType=BOOLEAN},
+            access_window_start = #{object.accessWindowStart,jdbcType=TIME},
+            access_window_end = #{object.accessWindowEnd,jdbcType=TIME},
+            valid_from = #{object.validFrom,jdbcType=DATE},
+            valid_until = #{object.validUntil,jdbcType=DATE},
+            timezone = #{object.timeZone,jdbcType=VARCHAR}
+        WHERE user_id = #{object.objectID,jdbcType=VARCHAR}
+    </update>
+
+</mapper>
diff --git a/extensions/guacamole-auth-jdbc/pom.xml b/extensions/guacamole-auth-jdbc/pom.xml
new file mode 100644
index 0000000..7a9637a
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/pom.xml
@@ -0,0 +1,72 @@
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
+                        http://maven.apache.org/maven-v4_0_0.xsd">
+
+    <modelVersion>4.0.0</modelVersion>
+    <groupId>org.glyptodon.guacamole</groupId>
+    <artifactId>guacamole-auth-jdbc</artifactId>
+    <packaging>pom</packaging>
+    <version>0.9.9</version>
+    <name>guacamole-auth-jdbc</name>
+    <url>http://guac-dev.org/</url>
+
+    <properties>
+        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+    </properties>
+
+    <modules>
+
+        <!-- Base JDBC classes -->
+        <module>modules/guacamole-auth-jdbc-base</module>
+
+        <!-- Database-specific implementations -->
+        <module>modules/guacamole-auth-jdbc-mysql</module>
+        <module>modules/guacamole-auth-jdbc-postgresql</module>
+
+    </modules>
+
+    <build>
+        <plugins>
+
+            <!-- Assembly plugin - for easy distribution -->
+            <plugin>
+                <artifactId>maven-assembly-plugin</artifactId>
+                <version>2.5.3</version>
+                <inherited>false</inherited>
+                <executions>
+                    <execution>
+                        <id>make-dist-archive</id>
+                        <phase>package</phase>
+                        <goals>
+                            <goal>single</goal>
+                        </goals>
+                        <configuration>
+                            <finalName>${project.artifactId}-${project.version}</finalName>
+                            <appendAssemblyId>false</appendAssemblyId>
+                            <descriptors>
+                                <descriptor>src/main/assembly/dist.xml</descriptor>
+                            </descriptors>
+                        </configuration>
+                    </execution>
+                </executions>
+            </plugin>
+
+        </plugins>
+    </build>
+
+    <dependencyManagement>
+        <dependencies>
+
+            <!-- Guacamole Extension API -->
+            <dependency>
+                <groupId>org.glyptodon.guacamole</groupId>
+                <artifactId>guacamole-ext</artifactId>
+                <version>0.9.9</version>
+                <scope>provided</scope>
+            </dependency>
+
+        </dependencies>
+    </dependencyManagement>
+
+</project>
diff --git a/extensions/guacamole-auth-jdbc/src/main/assembly/dist.xml b/extensions/guacamole-auth-jdbc/src/main/assembly/dist.xml
new file mode 100644
index 0000000..4e72cec
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/src/main/assembly/dist.xml
@@ -0,0 +1,45 @@
+<assembly
+    xmlns="http://maven.apache.org/plugins/maven-assembly-plugin/assembly/1.1.0"
+    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+    xsi:schemaLocation="http://maven.apache.org/plugins/maven-assembly-plugin/assembly/1.1.0 http://maven.apache.org/xsd/assembly-1.1.0.xsd">
+    
+    <id>dist</id>
+    <baseDirectory>${project.artifactId}-${project.version}</baseDirectory>
+
+    <!-- Output tar.gz -->
+    <formats>
+        <format>tar.gz</format>
+    </formats>
+
+    <!-- Include all implementations -->
+    <fileSets>
+
+            <!-- MySQL implementation -->
+            <fileSet>
+                <outputDirectory>mysql/schema</outputDirectory>
+                <directory>modules/guacamole-auth-jdbc-mysql/schema</directory>
+            </fileSet>
+            <fileSet>
+                <directory>modules/guacamole-auth-jdbc-mysql/target</directory>
+                <outputDirectory>mysql</outputDirectory>
+                <includes>
+                    <include>*.jar</include>
+                </includes>
+            </fileSet>
+
+            <!-- PostgreSQL implementation -->
+            <fileSet>
+                <outputDirectory>postgresql/schema</outputDirectory>
+                <directory>modules/guacamole-auth-jdbc-postgresql/schema</directory>
+            </fileSet>
+            <fileSet>
+                <directory>modules/guacamole-auth-jdbc-postgresql/target</directory>
+                <outputDirectory>postgresql</outputDirectory>
+                <includes>
+                    <include>*.jar</include>
+                </includes>
+            </fileSet>
+
+    </fileSets>
+
+</assembly>
diff --git a/extensions/guacamole-auth-ldap/LICENSE b/extensions/guacamole-auth-ldap/LICENSE
index 7714141..540cdcf 100644
--- a/extensions/guacamole-auth-ldap/LICENSE
+++ b/extensions/guacamole-auth-ldap/LICENSE
@@ -1,470 +1,19 @@
-                          MOZILLA PUBLIC LICENSE
-                                Version 1.1
-
-                              ---------------
-
-1. Definitions.
-
-     1.0.1. "Commercial Use" means distribution or otherwise making the
-     Covered Code available to a third party.
-
-     1.1. "Contributor" means each entity that creates or contributes to
-     the creation of Modifications.
-
-     1.2. "Contributor Version" means the combination of the Original
-     Code, prior Modifications used by a Contributor, and the Modifications
-     made by that particular Contributor.
-
-     1.3. "Covered Code" means the Original Code or Modifications or the
-     combination of the Original Code and Modifications, in each case
-     including portions thereof.
-
-     1.4. "Electronic Distribution Mechanism" means a mechanism generally
-     accepted in the software development community for the electronic
-     transfer of data.
-
-     1.5. "Executable" means Covered Code in any form other than Source
-     Code.
-
-     1.6. "Initial Developer" means the individual or entity identified
-     as the Initial Developer in the Source Code notice required by Exhibit
-     A.
-
-     1.7. "Larger Work" means a work which combines Covered Code or
-     portions thereof with code not governed by the terms of this License.
-
-     1.8. "License" means this document.
-
-     1.8.1. "Licensable" means having the right to grant, to the maximum
-     extent possible, whether at the time of the initial grant or
-     subsequently acquired, any and all of the rights conveyed herein.
-
-     1.9. "Modifications" means any addition to or deletion from the
-     substance or structure of either the Original Code or any previous
-     Modifications. When Covered Code is released as a series of files, a
-     Modification is:
-          A. Any addition to or deletion from the contents of a file
-          containing Original Code or previous Modifications.
-
-          B. Any new file that contains any part of the Original Code or
-          previous Modifications.
-
-     1.10. "Original Code" means Source Code of computer software code
-     which is described in the Source Code notice required by Exhibit A as
-     Original Code, and which, at the time of its release under this
-     License is not already Covered Code governed by this License.
-
-     1.10.1. "Patent Claims" means any patent claim(s), now owned or
-     hereafter acquired, including without limitation,  method, process,
-     and apparatus claims, in any patent Licensable by grantor.
-
-     1.11. "Source Code" means the preferred form of the Covered Code for
-     making modifications to it, including all modules it contains, plus
-     any associated interface definition files, scripts used to control
-     compilation and installation of an Executable, or source code
-     differential comparisons against either the Original Code or another
-     well known, available Covered Code of the Contributor's choice. The
-     Source Code can be in a compressed or archival form, provided the
-     appropriate decompression or de-archiving software is widely available
-     for no charge.
-
-     1.12. "You" (or "Your")  means an individual or a legal entity
-     exercising rights under, and complying with all of the terms of, this
-     License or a future version of this License issued under Section 6.1.
-     For legal entities, "You" includes any entity which controls, is
-     controlled by, or is under common control with You. For purposes of
-     this definition, "control" means (a) the power, direct or indirect,
-     to cause the direction or management of such entity, whether by
-     contract or otherwise, or (b) ownership of more than fifty percent
-     (50%) of the outstanding shares or beneficial ownership of such
-     entity.
-
-2. Source Code License.
-
-     2.1. The Initial Developer Grant.
-     The Initial Developer hereby grants You a world-wide, royalty-free,
-     non-exclusive license, subject to third party intellectual property
-     claims:
-          (a)  under intellectual property rights (other than patent or
-          trademark) Licensable by Initial Developer to use, reproduce,
-          modify, display, perform, sublicense and distribute the Original
-          Code (or portions thereof) with or without Modifications, and/or
-          as part of a Larger Work; and
-
-          (b) under Patents Claims infringed by the making, using or
-          selling of Original Code, to make, have made, use, practice,
-          sell, and offer for sale, and/or otherwise dispose of the
-          Original Code (or portions thereof).
-
-          (c) the licenses granted in this Section 2.1(a) and (b) are
-          effective on the date Initial Developer first distributes
-          Original Code under the terms of this License.
-
-          (d) Notwithstanding Section 2.1(b) above, no patent license is
-          granted: 1) for code that You delete from the Original Code; 2)
-          separate from the Original Code;  or 3) for infringements caused
-          by: i) the modification of the Original Code or ii) the
-          combination of the Original Code with other software or devices.
-
-     2.2. Contributor Grant.
-     Subject to third party intellectual property claims, each Contributor
-     hereby grants You a world-wide, royalty-free, non-exclusive license
-
-          (a)  under intellectual property rights (other than patent or
-          trademark) Licensable by Contributor, to use, reproduce, modify,
-          display, perform, sublicense and distribute the Modifications
-          created by such Contributor (or portions thereof) either on an
-          unmodified basis, with other Modifications, as Covered Code
-          and/or as part of a Larger Work; and
-
-          (b) under Patent Claims infringed by the making, using, or
-          selling of  Modifications made by that Contributor either alone
-          and/or in combination with its Contributor Version (or portions
-          of such combination), to make, use, sell, offer for sale, have
-          made, and/or otherwise dispose of: 1) Modifications made by that
-          Contributor (or portions thereof); and 2) the combination of
-          Modifications made by that Contributor with its Contributor
-          Version (or portions of such combination).
-
-          (c) the licenses granted in Sections 2.2(a) and 2.2(b) are
-          effective on the date Contributor first makes Commercial Use of
-          the Covered Code.
-
-          (d)    Notwithstanding Section 2.2(b) above, no patent license is
-          granted: 1) for any code that Contributor has deleted from the
-          Contributor Version; 2)  separate from the Contributor Version;
-          3)  for infringements caused by: i) third party modifications of
-          Contributor Version or ii)  the combination of Modifications made
-          by that Contributor with other software  (except as part of the
-          Contributor Version) or other devices; or 4) under Patent Claims
-          infringed by Covered Code in the absence of Modifications made by
-          that Contributor.
-
-3. Distribution Obligations.
-
-     3.1. Application of License.
-     The Modifications which You create or to which You contribute are
-     governed by the terms of this License, including without limitation
-     Section 2.2. The Source Code version of Covered Code may be
-     distributed only under the terms of this License or a future version
-     of this License released under Section 6.1, and You must include a
-     copy of this License with every copy of the Source Code You
-     distribute. You may not offer or impose any terms on any Source Code
-     version that alters or restricts the applicable version of this
-     License or the recipients' rights hereunder. However, You may include
-     an additional document offering the additional rights described in
-     Section 3.5.
-
-     3.2. Availability of Source Code.
-     Any Modification which You create or to which You contribute must be
-     made available in Source Code form under the terms of this License
-     either on the same media as an Executable version or via an accepted
-     Electronic Distribution Mechanism to anyone to whom you made an
-     Executable version available; and if made available via Electronic
-     Distribution Mechanism, must remain available for at least twelve (12)
-     months after the date it initially became available, or at least six
-     (6) months after a subsequent version of that particular Modification
-     has been made available to such recipients. You are responsible for
-     ensuring that the Source Code version remains available even if the
-     Electronic Distribution Mechanism is maintained by a third party.
-
-     3.3. Description of Modifications.
-     You must cause all Covered Code to which You contribute to contain a
-     file documenting the changes You made to create that Covered Code and
-     the date of any change. You must include a prominent statement that
-     the Modification is derived, directly or indirectly, from Original
-     Code provided by the Initial Developer and including the name of the
-     Initial Developer in (a) the Source Code, and (b) in any notice in an
-     Executable version or related documentation in which You describe the
-     origin or ownership of the Covered Code.
-
-     3.4. Intellectual Property Matters
-          (a) Third Party Claims.
-          If Contributor has knowledge that a license under a third party's
-          intellectual property rights is required to exercise the rights
-          granted by such Contributor under Sections 2.1 or 2.2,
-          Contributor must include a text file with the Source Code
-          distribution titled "LEGAL" which describes the claim and the
-          party making the claim in sufficient detail that a recipient will
-          know whom to contact. If Contributor obtains such knowledge after
-          the Modification is made available as described in Section 3.2,
-          Contributor shall promptly modify the LEGAL file in all copies
-          Contributor makes available thereafter and shall take other steps
-          (such as notifying appropriate mailing lists or newsgroups)
-          reasonably calculated to inform those who received the Covered
-          Code that new knowledge has been obtained.
-
-          (b) Contributor APIs.
-          If Contributor's Modifications include an application programming
-          interface and Contributor has knowledge of patent licenses which
-          are reasonably necessary to implement that API, Contributor must
-          also include this information in the LEGAL file.
-
-               (c)    Representations.
-          Contributor represents that, except as disclosed pursuant to
-          Section 3.4(a) above, Contributor believes that Contributor's
-          Modifications are Contributor's original creation(s) and/or
-          Contributor has sufficient rights to grant the rights conveyed by
-          this License.
-
-     3.5. Required Notices.
-     You must duplicate the notice in Exhibit A in each file of the Source
-     Code.  If it is not possible to put such notice in a particular Source
-     Code file due to its structure, then You must include such notice in a
-     location (such as a relevant directory) where a user would be likely
-     to look for such a notice.  If You created one or more Modification(s)
-     You may add your name as a Contributor to the notice described in
-     Exhibit A.  You must also duplicate this License in any documentation
-     for the Source Code where You describe recipients' rights or ownership
-     rights relating to Covered Code.  You may choose to offer, and to
-     charge a fee for, warranty, support, indemnity or liability
-     obligations to one or more recipients of Covered Code. However, You
-     may do so only on Your own behalf, and not on behalf of the Initial
-     Developer or any Contributor. You must make it absolutely clear than
-     any such warranty, support, indemnity or liability obligation is
-     offered by You alone, and You hereby agree to indemnify the Initial
-     Developer and every Contributor for any liability incurred by the
-     Initial Developer or such Contributor as a result of warranty,
-     support, indemnity or liability terms You offer.
-
-     3.6. Distribution of Executable Versions.
-     You may distribute Covered Code in Executable form only if the
-     requirements of Section 3.1-3.5 have been met for that Covered Code,
-     and if You include a notice stating that the Source Code version of
-     the Covered Code is available under the terms of this License,
-     including a description of how and where You have fulfilled the
-     obligations of Section 3.2. The notice must be conspicuously included
-     in any notice in an Executable version, related documentation or
-     collateral in which You describe recipients' rights relating to the
-     Covered Code. You may distribute the Executable version of Covered
-     Code or ownership rights under a license of Your choice, which may
-     contain terms different from this License, provided that You are in
-     compliance with the terms of this License and that the license for the
-     Executable version does not attempt to limit or alter the recipient's
-     rights in the Source Code version from the rights set forth in this
-     License. If You distribute the Executable version under a different
-     license You must make it absolutely clear that any terms which differ
-     from this License are offered by You alone, not by the Initial
-     Developer or any Contributor. You hereby agree to indemnify the
-     Initial Developer and every Contributor for any liability incurred by
-     the Initial Developer or such Contributor as a result of any such
-     terms You offer.
-
-     3.7. Larger Works.
-     You may create a Larger Work by combining Covered Code with other code
-     not governed by the terms of this License and distribute the Larger
-     Work as a single product. In such a case, You must make sure the
-     requirements of this License are fulfilled for the Covered Code.
-
-4. Inability to Comply Due to Statute or Regulation.
-
-     If it is impossible for You to comply with any of the terms of this
-     License with respect to some or all of the Covered Code due to
-     statute, judicial order, or regulation then You must: (a) comply with
-     the terms of this License to the maximum extent possible; and (b)
-     describe the limitations and the code they affect. Such description
-     must be included in the LEGAL file described in Section 3.4 and must
-     be included with all distributions of the Source Code. Except to the
-     extent prohibited by statute or regulation, such description must be
-     sufficiently detailed for a recipient of ordinary skill to be able to
-     understand it.
-
-5. Application of this License.
-
-     This License applies to code to which the Initial Developer has
-     attached the notice in Exhibit A and to related Covered Code.
-
-6. Versions of the License.
-
-     6.1. New Versions.
-     Netscape Communications Corporation ("Netscape") may publish revised
-     and/or new versions of the License from time to time. Each version
-     will be given a distinguishing version number.
-
-     6.2. Effect of New Versions.
-     Once Covered Code has been published under a particular version of the
-     License, You may always continue to use it under the terms of that
-     version. You may also choose to use such Covered Code under the terms
-     of any subsequent version of the License published by Netscape. No one
-     other than Netscape has the right to modify the terms applicable to
-     Covered Code created under this License.
-
-     6.3. Derivative Works.
-     If You create or use a modified version of this License (which you may
-     only do in order to apply it to code which is not already Covered Code
-     governed by this License), You must (a) rename Your license so that
-     the phrases "Mozilla", "MOZILLAPL", "MOZPL", "Netscape",
-     "MPL", "NPL" or any confusingly similar phrase do not appear in your
-     license (except to note that your license differs from this License)
-     and (b) otherwise make it clear that Your version of the license
-     contains terms which differ from the Mozilla Public License and
-     Netscape Public License. (Filling in the name of the Initial
-     Developer, Original Code or Contributor in the notice described in
-     Exhibit A shall not of themselves be deemed to be modifications of
-     this License.)
-
-7. DISCLAIMER OF WARRANTY.
-
-     COVERED CODE IS PROVIDED UNDER THIS LICENSE ON AN "AS IS" BASIS,
-     WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING,
-     WITHOUT LIMITATION, WARRANTIES THAT THE COVERED CODE IS FREE OF
-     DEFECTS, MERCHANTABLE, FIT FOR A PARTICULAR PURPOSE OR NON-INFRINGING.
-     THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE COVERED CODE
-     IS WITH YOU. SHOULD ANY COVERED CODE PROVE DEFECTIVE IN ANY RESPECT,
-     YOU (NOT THE INITIAL DEVELOPER OR ANY OTHER CONTRIBUTOR) ASSUME THE
-     COST OF ANY NECESSARY SERVICING, REPAIR OR CORRECTION. THIS DISCLAIMER
-     OF WARRANTY CONSTITUTES AN ESSENTIAL PART OF THIS LICENSE. NO USE OF
-     ANY COVERED CODE IS AUTHORIZED HEREUNDER EXCEPT UNDER THIS DISCLAIMER.
-
-8. TERMINATION.
-
-     8.1.  This License and the rights granted hereunder will terminate
-     automatically if You fail to comply with terms herein and fail to cure
-     such breach within 30 days of becoming aware of the breach. All
-     sublicenses to the Covered Code which are properly granted shall
-     survive any termination of this License. Provisions which, by their
-     nature, must remain in effect beyond the termination of this License
-     shall survive.
-
-     8.2.  If You initiate litigation by asserting a patent infringement
-     claim (excluding declatory judgment actions) against Initial Developer
-     or a Contributor (the Initial Developer or Contributor against whom
-     You file such action is referred to as "Participant")  alleging that:
-
-     (a)  such Participant's Contributor Version directly or indirectly
-     infringes any patent, then any and all rights granted by such
-     Participant to You under Sections 2.1 and/or 2.2 of this License
-     shall, upon 60 days notice from Participant terminate prospectively,
-     unless if within 60 days after receipt of notice You either: (i)
-     agree in writing to pay Participant a mutually agreeable reasonable
-     royalty for Your past and future use of Modifications made by such
-     Participant, or (ii) withdraw Your litigation claim with respect to
-     the Contributor Version against such Participant.  If within 60 days
-     of notice, a reasonable royalty and payment arrangement are not
-     mutually agreed upon in writing by the parties or the litigation claim
-     is not withdrawn, the rights granted by Participant to You under
-     Sections 2.1 and/or 2.2 automatically terminate at the expiration of
-     the 60 day notice period specified above.
-
-     (b)  any software, hardware, or device, other than such Participant's
-     Contributor Version, directly or indirectly infringes any patent, then
-     any rights granted to You by such Participant under Sections 2.1(b)
-     and 2.2(b) are revoked effective as of the date You first made, used,
-     sold, distributed, or had made, Modifications made by that
-     Participant.
-
-     8.3.  If You assert a patent infringement claim against Participant
-     alleging that such Participant's Contributor Version directly or
-     indirectly infringes any patent where such claim is resolved (such as
-     by license or settlement) prior to the initiation of patent
-     infringement litigation, then the reasonable value of the licenses
-     granted by such Participant under Sections 2.1 or 2.2 shall be taken
-     into account in determining the amount or value of any payment or
-     license.
-
-     8.4.  In the event of termination under Sections 8.1 or 8.2 above,
-     all end user license agreements (excluding distributors and resellers)
-     which have been validly granted by You or any distributor hereunder
-     prior to termination shall survive termination.
-
-9. LIMITATION OF LIABILITY.
-
-     UNDER NO CIRCUMSTANCES AND UNDER NO LEGAL THEORY, WHETHER TORT
-     (INCLUDING NEGLIGENCE), CONTRACT, OR OTHERWISE, SHALL YOU, THE INITIAL
-     DEVELOPER, ANY OTHER CONTRIBUTOR, OR ANY DISTRIBUTOR OF COVERED CODE,
-     OR ANY SUPPLIER OF ANY OF SUCH PARTIES, BE LIABLE TO ANY PERSON FOR
-     ANY INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES OF ANY
-     CHARACTER INCLUDING, WITHOUT LIMITATION, DAMAGES FOR LOSS OF GOODWILL,
-     WORK STOPPAGE, COMPUTER FAILURE OR MALFUNCTION, OR ANY AND ALL OTHER
-     COMMERCIAL DAMAGES OR LOSSES, EVEN IF SUCH PARTY SHALL HAVE BEEN
-     INFORMED OF THE POSSIBILITY OF SUCH DAMAGES. THIS LIMITATION OF
-     LIABILITY SHALL NOT APPLY TO LIABILITY FOR DEATH OR PERSONAL INJURY
-     RESULTING FROM SUCH PARTY'S NEGLIGENCE TO THE EXTENT APPLICABLE LAW
-     PROHIBITS SUCH LIMITATION. SOME JURISDICTIONS DO NOT ALLOW THE
-     EXCLUSION OR LIMITATION OF INCIDENTAL OR CONSEQUENTIAL DAMAGES, SO
-     THIS EXCLUSION AND LIMITATION MAY NOT APPLY TO YOU.
-
-10. U.S. GOVERNMENT END USERS.
-
-     The Covered Code is a "commercial item," as that term is defined in
-     48 C.F.R. 2.101 (Oct. 1995), consisting of "commercial computer
-     software" and "commercial computer software documentation," as such
-     terms are used in 48 C.F.R. 12.212 (Sept. 1995). Consistent with 48
-     C.F.R. 12.212 and 48 C.F.R. 227.7202-1 through 227.7202-4 (June 1995),
-     all U.S. Government End Users acquire Covered Code with only those
-     rights set forth herein.
-
-11. MISCELLANEOUS.
-
-     This License represents the complete agreement concerning subject
-     matter hereof. If any provision of this License is held to be
-     unenforceable, such provision shall be reformed only to the extent
-     necessary to make it enforceable. This License shall be governed by
-     California law provisions (except to the extent applicable law, if
-     any, provides otherwise), excluding its conflict-of-law provisions.
-     With respect to disputes in which at least one party is a citizen of,
-     or an entity chartered or registered to do business in the United
-     States of America, any litigation relating to this License shall be
-     subject to the jurisdiction of the Federal Courts of the Northern
-     District of California, with venue lying in Santa Clara County,
-     California, with the losing party responsible for costs, including
-     without limitation, court costs and reasonable attorneys' fees and
-     expenses. The application of the United Nations Convention on
-     Contracts for the International Sale of Goods is expressly excluded.
-     Any law or regulation which provides that the language of a contract
-     shall be construed against the drafter shall not apply to this
-     License.
-
-12. RESPONSIBILITY FOR CLAIMS.
-
-     As between Initial Developer and the Contributors, each party is
-     responsible for claims and damages arising, directly or indirectly,
-     out of its utilization of rights under this License and You agree to
-     work with Initial Developer and Contributors to distribute such
-     responsibility on an equitable basis. Nothing herein is intended or
-     shall be deemed to constitute any admission of liability.
-
-13. MULTIPLE-LICENSED CODE.
-
-     Initial Developer may designate portions of the Covered Code as
-     "Multiple-Licensed".  "Multiple-Licensed" means that the Initial
-     Developer permits you to utilize portions of the Covered Code under
-     Your choice of the NPL or the alternative licenses, if any, specified
-     by the Initial Developer in the file described in Exhibit A.
-
-EXHIBIT A -Mozilla Public License.
-
-     ``The contents of this file are subject to the Mozilla Public License
-     Version 1.1 (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.mozilla.org/MPL/
-
-     Software distributed under the License is distributed on an "AS IS"
-     basis, WITHOUT WARRANTY OF ANY KIND, either express or implied. See the
-     License for the specific language governing rights and limitations
-     under the License.
-
-     The Original Code is ______________________________________.
-
-     The Initial Developer of the Original Code is ________________________.
-     Portions created by ______________________ are Copyright (C) ______
-     _______________________. All Rights Reserved.
-
-     Contributor(s): ______________________________________.
-
-     Alternatively, the contents of this file may be used under the terms
-     of the _____ license (the  "[___] License"), in which case the
-     provisions of [______] License are applicable instead of those
-     above.  If you wish to allow use of your version of this file only
-     under the terms of the [____] License and not to allow others to use
-     your version of this file under the MPL, indicate your decision by
-     deleting  the provisions above and replace  them with the notice and
-     other provisions required by the [___] License.  If you do not delete
-     the provisions above, a recipient may use your version of this file
-     under either the MPL or the [___] License."
-
-     [NOTE: The text of this Exhibit A may differ slightly from the text of
-     the notices in the Source Code files of the Original Code. You should
-     use the text of this Exhibit A rather than the text found in the
-     Original Code Source Code for Your Modifications.]
-
+Copyright (C) 2013 Glyptodon LLC
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/extensions/guacamole-auth-ldap/pom.xml b/extensions/guacamole-auth-ldap/pom.xml
index 046c895..3abb23a 100644
--- a/extensions/guacamole-auth-ldap/pom.xml
+++ b/extensions/guacamole-auth-ldap/pom.xml
@@ -5,7 +5,7 @@
     <groupId>org.glyptodon.guacamole</groupId>
     <artifactId>guacamole-auth-ldap</artifactId>
     <packaging>jar</packaging>
-    <version>0.8.0</version>
+    <version>0.9.9</version>
     <name>guacamole-auth-ldap</name>
     <url>http://guac-dev.org/</url>
 
@@ -20,16 +20,42 @@
             <plugin>
                 <groupId>org.apache.maven.plugins</groupId>
                 <artifactId>maven-compiler-plugin</artifactId>
+                <version>3.3</version>
                 <configuration>
                     <source>1.6</source>
                     <target>1.6</target>
+                    <compilerArgs>
+                        <arg>-Xlint:all</arg>
+                        <arg>-Werror</arg>
+                    </compilerArgs>
+                    <fork>true</fork>
                 </configuration>
             </plugin>
 
+            <!-- Copy dependencies prior to packaging -->
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-dependency-plugin</artifactId>
+                <version>2.10</version>
+                <executions>
+                    <execution>
+                        <id>unpack-dependencies</id>
+                        <phase>prepare-package</phase>
+                        <goals>
+                            <goal>unpack-dependencies</goal>
+                        </goals>
+                        <configuration>
+                            <includeScope>runtime</includeScope>
+                            <outputDirectory>${project.build.directory}/classes</outputDirectory>
+                        </configuration>
+                    </execution>
+                </executions>
+            </plugin>
+
             <!-- Assembly plugin - for easy distribution -->
             <plugin>
                 <artifactId>maven-assembly-plugin</artifactId>
-                <version>2.2-beta-5</version>
+                <version>2.5.3</version>
                 <configuration>
                     <finalName>${project.artifactId}-${project.version}</finalName>
                     <appendAssemblyId>false</appendAssemblyId>
@@ -57,14 +83,16 @@
         <dependency>
             <groupId>org.glyptodon.guacamole</groupId>
             <artifactId>guacamole-common</artifactId>
-            <version>0.8.0</version>
+            <version>0.9.9</version>
+            <scope>provided</scope>
         </dependency>
 
         <!-- Guacamole Extension API -->
         <dependency>
             <groupId>org.glyptodon.guacamole</groupId>
             <artifactId>guacamole-ext</artifactId>
-            <version>0.8.1</version>
+            <version>0.9.9</version>
+            <scope>provided</scope>
         </dependency>
 
         <!-- JLDAP -->
@@ -74,6 +102,18 @@
             <version>4.3</version>
         </dependency>
 
+        <!-- Guice -->
+        <dependency>
+            <groupId>com.google.inject</groupId>
+            <artifactId>guice</artifactId>
+            <version>3.0</version>
+        </dependency>
+        <dependency>
+            <groupId>com.google.inject.extensions</groupId>
+            <artifactId>guice-multibindings</artifactId>
+            <version>3.0</version>
+        </dependency>
+
     </dependencies>
 
 </project>
diff --git a/extensions/guacamole-auth-ldap/src/main/assembly/dist.xml b/extensions/guacamole-auth-ldap/src/main/assembly/dist.xml
index 0628ad6..59a416b 100644
--- a/extensions/guacamole-auth-ldap/src/main/assembly/dist.xml
+++ b/extensions/guacamole-auth-ldap/src/main/assembly/dist.xml
@@ -11,44 +11,29 @@
         <format>tar.gz</format>
     </formats>
 
-    <!-- Include docs and schema -->
+    <!-- Include docs, schema, and extension .jar -->
     <fileSets>
 
-            <!-- Include docs -->
-            <fileSet>
-                <outputDirectory>/</outputDirectory>
-                <directory>doc</directory>
-            </fileSet>
-
-            <!-- Include schema -->
-            <fileSet>
-                <outputDirectory>/schema</outputDirectory>
-                <directory>schema</directory>
-            </fileSet>
+        <!-- Include docs -->
+        <fileSet>
+            <directory>doc</directory>
+        </fileSet>
+
+        <!-- Include schema -->
+        <fileSet>
+            <outputDirectory>schema</outputDirectory>
+            <directory>schema</directory>
+        </fileSet>
+
+        <!-- Include extension .jar -->
+        <fileSet>
+            <directory>target</directory>
+            <outputDirectory></outputDirectory>
+            <includes>
+                <include>*.jar</include>
+            </includes>
+        </fileSet>
 
     </fileSets>
 
-    <!-- Include self and all dependencies except guacamole-common 
-         and guacamole-ext -->
-    <dependencySets>
-        <dependencySet>
-
-            <outputDirectory>/lib</outputDirectory>
-            <scope>runtime</scope>
-            <unpack>false</unpack>
-            <useProjectArtifact>true</useProjectArtifact>
-            <useTransitiveFiltering>true</useTransitiveFiltering>
-
-            <excludes>
-
-                <!-- Do not include guacamole-common -->
-                <exclude>org.glyptodon.guacamole:guacamole-common</exclude>
-
-                <!-- Do not include guacamole-ext -->
-                <exclude>org.glyptodon.guacamole:guacamole-ext</exclude>
-
-            </excludes>
-        </dependencySet>
-    </dependencySets>
-
 </assembly>
diff --git a/extensions/guacamole-auth-ldap/src/main/java/net/sourceforge/guacamole/net/auth/ldap/LDAPAuthenticationProvider.java b/extensions/guacamole-auth-ldap/src/main/java/net/sourceforge/guacamole/net/auth/ldap/LDAPAuthenticationProvider.java
index 5fe6ef9..cc6b001 100644
--- a/extensions/guacamole-auth-ldap/src/main/java/net/sourceforge/guacamole/net/auth/ldap/LDAPAuthenticationProvider.java
+++ b/extensions/guacamole-auth-ldap/src/main/java/net/sourceforge/guacamole/net/auth/ldap/LDAPAuthenticationProvider.java
@@ -1,59 +1,37 @@
-
-package net.sourceforge.guacamole.net.auth.ldap;
-
-/* ***** BEGIN LICENSE BLOCK *****
- * Version: MPL 1.1/GPL 2.0/LGPL 2.1
- *
- * The contents of this file are subject to the Mozilla Public License Version
- * 1.1 (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.mozilla.org/MPL/
- *
- * Software distributed under the License is distributed on an "AS IS" basis,
- * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- * for the specific language governing rights and limitations under the
- * License.
- *
- * The Original Code is guacamole-auth-ldap.
- *
- * The Initial Developer of the Original Code is
- * Michael Jumper.
- * Portions created by the Initial Developer are Copyright (C) 2010
- * the Initial Developer. All Rights Reserved.
+/*
+ * Copyright (C) 2015 Glyptodon LLC
  *
- * Contributor(s):
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
  *
- * Alternatively, the contents of this file may be used under the terms of
- * either the GNU General Public License Version 2 or later (the "GPL"), or
- * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
- * in which case the provisions of the GPL or the LGPL are applicable instead
- * of those above. If you wish to allow use of your version of this file only
- * under the terms of either the GPL or the LGPL, and not to allow others to
- * use your version of this file under the terms of the MPL, indicate your
- * decision by deleting the provisions above and replace them with the notice
- * and other provisions required by the GPL or the LGPL. If you do not delete
- * the provisions above, a recipient may use your version of this file under
- * the terms of any one of the MPL, the GPL or the LGPL.
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
  *
- * ***** END LICENSE BLOCK ***** */
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package net.sourceforge.guacamole.net.auth.ldap;
 
-import com.novell.ldap.LDAPAttribute;
-import com.novell.ldap.LDAPConnection;
-import com.novell.ldap.LDAPEntry;
-import com.novell.ldap.LDAPException;
-import com.novell.ldap.LDAPSearchResults;
-import java.io.UnsupportedEncodingException;
-import java.util.Enumeration;
-import java.util.Map;
-import java.util.TreeMap;
+
+import org.glyptodon.guacamole.auth.ldap.AuthenticationProviderService;
+import org.glyptodon.guacamole.auth.ldap.LDAPAuthenticationProviderModule;
+import com.google.inject.Guice;
+import com.google.inject.Injector;
 import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.net.auth.AuthenticatedUser;
+import org.glyptodon.guacamole.net.auth.AuthenticationProvider;
 import org.glyptodon.guacamole.net.auth.Credentials;
-import net.sourceforge.guacamole.net.auth.ldap.properties.LDAPGuacamoleProperties;
-import org.glyptodon.guacamole.net.auth.simple.SimpleAuthenticationProvider;
-import org.glyptodon.guacamole.properties.GuacamoleProperties;
-import org.glyptodon.guacamole.protocol.GuacamoleConfiguration;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
+import org.glyptodon.guacamole.net.auth.UserContext;
 
 /**
  * Allows users to be authenticated against an LDAP server. Each user may have
@@ -62,211 +40,68 @@ import org.slf4j.LoggerFactory;
  *
  * @author Michael Jumper
  */
-public class LDAPAuthenticationProvider extends SimpleAuthenticationProvider {
+public class LDAPAuthenticationProvider implements AuthenticationProvider {
 
     /**
-     * Logger for this class.
+     * The identifier reserved for the root connection group.
      */
-    private Logger logger = LoggerFactory.getLogger(LDAPAuthenticationProvider.class);
-
-    // Courtesy of OWASP: https://www.owasp.org/index.php/Preventing_LDAP_Injection_in_Java
-    private static String escapeLDAPSearchFilter(String filter) {
-        StringBuilder sb = new StringBuilder();
-        for (int i = 0; i < filter.length(); i++) {
-            char curChar = filter.charAt(i);
-            switch (curChar) {
-                case '\\':
-                    sb.append("\\5c");
-                    break;
-                case '*':
-                    sb.append("\\2a");
-                    break;
-                case '(':
-                    sb.append("\\28");
-                    break;
-                case ')':
-                    sb.append("\\29");
-                    break;
-                case '\u0000':
-                    sb.append("\\00");
-                    break;
-                default:
-                    sb.append(curChar);
-            }
-        }
-        return sb.toString();
-    }
-
-    // Courtesy of OWASP: https://www.owasp.org/index.php/Preventing_LDAP_Injection_in_Java
-    private static String escapeDN(String name) {
-       StringBuilder sb = new StringBuilder();
-       if ((name.length() > 0) && ((name.charAt(0) == ' ') || (name.charAt(0) == '#'))) {
-           sb.append('\\'); // add the leading backslash if needed
-       }
-       for (int i = 0; i < name.length(); i++) {
-           char curChar = name.charAt(i);
-           switch (curChar) {
-               case '\\':
-                   sb.append("\\\\");
-                   break;
-               case ',':
-                   sb.append("\\,");
-                   break;
-               case '+':
-                   sb.append("\\+");
-                   break;
-               case '"':
-                   sb.append("\\\"");
-                   break;
-               case '<':
-                   sb.append("\\<");
-                   break;
-               case '>':
-                   sb.append("\\>");
-                   break;
-               case ';':
-                   sb.append("\\;");
-                   break;
-               default:
-                   sb.append(curChar);
-           }
-       }
-       if ((name.length() > 1) && (name.charAt(name.length() - 1) == ' ')) {
-           sb.insert(sb.length() - 1, '\\'); // add the trailing backslash if needed
-       }
-       return sb.toString();
-   }
-
-
-    @Override
-    public Map<String, GuacamoleConfiguration> getAuthorizedConfigurations(Credentials credentials) throws GuacamoleException {
-
-        try {
-
-            // Require username
-            if (credentials.getUsername() == null) {
-                logger.info("Anonymous bind is not currently allowed by the LDAP authentication provider.");
-                return null;
-            }
-
-            // Require password, and do not allow anonymous binding
-            if (credentials.getPassword() == null
-                    || credentials.getPassword().length() == 0) {
-                logger.info("Anonymous bind is not currently allowed by the LDAP authentication provider.");
-                return null;
-            }
-
-            // Connect to LDAP server
-            LDAPConnection ldapConnection = new LDAPConnection();
-            ldapConnection.connect(
-                    GuacamoleProperties.getRequiredProperty(LDAPGuacamoleProperties.LDAP_HOSTNAME),
-                    GuacamoleProperties.getRequiredProperty(LDAPGuacamoleProperties.LDAP_PORT)
-            );
-
-            // Get username attribute
-            String username_attribute = GuacamoleProperties.getRequiredProperty(
-                LDAPGuacamoleProperties.LDAP_USERNAME_ATTRIBUTE
-            );
-
-            // Get user base DN
-            String user_base_dn = GuacamoleProperties.getRequiredProperty(
-                    LDAPGuacamoleProperties.LDAP_USER_BASE_DN
-            );
-
-            // Construct user DN
-            String user_dn =
-                escapeDN(username_attribute) + "=" + escapeDN(credentials.getUsername())
-                + "," + user_base_dn;
-
-            // Bind as user
-            try {
-                ldapConnection.bind(
-                        LDAPConnection.LDAP_V3,
-                        user_dn,
-                        credentials.getPassword().getBytes("UTF-8")
-                );
-            }
-            catch (UnsupportedEncodingException e) {
-                throw new GuacamoleException(e);
-            }
+    public static final String ROOT_CONNECTION_GROUP = "ROOT";
 
-            // Get config base DN
-            String config_base_dn = GuacamoleProperties.getRequiredProperty(
-                    LDAPGuacamoleProperties.LDAP_CONFIG_BASE_DN
-            );
-
-            // Find all guac configs for this user
-            LDAPSearchResults results = ldapConnection.search(
-                    config_base_dn,
-                    LDAPConnection.SCOPE_SUB,
-                    "(&(objectClass=guacConfigGroup)(member=" + escapeLDAPSearchFilter(user_dn) + "))",
-                    null,
-                    false
-            );
-
-            // Add all configs
-            Map<String, GuacamoleConfiguration> configs = new TreeMap<String, GuacamoleConfiguration>();
-            while (results.hasMore()) {
-
-                LDAPEntry entry = results.next();
-
-                // New empty configuration
-                GuacamoleConfiguration config = new GuacamoleConfiguration();
-
-                // Get CN
-                LDAPAttribute cn = entry.getAttribute("cn");
-                if (cn == null)
-                    throw new GuacamoleException("guacConfigGroup without cn");
-
-                // Get protocol
-                LDAPAttribute protocol = entry.getAttribute("guacConfigProtocol");
-                if (protocol == null)
-                    throw new GuacamoleException("guacConfigGroup without guacConfigProtocol");
-
-                // Set protocol
-                config.setProtocol(protocol.getStringValue());
-
-                // Get parameters, if any
-                LDAPAttribute parameterAttribute = entry.getAttribute("guacConfigParameter");
-                if (parameterAttribute != null) {
-
-                    // For each parameter
-                    Enumeration<String> parameters = parameterAttribute.getStringValues();
-                    while (parameters.hasMoreElements()) {
-
-                        String parameter = parameters.nextElement();
+    /**
+     * Injector which will manage the object graph of this authentication
+     * provider.
+     */
+    private final Injector injector;
 
-                        // Parse parameter
-                        int equals = parameter.indexOf('=');
-                        if (equals != -1) {
+    /**
+     * Creates a new LDAPAuthenticationProvider that authenticates users
+     * against an LDAP directory.
+     *
+     * @throws GuacamoleException
+     *     If a required property is missing, or an error occurs while parsing
+     *     a property.
+     */
+    public LDAPAuthenticationProvider() throws GuacamoleException {
 
-                            // Parse name
-                            String name = parameter.substring(0, equals);
-                            String value = parameter.substring(equals+1);
+        // Set up Guice injector.
+        injector = Guice.createInjector(
+            new LDAPAuthenticationProviderModule(this)
+        );
 
-                            config.setParameter(name, value);
+    }
 
-                        }
+    @Override
+    public String getIdentifier() {
+        return "ldap";
+    }
 
-                    }
+    @Override
+    public AuthenticatedUser authenticateUser(Credentials credentials) throws GuacamoleException {
 
-                }
+        AuthenticationProviderService authProviderService = injector.getInstance(AuthenticationProviderService.class);
+        return authProviderService.authenticateUser(credentials);
 
-                // Store config by CN
-                configs.put(cn.getStringValue(), config);
+    }
 
-            }
+    @Override
+    public AuthenticatedUser updateAuthenticatedUser(AuthenticatedUser authenticatedUser,
+            Credentials credentials) throws GuacamoleException {
+        return authenticatedUser;
+    }
 
-            // Disconnect
-            ldapConnection.disconnect();
-            return configs;
+    @Override
+    public UserContext getUserContext(AuthenticatedUser authenticatedUser)
+            throws GuacamoleException {
 
-        }
-        catch (LDAPException e) {
-            throw new GuacamoleException(e);
-        }
+        AuthenticationProviderService authProviderService = injector.getInstance(AuthenticationProviderService.class);
+        return authProviderService.getUserContext(authenticatedUser);
 
+    }
 
+    @Override
+    public UserContext updateUserContext(UserContext context,
+            AuthenticatedUser authenticatedUser) throws GuacamoleException {
+        return context;
     }
 
 }
diff --git a/extensions/guacamole-auth-ldap/src/main/java/net/sourceforge/guacamole/net/auth/ldap/properties/LDAPGuacamoleProperties.java b/extensions/guacamole-auth-ldap/src/main/java/net/sourceforge/guacamole/net/auth/ldap/properties/LDAPGuacamoleProperties.java
deleted file mode 100644
index a523da8..0000000
--- a/extensions/guacamole-auth-ldap/src/main/java/net/sourceforge/guacamole/net/auth/ldap/properties/LDAPGuacamoleProperties.java
+++ /dev/null
@@ -1,110 +0,0 @@
-
-package net.sourceforge.guacamole.net.auth.ldap.properties;
-
-import org.glyptodon.guacamole.properties.IntegerGuacamoleProperty;
-import org.glyptodon.guacamole.properties.StringGuacamoleProperty;
-
-/* ***** BEGIN LICENSE BLOCK *****
- * Version: MPL 1.1/GPL 2.0/LGPL 2.1
- *
- * The contents of this file are subject to the Mozilla Public License Version
- * 1.1 (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.mozilla.org/MPL/
- *
- * Software distributed under the License is distributed on an "AS IS" basis,
- * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- * for the specific language governing rights and limitations under the
- * License.
- *
- * The Original Code is guacamole-auth-ldap.
- *
- * The Initial Developer of the Original Code is
- * Michael Jumper.
- * Portions created by the Initial Developer are Copyright (C) 2010
- * the Initial Developer. All Rights Reserved.
- *
- * Contributor(s):
- *
- * Alternatively, the contents of this file may be used under the terms of
- * either the GNU General Public License Version 2 or later (the "GPL"), or
- * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
- * in which case the provisions of the GPL or the LGPL are applicable instead
- * of those above. If you wish to allow use of your version of this file only
- * under the terms of either the GPL or the LGPL, and not to allow others to
- * use your version of this file under the terms of the MPL, indicate your
- * decision by deleting the provisions above and replace them with the notice
- * and other provisions required by the GPL or the LGPL. If you do not delete
- * the provisions above, a recipient may use your version of this file under
- * the terms of any one of the MPL, the GPL or the LGPL.
- *
- * ***** END LICENSE BLOCK ***** */
-
-/**
- * Provides properties required for use of the LDAP authentication provider.
- * These properties will be read from guacamole.properties when the LDAP
- * authentication provider is used.
- *
- * @author Michael Jumper
- */
-public class LDAPGuacamoleProperties {
-
-    /**
-     * This class should not be instantiated.
-     */
-    private LDAPGuacamoleProperties() {}
-
-    /**
-     * The base DN to search for Guacamole configurations.
-     */
-    public static final StringGuacamoleProperty LDAP_CONFIG_BASE_DN = new StringGuacamoleProperty() {
-
-        @Override
-        public String getName() { return "ldap-config-base-dn"; }
-
-    };
-
-    /**
-     * The base DN of users. All users must be direct children of this DN,
-     * varying only by LDAP_USERNAME_ATTRIBUTE.
-     */
-    public static final StringGuacamoleProperty LDAP_USER_BASE_DN = new StringGuacamoleProperty() {
-
-        @Override
-        public String getName() { return "ldap-user-base-dn"; }
-
-    };
-
-    /**
-     * The attribute which identifies users. This attribute must be part of
-     * each user's DN such that the concatenation of this attribute and
-     * LDAP_USER_BASE_DN equals the users full DN.
-     */
-    public static final StringGuacamoleProperty LDAP_USERNAME_ATTRIBUTE = new StringGuacamoleProperty() {
-
-        @Override
-        public String getName() { return "ldap-username-attribute"; }
-
-    };
-
-    /**
-     * The port on the LDAP server to connect to when authenticating users.
-     */
-    public static final IntegerGuacamoleProperty LDAP_PORT = new IntegerGuacamoleProperty() {
-
-        @Override
-        public String getName() { return "ldap-port"; }
-
-    };
-
-    /**
-     * The hostname of the LDAP server to connect to when authenticating users.
-     */
-    public static final StringGuacamoleProperty LDAP_HOSTNAME = new StringGuacamoleProperty() {
-
-        @Override
-        public String getName() { return "ldap-hostname"; }
-
-    };
-
-}
diff --git a/extensions/guacamole-auth-ldap/src/main/java/org/glyptodon/guacamole/auth/ldap/AuthenticationProviderService.java b/extensions/guacamole-auth-ldap/src/main/java/org/glyptodon/guacamole/auth/ldap/AuthenticationProviderService.java
new file mode 100644
index 0000000..24dd17f
--- /dev/null
+++ b/extensions/guacamole-auth-ldap/src/main/java/org/glyptodon/guacamole/auth/ldap/AuthenticationProviderService.java
@@ -0,0 +1,283 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.ldap;
+
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.novell.ldap.LDAPConnection;
+import java.util.List;
+import org.glyptodon.guacamole.auth.ldap.user.AuthenticatedUser;
+import org.glyptodon.guacamole.auth.ldap.user.UserContext;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.auth.ldap.user.UserService;
+import org.glyptodon.guacamole.net.auth.Credentials;
+import org.glyptodon.guacamole.net.auth.credentials.CredentialsInfo;
+import org.glyptodon.guacamole.net.auth.credentials.GuacamoleInvalidCredentialsException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Service providing convenience functions for the LDAP AuthenticationProvider
+ * implementation.
+ *
+ * @author Michael Jumper
+ */
+public class AuthenticationProviderService {
+
+    /**
+     * Logger for this class.
+     */
+    private final Logger logger = LoggerFactory.getLogger(AuthenticationProviderService.class);
+
+    /**
+     * Service for creating and managing connections to LDAP servers.
+     */
+    @Inject
+    private LDAPConnectionService ldapService;
+
+    /**
+     * Service for retrieving LDAP server configuration information.
+     */
+    @Inject
+    private ConfigurationService confService;
+
+    /**
+     * Service for retrieving users and their corresponding LDAP DNs.
+     */
+    @Inject
+    private UserService userService;
+
+    /**
+     * Provider for AuthenticatedUser objects.
+     */
+    @Inject
+    private Provider<AuthenticatedUser> authenticatedUserProvider;
+
+    /**
+     * Provider for UserContext objects.
+     */
+    @Inject
+    private Provider<UserContext> userContextProvider;
+
+    /**
+     * Determines the DN which corresponds to the user having the given
+     * username. The DN will either be derived directly from the user base DN,
+     * or queried from the LDAP server, depending on how LDAP authentication
+     * has been configured.
+     *
+     * @param username
+     *     The username of the user whose corresponding DN should be returned.
+     *
+     * @return
+     *     The DN which corresponds to the user having the given username.
+     *
+     * @throws GuacamoleException
+     *     If required properties are missing, and thus the user DN cannot be
+     *     determined.
+     */
+    private String getUserBindDN(String username)
+            throws GuacamoleException {
+
+        // If a search DN is provided, search the LDAP directory for the DN
+        // corresponding to the given username
+        String searchBindDN = confService.getSearchBindDN();
+        if (searchBindDN != null) {
+
+            // Create an LDAP connection using the search account
+            LDAPConnection searchConnection = ldapService.bindAs(
+                searchBindDN,
+                confService.getSearchBindPassword()
+            );
+
+            // Warn of failure to find
+            if (searchConnection == null) {
+                logger.error("Unable to bind using search DN \"{}\"", searchBindDN);
+                return null;
+            }
+
+            try {
+
+                // Retrieve all DNs associated with the given username
+                List<String> userDNs = userService.getUserDNs(searchConnection, username);
+                if (userDNs.isEmpty())
+                    return null;
+
+                // Warn if multiple DNs exist for the same user
+                if (userDNs.size() != 1) {
+                    logger.warn("Multiple DNs possible for user \"{}\": {}", username, userDNs);
+                    return null;
+                }
+
+                // Return the single possible DN
+                return userDNs.get(0);
+
+            }
+
+            // Always disconnect
+            finally {
+                ldapService.disconnect(searchConnection);
+            }
+
+        }
+
+        // Otherwise, derive user DN from base DN
+        return userService.deriveUserDN(username);
+
+    }
+
+    /**
+     * Binds to the LDAP server using the provided Guacamole credentials. The
+     * DN of the user is derived using the LDAP configuration properties
+     * provided in guacamole.properties, as is the server hostname and port
+     * information.
+     *
+     * @param credentials
+     *     The credentials to use to bind to the LDAP server.
+     *
+     * @return
+     *     A bound LDAP connection, or null if the connection could not be
+     *     bound.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while binding to the LDAP server.
+     */
+    private LDAPConnection bindAs(Credentials credentials)
+        throws GuacamoleException {
+
+        // Get username and password from credentials
+        String username = credentials.getUsername();
+        String password = credentials.getPassword();
+
+        // Require username
+        if (username == null || username.isEmpty()) {
+            logger.debug("Anonymous bind is not currently allowed by the LDAP authentication provider.");
+            return null;
+        }
+
+        // Require password, and do not allow anonymous binding
+        if (password == null || password.isEmpty()) {
+            logger.debug("Anonymous bind is not currently allowed by the LDAP authentication provider.");
+            return null;
+        }
+
+        // Determine user DN
+        String userDN = getUserBindDN(username);
+        if (userDN == null) {
+            logger.debug("Unable to determine DN for user \"{}\".", username);
+            return null;
+        }
+
+        // Bind using user's DN
+        return ldapService.bindAs(userDN, password);
+
+    }
+
+    /**
+     * Returns an AuthenticatedUser representing the user authenticated by the
+     * given credentials.
+     *
+     * @param credentials
+     *     The credentials to use for authentication.
+     *
+     * @return
+     *     An AuthenticatedUser representing the user authenticated by the
+     *     given credentials.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while authenticating the user, or if access is
+     *     denied.
+     */
+    public AuthenticatedUser authenticateUser(Credentials credentials)
+            throws GuacamoleException {
+
+        // Attempt bind
+        LDAPConnection ldapConnection;
+        try {
+            ldapConnection = bindAs(credentials);
+        }
+        catch (GuacamoleException e) {
+            logger.error("Cannot bind with LDAP server: {}", e.getMessage());
+            logger.debug("Error binding with LDAP server.", e);
+            ldapConnection = null;
+        }
+
+        // If bind fails, permission to login is denied
+        if (ldapConnection == null)
+            throw new GuacamoleInvalidCredentialsException("Permission denied.", CredentialsInfo.USERNAME_PASSWORD);
+
+        try {
+
+            // Return AuthenticatedUser if bind succeeds
+            AuthenticatedUser authenticatedUser = authenticatedUserProvider.get();
+            authenticatedUser.init(credentials);
+            return authenticatedUser;
+
+        }
+
+        // Always disconnect
+        finally {
+            ldapService.disconnect(ldapConnection);
+        }
+
+    }
+
+    /**
+     * Returns a UserContext object initialized with data accessible to the
+     * given AuthenticatedUser.
+     *
+     * @param authenticatedUser
+     *     The AuthenticatedUser to retrieve data for.
+     *
+     * @return
+     *     A UserContext object initialized with data accessible to the given
+     *     AuthenticatedUser.
+     *
+     * @throws GuacamoleException
+     *     If the UserContext cannot be created due to an error.
+     */
+    public UserContext getUserContext(org.glyptodon.guacamole.net.auth.AuthenticatedUser authenticatedUser)
+            throws GuacamoleException {
+
+        // Bind using credentials associated with AuthenticatedUser
+        Credentials credentials = authenticatedUser.getCredentials();
+        LDAPConnection ldapConnection = bindAs(credentials);
+        if (ldapConnection == null)
+            return null;
+
+        try {
+
+            // Build user context by querying LDAP
+            UserContext userContext = userContextProvider.get();
+            userContext.init(authenticatedUser, ldapConnection);
+            return userContext;
+
+        }
+
+        // Always disconnect
+        finally {
+            ldapService.disconnect(ldapConnection);
+        }
+
+    }
+
+}
diff --git a/extensions/guacamole-auth-ldap/src/main/java/org/glyptodon/guacamole/auth/ldap/ConfigurationService.java b/extensions/guacamole-auth-ldap/src/main/java/org/glyptodon/guacamole/auth/ldap/ConfigurationService.java
new file mode 100644
index 0000000..ae4a90a
--- /dev/null
+++ b/extensions/guacamole-auth-ldap/src/main/java/org/glyptodon/guacamole/auth/ldap/ConfigurationService.java
@@ -0,0 +1,194 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.ldap;
+
+import com.google.inject.Inject;
+import java.util.Collections;
+import java.util.List;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.environment.Environment;
+
+/**
+ * Service for retrieving configuration information regarding the LDAP server.
+ *
+ * @author Michael Jumper
+ */
+public class ConfigurationService {
+
+    /**
+     * The Guacamole server environment.
+     */
+    @Inject
+    private Environment environment;
+
+    /**
+     * Returns the hostname of the LDAP server as configured with
+     * guacamole.properties. By default, this will be "localhost".
+     *
+     * @return
+     *     The hostname of the LDAP server, as configured with
+     *     guacamole.properties.
+     *
+     * @throws GuacamoleException
+     *     If guacamole.properties cannot be parsed.
+     */
+    public String getServerHostname() throws GuacamoleException {
+        return environment.getProperty(
+            LDAPGuacamoleProperties.LDAP_HOSTNAME,
+            "localhost"
+        );
+    }
+
+    /**
+     * Returns the port of the LDAP server configured with
+     * guacamole.properties. The default value depends on which encryption
+     * method is being used. For unencrypted LDAP and STARTTLS, this will be
+     * 389. For LDAPS (LDAP over SSL) this will be 636.
+     *
+     * @return
+     *     The port of the LDAP server, as configured with
+     *     guacamole.properties.
+     *
+     * @throws GuacamoleException
+     *     If guacamole.properties cannot be parsed.
+     */
+    public int getServerPort() throws GuacamoleException {
+        return environment.getProperty(
+            LDAPGuacamoleProperties.LDAP_PORT,
+            getEncryptionMethod().DEFAULT_PORT
+        );
+    }
+
+    /**
+     * Returns all username attributes which should be used to query and bind
+     * users using the LDAP directory. By default, this will be "uid" - a
+     * common attribute used for this purpose.
+     *
+     * @return
+     *     The username attributes which should be used to query and bind users
+     *     using the LDAP directory.
+     *
+     * @throws GuacamoleException
+     *     If guacamole.properties cannot be parsed.
+     */
+    public List<String> getUsernameAttributes() throws GuacamoleException {
+        return environment.getProperty(
+            LDAPGuacamoleProperties.LDAP_USERNAME_ATTRIBUTE,
+            Collections.singletonList("uid")
+        );
+    }
+
+    /**
+     * Returns the base DN under which all Guacamole users will be stored
+     * within the LDAP directory.
+     *
+     * @return
+     *     The base DN under which all Guacamole users will be stored within
+     *     the LDAP directory.
+     *
+     * @throws GuacamoleException
+     *     If guacamole.properties cannot be parsed, or if the user base DN
+     *     property is not specified.
+     */
+    public String getUserBaseDN() throws GuacamoleException {
+        return environment.getRequiredProperty(
+            LDAPGuacamoleProperties.LDAP_USER_BASE_DN
+        );
+    }
+
+    /**
+     * Returns the base DN under which all Guacamole configurations
+     * (connections) will be stored within the LDAP directory. If Guacamole
+     * configurations will not be stored within LDAP, null is returned.
+     *
+     * @return
+     *     The base DN under which all Guacamole configurations will be stored
+     *     within the LDAP directory, or null if no Guacamole configurations
+     *     will be stored within the LDAP directory.
+     *
+     * @throws GuacamoleException
+     *     If guacamole.properties cannot be parsed.
+     */
+    public String getConfigurationBaseDN() throws GuacamoleException {
+        return environment.getProperty(
+            LDAPGuacamoleProperties.LDAP_CONFIG_BASE_DN
+        );
+    }
+
+    /**
+     * Returns the DN that should be used when searching for the DNs of users
+     * attempting to authenticate. If no such search should be performed, null
+     * is returned.
+     *
+     * @return
+     *     The DN that should be used when searching for the DNs of users
+     *     attempting to authenticate, or null if no such search should be
+     *     performed.
+     *
+     * @throws GuacamoleException
+     *     If guacamole.properties cannot be parsed.
+     */
+    public String getSearchBindDN() throws GuacamoleException {
+        return environment.getProperty(
+            LDAPGuacamoleProperties.LDAP_SEARCH_BIND_DN
+        );
+    }
+
+    /**
+     * Returns the password that should be used when binding to the LDAP server
+     * using the DN returned by getSearchBindDN(). If no password should be
+     * used, null is returned.
+     *
+     * @return
+     *     The password that should be used when binding to the LDAP server
+     *     using the DN returned by getSearchBindDN(), or null if no password
+     *     should be used.
+     *
+     * @throws GuacamoleException
+     *     If guacamole.properties cannot be parsed.
+     */
+    public String getSearchBindPassword() throws GuacamoleException {
+        return environment.getProperty(
+            LDAPGuacamoleProperties.LDAP_SEARCH_BIND_PASSWORD
+        );
+    }
+
+    /**
+     * Returns the encryption method that should be used when connecting to the
+     * LDAP server. By default, no encryption is used.
+     *
+     * @return
+     *     The encryption method that should be used when connecting to the
+     *     LDAP server.
+     *
+     * @throws GuacamoleException
+     *     If guacamole.properties cannot be parsed.
+     */
+    public EncryptionMethod getEncryptionMethod() throws GuacamoleException {
+        return environment.getProperty(
+            LDAPGuacamoleProperties.LDAP_ENCRYPTION_METHOD,
+            EncryptionMethod.NONE
+        );
+    }
+
+}
diff --git a/extensions/guacamole-auth-ldap/src/main/java/org/glyptodon/guacamole/auth/ldap/EncryptionMethod.java b/extensions/guacamole-auth-ldap/src/main/java/org/glyptodon/guacamole/auth/ldap/EncryptionMethod.java
new file mode 100644
index 0000000..94c112e
--- /dev/null
+++ b/extensions/guacamole-auth-ldap/src/main/java/org/glyptodon/guacamole/auth/ldap/EncryptionMethod.java
@@ -0,0 +1,69 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.ldap;
+
+/**
+ * All possible encryption methods which may be used when connecting to an LDAP
+ * server.
+ *
+ * @author Michael Jumper
+ */
+public enum EncryptionMethod {
+
+    /**
+     * No encryption will be used. All data will be sent to the LDAP server in
+     * plaintext. Unencrypted LDAP connections use port 389 by default.
+     */
+    NONE(389),
+
+    /**
+     * The connection to the LDAP server will be encrypted with SSL. LDAP over
+     * SSL (LDAPS) will use port 636 by default.
+     */
+    SSL(636),
+
+    /**
+     * The connection to the LDAP server will be encrypted using STARTTLS. TLS
+     * connections are negotiated over the standard LDAP port of 389 - the same
+     * port used for unencrypted traffic.
+     */
+    STARTTLS(389);
+
+    /**
+     * The default port of this specific encryption method. As with most
+     * protocols, the default port for LDAP varies by whether SSL is used.
+     */
+    public final int DEFAULT_PORT;
+
+    /**
+     * Initializes this encryption method such that it is associated with the
+     * given default port.
+     *
+     * @param defaultPort
+     *     The default port to associate with this encryption method.
+     */
+    private EncryptionMethod(int defaultPort) {
+        this.DEFAULT_PORT = defaultPort;
+    }
+
+}
diff --git a/extensions/guacamole-auth-ldap/src/main/java/org/glyptodon/guacamole/auth/ldap/EncryptionMethodProperty.java b/extensions/guacamole-auth-ldap/src/main/java/org/glyptodon/guacamole/auth/ldap/EncryptionMethodProperty.java
new file mode 100644
index 0000000..bd41cc2
--- /dev/null
+++ b/extensions/guacamole-auth-ldap/src/main/java/org/glyptodon/guacamole/auth/ldap/EncryptionMethodProperty.java
@@ -0,0 +1,63 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.ldap;
+
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.GuacamoleServerException;
+import org.glyptodon.guacamole.properties.GuacamoleProperty;
+
+/**
+ * A GuacamoleProperty whose value is an EncryptionMethod. The string values
+ * "none", "ssl", and "starttls" are each parsed to their corresponding values
+ * within the EncryptionMethod enum. All other string values result in parse
+ * errors.
+ *
+ * @author Michael Jumper
+ */
+public abstract class EncryptionMethodProperty implements GuacamoleProperty<EncryptionMethod> {
+
+    @Override
+    public EncryptionMethod parseValue(String value) throws GuacamoleException {
+
+        // If no value provided, return null.
+        if (value == null)
+            return null;
+
+        // Plaintext (no encryption)
+        if (value.equals("none"))
+            return EncryptionMethod.NONE;
+
+        // SSL
+        if (value.equals("ssl"))
+            return EncryptionMethod.SSL;
+
+        // STARTTLS
+        if (value.equals("starttls"))
+            return EncryptionMethod.STARTTLS;
+
+        // The provided value is not legal
+        throw new GuacamoleServerException("Encryption method must be one of \"none\", \"ssl\", or \"starttls\".");
+
+    }
+
+}
diff --git a/extensions/guacamole-auth-ldap/src/main/java/org/glyptodon/guacamole/auth/ldap/EscapingService.java b/extensions/guacamole-auth-ldap/src/main/java/org/glyptodon/guacamole/auth/ldap/EscapingService.java
new file mode 100644
index 0000000..925ba24
--- /dev/null
+++ b/extensions/guacamole-auth-ldap/src/main/java/org/glyptodon/guacamole/auth/ldap/EscapingService.java
@@ -0,0 +1,125 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.ldap;
+
+/**
+ * Service for escaping LDAP filters, distinguished names (DN's), etc.
+ *
+ * @author Michael Jumper
+ */
+public class EscapingService {
+
+    /**
+     * Escapes the given string for use within an LDAP search filter. This
+     * implementation is provided courtesy of OWASP:
+     * 
+     * https://www.owasp.org/index.php/Preventing_LDAP_Injection_in_Java
+     *
+     * @param filter
+     *     The string to escape such that it has no special meaning within an
+     *     LDAP search filter.
+     *
+     * @return
+     *     The escaped string, safe for use within an LDAP search filter.
+     */
+    public String escapeLDAPSearchFilter(String filter) {
+        StringBuilder sb = new StringBuilder();
+        for (int i = 0; i < filter.length(); i++) {
+            char curChar = filter.charAt(i);
+            switch (curChar) {
+                case '\\':
+                    sb.append("\\5c");
+                    break;
+                case '*':
+                    sb.append("\\2a");
+                    break;
+                case '(':
+                    sb.append("\\28");
+                    break;
+                case ')':
+                    sb.append("\\29");
+                    break;
+                case '\u0000':
+                    sb.append("\\00");
+                    break;
+                default:
+                    sb.append(curChar);
+            }
+        }
+        return sb.toString();
+    }
+
+    /**
+     * Escapes the given string such that it is safe for use within an LDAP
+     * distinguished name (DN). This implementation is provided courtesy of
+     * OWASP:
+     * 
+     * https://www.owasp.org/index.php/Preventing_LDAP_Injection_in_Java
+     *
+     * @param name
+     *     The string to escape such that it has no special meaning within an
+     *     LDAP DN.
+     *
+     * @return
+     *     The escaped string, safe for use within an LDAP DN.
+     */
+    public String escapeDN(String name) {
+        StringBuilder sb = new StringBuilder();
+        if ((name.length() > 0) && ((name.charAt(0) == ' ') || (name.charAt(0) == '#'))) {
+            sb.append('\\'); // add the leading backslash if needed
+        }
+        for (int i = 0; i < name.length(); i++) {
+            char curChar = name.charAt(i);
+            switch (curChar) {
+                case '\\':
+                    sb.append("\\\\");
+                    break;
+                case ',':
+                    sb.append("\\,");
+                    break;
+                case '+':
+                    sb.append("\\+");
+                    break;
+                case '"':
+                    sb.append("\\\"");
+                    break;
+                case '<':
+                    sb.append("\\<");
+                    break;
+                case '>':
+                    sb.append("\\>");
+                    break;
+                case ';':
+                    sb.append("\\;");
+                    break;
+                default:
+                    sb.append(curChar);
+            }
+        }
+        if ((name.length() > 1) && (name.charAt(name.length() - 1) == ' ')) {
+            sb.insert(sb.length() - 1, '\\'); // add the trailing backslash if needed
+        }
+        return sb.toString();
+    }
+
+}
diff --git a/extensions/guacamole-auth-ldap/src/main/java/org/glyptodon/guacamole/auth/ldap/LDAPAuthenticationProviderModule.java b/extensions/guacamole-auth-ldap/src/main/java/org/glyptodon/guacamole/auth/ldap/LDAPAuthenticationProviderModule.java
new file mode 100644
index 0000000..c0133ba
--- /dev/null
+++ b/extensions/guacamole-auth-ldap/src/main/java/org/glyptodon/guacamole/auth/ldap/LDAPAuthenticationProviderModule.java
@@ -0,0 +1,89 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.ldap;
+
+import com.google.inject.AbstractModule;
+import org.glyptodon.guacamole.auth.ldap.connection.ConnectionService;
+import org.glyptodon.guacamole.auth.ldap.user.UserService;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.environment.Environment;
+import org.glyptodon.guacamole.environment.LocalEnvironment;
+import org.glyptodon.guacamole.net.auth.AuthenticationProvider;
+
+/**
+ * Guice module which configures LDAP-specific injections.
+ *
+ * @author Michael Jumper
+ */
+public class LDAPAuthenticationProviderModule extends AbstractModule {
+
+    /**
+     * Guacamole server environment.
+     */
+    private final Environment environment;
+
+    /**
+     * A reference to the LDAPAuthenticationProvider on behalf of which this
+     * module has configured injection.
+     */
+    private final AuthenticationProvider authProvider;
+
+    /**
+     * Creates a new LDAP authentication provider module which configures
+     * injection for the LDAPAuthenticationProvider.
+     *
+     * @param authProvider
+     *     The AuthenticationProvider for which injection is being configured.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while retrieving the Guacamole server
+     *     environment.
+     */
+    public LDAPAuthenticationProviderModule(AuthenticationProvider authProvider)
+            throws GuacamoleException {
+
+        // Get local environment
+        this.environment = new LocalEnvironment();
+
+        // Store associated auth provider
+        this.authProvider = authProvider;
+
+    }
+
+    @Override
+    protected void configure() {
+
+        // Bind core implementations of guacamole-ext classes
+        bind(AuthenticationProvider.class).toInstance(authProvider);
+        bind(Environment.class).toInstance(environment);
+
+        // Bind LDAP-specific services
+        bind(ConfigurationService.class);
+        bind(ConnectionService.class);
+        bind(EscapingService.class);
+        bind(LDAPConnectionService.class);
+        bind(UserService.class);
+
+    }
+
+}
diff --git a/extensions/guacamole-auth-ldap/src/main/java/org/glyptodon/guacamole/auth/ldap/LDAPConnectionService.java b/extensions/guacamole-auth-ldap/src/main/java/org/glyptodon/guacamole/auth/ldap/LDAPConnectionService.java
new file mode 100644
index 0000000..27f3edd
--- /dev/null
+++ b/extensions/guacamole-auth-ldap/src/main/java/org/glyptodon/guacamole/auth/ldap/LDAPConnectionService.java
@@ -0,0 +1,196 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.ldap;
+
+import com.google.inject.Inject;
+import com.novell.ldap.LDAPConnection;
+import com.novell.ldap.LDAPException;
+import com.novell.ldap.LDAPJSSESecureSocketFactory;
+import com.novell.ldap.LDAPJSSEStartTLSFactory;
+import java.io.UnsupportedEncodingException;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.GuacamoleUnsupportedException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Service for creating and managing connections to LDAP servers.
+ *
+ * @author Michael Jumper
+ */
+public class LDAPConnectionService {
+
+    /**
+     * Logger for this class.
+     */
+    private final Logger logger = LoggerFactory.getLogger(LDAPConnectionService.class);
+
+    /**
+     * Service for retrieving LDAP server configuration information.
+     */
+    @Inject
+    private ConfigurationService confService;
+
+    /**
+     * Creates a new instance of LDAPConnection, configured as required to use
+     * whichever encryption method is requested within guacamole.properties.
+     *
+     * @return
+     *     A new LDAPConnection instance which has already been configured to
+     *     use the encryption method requested within guacamole.properties.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while parsing guacamole.properties, or if the
+     *     requested encryption method is actually not implemented (a bug).
+     */
+    private LDAPConnection createLDAPConnection() throws GuacamoleException {
+
+        // Map encryption method to proper connection and socket factory
+        EncryptionMethod encryptionMethod = confService.getEncryptionMethod();
+        switch (encryptionMethod) {
+
+            // Unencrypted LDAP connection
+            case NONE:
+                logger.debug("Connection to LDAP server without encryption.");
+                return new LDAPConnection();
+
+            // LDAP over SSL (LDAPS)
+            case SSL:
+                logger.debug("Connecting to LDAP server using SSL/TLS.");
+                return new LDAPConnection(new LDAPJSSESecureSocketFactory());
+
+            // LDAP + STARTTLS
+            case STARTTLS:
+                logger.debug("Connecting to LDAP server using STARTTLS.");
+                return new LDAPConnection(new LDAPJSSEStartTLSFactory());
+
+            // The encryption method, though known, is not actually
+            // implemented. If encountered, this would be a bug.
+            default:
+                throw new GuacamoleUnsupportedException("Unimplemented encryption method: " + encryptionMethod);
+
+        }
+
+    }
+
+    /**
+     * Binds to the LDAP server using the provided user DN and password.
+     *
+     * @param userDN
+     *     The DN of the user to bind as, or null to bind anonymously.
+     *
+     * @param password
+     *     The password to use when binding as the specified user, or null to
+     *     attempt to bind without a password.
+     *
+     * @return
+     *     A bound LDAP connection, or null if the connection could not be
+     *     bound.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while binding to the LDAP server.
+     */
+    public LDAPConnection bindAs(String userDN, String password)
+            throws GuacamoleException {
+
+        // Obtain appropriately-configured LDAPConnection instance
+        LDAPConnection ldapConnection = createLDAPConnection();
+
+        try {
+
+            // Connect to LDAP server
+            ldapConnection.connect(
+                confService.getServerHostname(),
+                confService.getServerPort()
+            );
+
+            // Explicitly start TLS if requested
+            if (confService.getEncryptionMethod() == EncryptionMethod.STARTTLS)
+                ldapConnection.startTLS();
+
+        }
+        catch (LDAPException e) {
+            logger.error("Unable to connect to LDAP server: {}", e.getMessage());
+            logger.debug("Failed to connect to LDAP server.", e);
+            return null;
+        }
+
+        // Bind using provided credentials
+        try {
+
+            byte[] passwordBytes;
+            try {
+
+                // Convert password into corresponding byte array
+                if (password != null)
+                    passwordBytes = password.getBytes("UTF-8");
+                else
+                    passwordBytes = null;
+
+            }
+            catch (UnsupportedEncodingException e) {
+                logger.error("Unexpected lack of support for UTF-8: {}", e.getMessage());
+                logger.debug("Support for UTF-8 (as required by Java spec) not found.", e);
+                disconnect(ldapConnection);
+                return null;
+            }
+
+            // Bind as user
+            ldapConnection.bind(LDAPConnection.LDAP_V3, userDN, passwordBytes);
+
+        }
+
+        // Disconnect if an error occurs during bind
+        catch (LDAPException e) {
+            logger.debug("LDAP bind failed.", e);
+            disconnect(ldapConnection);
+            return null;
+        }
+
+        return ldapConnection;
+
+    }
+
+    /**
+     * Disconnects the given LDAP connection, logging any failure to do so
+     * appropriately.
+     *
+     * @param ldapConnection
+     *     The LDAP connection to disconnect.
+     */
+    public void disconnect(LDAPConnection ldapConnection) {
+
+        // Attempt disconnect
+        try {
+            ldapConnection.disconnect();
+        }
+
+        // Warn if disconnect unexpectedly fails
+        catch (LDAPException e) {
+            logger.warn("Unable to disconnect from LDAP server: {}", e.getMessage());
+            logger.debug("LDAP disconnect failed.", e);
+        }
+
+    }
+
+}
diff --git a/extensions/guacamole-auth-ldap/src/main/java/org/glyptodon/guacamole/auth/ldap/LDAPGuacamoleProperties.java b/extensions/guacamole-auth-ldap/src/main/java/org/glyptodon/guacamole/auth/ldap/LDAPGuacamoleProperties.java
new file mode 100644
index 0000000..283584e
--- /dev/null
+++ b/extensions/guacamole-auth-ldap/src/main/java/org/glyptodon/guacamole/auth/ldap/LDAPGuacamoleProperties.java
@@ -0,0 +1,139 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.ldap;
+
+import org.glyptodon.guacamole.properties.IntegerGuacamoleProperty;
+import org.glyptodon.guacamole.properties.StringGuacamoleProperty;
+
+
+/**
+ * Provides properties required for use of the LDAP authentication provider.
+ * These properties will be read from guacamole.properties when the LDAP
+ * authentication provider is used.
+ *
+ * @author Michael Jumper
+ */
+public class LDAPGuacamoleProperties {
+
+    /**
+     * This class should not be instantiated.
+     */
+    private LDAPGuacamoleProperties() {}
+
+    /**
+     * The base DN to search for Guacamole configurations.
+     */
+    public static final StringGuacamoleProperty LDAP_CONFIG_BASE_DN = new StringGuacamoleProperty() {
+
+        @Override
+        public String getName() { return "ldap-config-base-dn"; }
+
+    };
+
+    /**
+     * The base DN of users. All users must be contained somewhere within the
+     * subtree of this DN. If the LDAP authentication will not be given its own
+     * credentials for querying other LDAP users, all users must be direct
+     * children of this base DN, varying only by LDAP_USERNAME_ATTRIBUTE.
+     */
+    public static final StringGuacamoleProperty LDAP_USER_BASE_DN = new StringGuacamoleProperty() {
+
+        @Override
+        public String getName() { return "ldap-user-base-dn"; }
+
+    };
+
+    /**
+     * The attribute or attributes which identify users. One of these
+     * attributes must be present within each Guacamole user's record in the
+     * LDAP directory. If the LDAP authentication will not be given its own
+     * credentials for querying other LDAP users, this list may contain only
+     * one attribute, and the concatenation of that attribute and the value of
+     * LDAP_USER_BASE_DN must equal the user's full DN.
+     */
+    public static final StringListProperty LDAP_USERNAME_ATTRIBUTE = new StringListProperty() {
+
+        @Override
+        public String getName() { return "ldap-username-attribute"; }
+
+    };
+
+    /**
+     * The port on the LDAP server to connect to when authenticating users.
+     */
+    public static final IntegerGuacamoleProperty LDAP_PORT = new IntegerGuacamoleProperty() {
+
+        @Override
+        public String getName() { return "ldap-port"; }
+
+    };
+
+    /**
+     * The hostname of the LDAP server to connect to when authenticating users.
+     */
+    public static final StringGuacamoleProperty LDAP_HOSTNAME = new StringGuacamoleProperty() {
+
+        @Override
+        public String getName() { return "ldap-hostname"; }
+
+    };
+
+    /**
+     * The DN of the user that the LDAP authentication should bind as when
+     * searching for the user accounts of users attempting to log in. If not
+     * specified, the DNs of users attempting to log in will be derived from
+     * the LDAP_BASE_DN and LDAP_USERNAME_ATTRIBUTE directly.
+     */
+    public static final StringGuacamoleProperty LDAP_SEARCH_BIND_DN = new StringGuacamoleProperty() {
+
+        @Override
+        public String getName() { return "ldap-search-bind-dn"; }
+
+    };
+
+    /**
+     * The password to provide to the LDAP server when binding as
+     * LDAP_SEARCH_BIND_DN. If LDAP_SEARCH_BIND_DN is not specified, this
+     * property has no effect. If this property is not specified, no password
+     * will be provided when attempting to bind as LDAP_SEARCH_BIND_DN.
+     */
+    public static final StringGuacamoleProperty LDAP_SEARCH_BIND_PASSWORD = new StringGuacamoleProperty() {
+
+        @Override
+        public String getName() { return "ldap-search-bind-password"; }
+
+    };
+
+    /**
+     * The encryption method to use when connecting to the LDAP server, if any.
+     * The chosen method will also dictate the default port if not already
+     * explicitly specified via LDAP_PORT.
+     */
+    public static final EncryptionMethodProperty LDAP_ENCRYPTION_METHOD = new EncryptionMethodProperty() {
+
+        @Override
+        public String getName() { return "ldap-encryption-method"; }
+
+    };
+
+}
diff --git a/extensions/guacamole-auth-ldap/src/main/java/org/glyptodon/guacamole/auth/ldap/StringListProperty.java b/extensions/guacamole-auth-ldap/src/main/java/org/glyptodon/guacamole/auth/ldap/StringListProperty.java
new file mode 100644
index 0000000..e545e56
--- /dev/null
+++ b/extensions/guacamole-auth-ldap/src/main/java/org/glyptodon/guacamole/auth/ldap/StringListProperty.java
@@ -0,0 +1,67 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.ldap;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.regex.Pattern;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.properties.GuacamoleProperty;
+
+/**
+ * A GuacamoleProperty whose value is a List of Strings. The string value
+ * parsed to produce this list is a comma-delimited list. Duplicate values are
+ * ignored, as is any whitespace following delimiters. To maintain
+ * compatibility with the behavior of Java properties in general, only
+ * whitespace at the beginning of each value is ignored; trailing whitespace
+ * becomes part of the value.
+ *
+ * @author Michael Jumper
+ */
+public abstract class StringListProperty implements GuacamoleProperty<List<String>> {
+
+    /**
+     * A pattern which matches against the delimiters between values. This is
+     * currently simply a comma and any following whitespace. Parts of the
+     * input string which match this pattern will not be included in the parsed
+     * result.
+     */
+    private static final Pattern DELIMITER_PATTERN = Pattern.compile(",\\s*");
+
+    @Override
+    public List<String> parseValue(String values) throws GuacamoleException {
+
+        // If no property provided, return null.
+        if (values == null)
+            return null;
+
+        // Split string into a list of individual values
+        List<String> stringValues = Arrays.asList(DELIMITER_PATTERN.split(values));
+        if (stringValues.isEmpty())
+            return null;
+
+        return stringValues;
+
+    }
+
+}
diff --git a/extensions/guacamole-auth-ldap/src/main/java/org/glyptodon/guacamole/auth/ldap/connection/ConnectionService.java b/extensions/guacamole-auth-ldap/src/main/java/org/glyptodon/guacamole/auth/ldap/connection/ConnectionService.java
new file mode 100644
index 0000000..9d2abe0
--- /dev/null
+++ b/extensions/guacamole-auth-ldap/src/main/java/org/glyptodon/guacamole/auth/ldap/connection/ConnectionService.java
@@ -0,0 +1,198 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.ldap.connection;
+
+import com.google.inject.Inject;
+import com.novell.ldap.LDAPAttribute;
+import com.novell.ldap.LDAPConnection;
+import com.novell.ldap.LDAPEntry;
+import com.novell.ldap.LDAPException;
+import com.novell.ldap.LDAPSearchResults;
+import java.util.Collections;
+import java.util.Enumeration;
+import java.util.HashMap;
+import java.util.Map;
+import net.sourceforge.guacamole.net.auth.ldap.LDAPAuthenticationProvider;
+import org.glyptodon.guacamole.auth.ldap.ConfigurationService;
+import org.glyptodon.guacamole.auth.ldap.EscapingService;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.GuacamoleServerException;
+import org.glyptodon.guacamole.net.auth.AuthenticatedUser;
+import org.glyptodon.guacamole.net.auth.Connection;
+import org.glyptodon.guacamole.net.auth.simple.SimpleConnection;
+import org.glyptodon.guacamole.protocol.GuacamoleConfiguration;
+import org.glyptodon.guacamole.token.StandardTokens;
+import org.glyptodon.guacamole.token.TokenFilter;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Service for querying the connections available to a particular Guacamole
+ * user according to an LDAP directory.
+ *
+ * @author Michael Jumper
+ */
+public class ConnectionService {
+
+    /**
+     * Logger for this class.
+     */
+    private final Logger logger = LoggerFactory.getLogger(ConnectionService.class);
+
+    /**
+     * Service for escaping parts of LDAP queries.
+     */
+    @Inject
+    private EscapingService escapingService;
+
+    /**
+     * Service for retrieving LDAP server configuration information.
+     */
+    @Inject
+    private ConfigurationService confService;
+
+    /**
+     * Returns all Guacamole connections accessible to the user currently bound
+     * under the given LDAP connection.
+     *
+     * @param user
+     *     The AuthenticatedUser object associated with the user who is
+     *     currently authenticated with Guacamole.
+     *
+     * @param ldapConnection
+     *     The current connection to the LDAP server, associated with the
+     *     current user.
+     *
+     * @return
+     *     All connections accessible to the user currently bound under the
+     *     given LDAP connection, as a map of connection identifier to
+     *     corresponding connection object.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs preventing retrieval of connections.
+     */
+    public Map<String, Connection> getConnections(AuthenticatedUser user,
+            LDAPConnection ldapConnection) throws GuacamoleException {
+
+        // Do not return any connections if base DN is not specified
+        String configurationBaseDN = confService.getConfigurationBaseDN();
+        if (configurationBaseDN == null)
+            return Collections.<String, Connection>emptyMap();
+
+        try {
+
+            // Pull the current user DN from the LDAP connection
+            String userDN = ldapConnection.getAuthenticationDN();
+
+            // getConnections() will only be called after a connection has been
+            // authenticated (via non-anonymous bind), thus userDN cannot
+            // possibly be null
+            assert(userDN != null);
+
+            // Find all Guacamole connections for the given user
+            LDAPSearchResults results = ldapConnection.search(
+                configurationBaseDN,
+                LDAPConnection.SCOPE_SUB,
+                "(&(objectClass=guacConfigGroup)(member=" + escapingService.escapeLDAPSearchFilter(userDN) + "))",
+                null,
+                false
+            );
+
+            // Build token filter containing credential tokens
+            TokenFilter tokenFilter = new TokenFilter();
+            StandardTokens.addStandardTokens(tokenFilter, user.getCredentials());
+
+            // Produce connections for each readable configuration
+            Map<String, Connection> connections = new HashMap<String, Connection>();
+            while (results.hasMore()) {
+
+                LDAPEntry entry = results.next();
+
+                // Get common name (CN)
+                LDAPAttribute cn = entry.getAttribute("cn");
+                if (cn == null) {
+                    logger.warn("guacConfigGroup is missing a cn.");
+                    continue;
+                }
+
+                // Get associated protocol
+                LDAPAttribute protocol = entry.getAttribute("guacConfigProtocol");
+                if (protocol == null) {
+                    logger.warn("guacConfigGroup \"{}\" is missing the "
+                              + "required \"guacConfigProtocol\" attribute.",
+                            cn.getStringValue());
+                    continue;
+                }
+
+                // Set protocol
+                GuacamoleConfiguration config = new GuacamoleConfiguration();
+                config.setProtocol(protocol.getStringValue());
+
+                // Get parameters, if any
+                LDAPAttribute parameterAttribute = entry.getAttribute("guacConfigParameter");
+                if (parameterAttribute != null) {
+
+                    // For each parameter
+                    Enumeration<?> parameters = parameterAttribute.getStringValues();
+                    while (parameters.hasMoreElements()) {
+
+                        String parameter = (String) parameters.nextElement();
+
+                        // Parse parameter
+                        int equals = parameter.indexOf('=');
+                        if (equals != -1) {
+
+                            // Parse name
+                            String name = parameter.substring(0, equals);
+                            String value = parameter.substring(equals+1);
+
+                            config.setParameter(name, value);
+
+                        }
+
+                    }
+
+                }
+
+                // Filter the configuration, substituting all defined tokens
+                tokenFilter.filterValues(config.getParameters());
+
+                // Store connection using cn for both identifier and name
+                String name = cn.getStringValue();
+                Connection connection = new SimpleConnection(name, name, config);
+                connection.setParentIdentifier(LDAPAuthenticationProvider.ROOT_CONNECTION_GROUP);
+                connections.put(name, connection);
+
+            }
+
+            // Return map of all connections
+            return connections;
+
+        }
+        catch (LDAPException e) {
+            throw new GuacamoleServerException("Error while querying for connections.", e);
+        }
+
+    }
+
+}
diff --git a/extensions/guacamole-auth-ldap/src/main/java/org/glyptodon/guacamole/auth/ldap/user/AuthenticatedUser.java b/extensions/guacamole-auth-ldap/src/main/java/org/glyptodon/guacamole/auth/ldap/user/AuthenticatedUser.java
new file mode 100644
index 0000000..e594193
--- /dev/null
+++ b/extensions/guacamole-auth-ldap/src/main/java/org/glyptodon/guacamole/auth/ldap/user/AuthenticatedUser.java
@@ -0,0 +1,71 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.ldap.user;
+
+import com.google.inject.Inject;
+import org.glyptodon.guacamole.net.auth.AbstractAuthenticatedUser;
+import org.glyptodon.guacamole.net.auth.AuthenticationProvider;
+import org.glyptodon.guacamole.net.auth.Credentials;
+
+/**
+ * An LDAP-specific implementation of AuthenticatedUser, associating a
+ * particular set of credentials with the LDAP authentication provider.
+ *
+ * @author Michael Jumper
+ */
+public class AuthenticatedUser extends AbstractAuthenticatedUser {
+
+    /**
+     * Reference to the authentication provider associated with this
+     * authenticated user.
+     */
+    @Inject
+    private AuthenticationProvider authProvider;
+
+    /**
+     * The credentials provided when this user was authenticated.
+     */
+    private Credentials credentials;
+
+    /**
+     * Initializes this AuthenticatedUser using the given credentials.
+     *
+     * @param credentials
+     *     The credentials provided when this user was authenticated.
+     */
+    public void init(Credentials credentials) {
+        this.credentials = credentials;
+        setIdentifier(credentials.getUsername());
+    }
+
+    @Override
+    public AuthenticationProvider getAuthenticationProvider() {
+        return authProvider;
+    }
+
+    @Override
+    public Credentials getCredentials() {
+        return credentials;
+    }
+
+}
diff --git a/extensions/guacamole-auth-ldap/src/main/java/org/glyptodon/guacamole/auth/ldap/user/UserContext.java b/extensions/guacamole-auth-ldap/src/main/java/org/glyptodon/guacamole/auth/ldap/user/UserContext.java
new file mode 100644
index 0000000..5d645e3
--- /dev/null
+++ b/extensions/guacamole-auth-ldap/src/main/java/org/glyptodon/guacamole/auth/ldap/user/UserContext.java
@@ -0,0 +1,220 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.ldap.user;
+
+import com.google.inject.Inject;
+import com.novell.ldap.LDAPConnection;
+import java.util.Collection;
+import java.util.Collections;
+import net.sourceforge.guacamole.net.auth.ldap.LDAPAuthenticationProvider;
+import org.glyptodon.guacamole.auth.ldap.connection.ConnectionService;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.form.Form;
+import org.glyptodon.guacamole.net.auth.ActiveConnection;
+import org.glyptodon.guacamole.net.auth.AuthenticatedUser;
+import org.glyptodon.guacamole.net.auth.AuthenticationProvider;
+import org.glyptodon.guacamole.net.auth.Connection;
+import org.glyptodon.guacamole.net.auth.ConnectionGroup;
+import org.glyptodon.guacamole.net.auth.ConnectionRecordSet;
+import org.glyptodon.guacamole.net.auth.Directory;
+import org.glyptodon.guacamole.net.auth.User;
+import org.glyptodon.guacamole.net.auth.simple.SimpleConnectionGroup;
+import org.glyptodon.guacamole.net.auth.simple.SimpleConnectionGroupDirectory;
+import org.glyptodon.guacamole.net.auth.simple.SimpleConnectionRecordSet;
+import org.glyptodon.guacamole.net.auth.simple.SimpleDirectory;
+import org.glyptodon.guacamole.net.auth.simple.SimpleUser;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * An LDAP-specific implementation of UserContext which queries all Guacamole
+ * connections and users from the LDAP directory.
+ *
+ * @author Michael Jumper
+ */
+public class UserContext implements org.glyptodon.guacamole.net.auth.UserContext {
+
+    /**
+     * Logger for this class.
+     */
+    private final Logger logger = LoggerFactory.getLogger(UserContext.class);
+
+    /**
+     * Service for retrieving Guacamole connections from the LDAP server.
+     */
+    @Inject
+    private ConnectionService connectionService;
+
+    /**
+     * Service for retrieving Guacamole users from the LDAP server.
+     */
+    @Inject
+    private UserService userService;
+
+    /**
+     * Reference to the AuthenticationProvider associated with this
+     * UserContext.
+     */
+    @Inject
+    private AuthenticationProvider authProvider;
+
+    /**
+     * Reference to a User object representing the user whose access level
+     * dictates the users and connections visible through this UserContext.
+     */
+    private User self;
+
+    /**
+     * Directory containing all User objects accessible to the user associated
+     * with this UserContext.
+     */
+    private Directory<User> userDirectory;
+
+    /**
+     * Directory containing all Connection objects accessible to the user
+     * associated with this UserContext.
+     */
+    private Directory<Connection> connectionDirectory;
+
+    /**
+     * Directory containing all ConnectionGroup objects accessible to the user
+     * associated with this UserContext.
+     */
+    private Directory<ConnectionGroup> connectionGroupDirectory;
+
+    /**
+     * Reference to the root connection group.
+     */
+    private ConnectionGroup rootGroup;
+
+    /**
+     * Initializes this UserContext using the provided AuthenticatedUser and
+     * LDAPConnection.
+     *
+     * @param user
+     *     The AuthenticatedUser representing the user that authenticated. This
+     *     user may have been authenticated by a different authentication
+     *     provider (not LDAP).
+     *
+     * @param ldapConnection
+     *     The connection to the LDAP server to use when querying accessible
+     *     Guacamole users and connections.
+     *
+     * @throws GuacamoleException
+     *     If associated data stored within the LDAP directory cannot be
+     *     queried due to an error.
+     */
+    public void init(AuthenticatedUser user, LDAPConnection ldapConnection)
+            throws GuacamoleException {
+
+        // Query all accessible users
+        userDirectory = new SimpleDirectory<User>(
+            userService.getUsers(ldapConnection)
+        );
+
+        // Query all accessible connections
+        connectionDirectory = new SimpleDirectory<Connection>(
+            connectionService.getConnections(user, ldapConnection)
+        );
+
+        // Root group contains only connections
+        rootGroup = new SimpleConnectionGroup(
+            LDAPAuthenticationProvider.ROOT_CONNECTION_GROUP,
+            LDAPAuthenticationProvider.ROOT_CONNECTION_GROUP,
+            connectionDirectory.getIdentifiers(),
+            Collections.<String>emptyList()
+        );
+
+        // Expose only the root group in the connection group directory
+        connectionGroupDirectory = new SimpleConnectionGroupDirectory(Collections.singleton(rootGroup));
+
+        // Init self with basic permissions
+        self = new SimpleUser(
+            user.getIdentifier(),
+            userDirectory.getIdentifiers(),
+            connectionDirectory.getIdentifiers(),
+            connectionGroupDirectory.getIdentifiers()
+        );
+
+    }
+
+    @Override
+    public User self() {
+        return self;
+    }
+
+    @Override
+    public AuthenticationProvider getAuthenticationProvider() {
+        return authProvider;
+    }
+
+    @Override
+    public Directory<User> getUserDirectory() throws GuacamoleException {
+        return userDirectory;
+    }
+
+    @Override
+    public Directory<Connection> getConnectionDirectory()
+            throws GuacamoleException {
+        return connectionDirectory;
+    }
+
+    @Override
+    public Directory<ConnectionGroup> getConnectionGroupDirectory()
+            throws GuacamoleException {
+        return connectionGroupDirectory;
+    }
+
+    @Override
+    public ConnectionGroup getRootConnectionGroup() throws GuacamoleException {
+        return rootGroup;
+    }
+
+    @Override
+    public Directory<ActiveConnection> getActiveConnectionDirectory()
+            throws GuacamoleException {
+        return new SimpleDirectory<ActiveConnection>();
+    }
+
+    @Override
+    public ConnectionRecordSet getConnectionHistory()
+            throws GuacamoleException {
+        return new SimpleConnectionRecordSet();
+    }
+
+    @Override
+    public Collection<Form> getUserAttributes() {
+        return Collections.<Form>emptyList();
+    }
+
+    @Override
+    public Collection<Form> getConnectionAttributes() {
+        return Collections.<Form>emptyList();
+    }
+
+    @Override
+    public Collection<Form> getConnectionGroupAttributes() {
+        return Collections.<Form>emptyList();
+    }
+
+}
diff --git a/extensions/guacamole-auth-ldap/src/main/java/org/glyptodon/guacamole/auth/ldap/user/UserService.java b/extensions/guacamole-auth-ldap/src/main/java/org/glyptodon/guacamole/auth/ldap/user/UserService.java
new file mode 100644
index 0000000..1f47d4c
--- /dev/null
+++ b/extensions/guacamole-auth-ldap/src/main/java/org/glyptodon/guacamole/auth/ldap/user/UserService.java
@@ -0,0 +1,316 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.auth.ldap.user;
+
+import com.google.inject.Inject;
+import com.novell.ldap.LDAPAttribute;
+import com.novell.ldap.LDAPConnection;
+import com.novell.ldap.LDAPEntry;
+import com.novell.ldap.LDAPException;
+import com.novell.ldap.LDAPSearchResults;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.glyptodon.guacamole.auth.ldap.ConfigurationService;
+import org.glyptodon.guacamole.auth.ldap.EscapingService;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.GuacamoleServerException;
+import org.glyptodon.guacamole.auth.ldap.LDAPGuacamoleProperties;
+import org.glyptodon.guacamole.net.auth.User;
+import org.glyptodon.guacamole.net.auth.simple.SimpleUser;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Service for queries the users visible to a particular Guacamole user
+ * according to an LDAP directory.
+ *
+ * @author Michael Jumper
+ */
+public class UserService {
+
+    /**
+     * Logger for this class.
+     */
+    private final Logger logger = LoggerFactory.getLogger(UserService.class);
+
+    /**
+     * Service for escaping parts of LDAP queries.
+     */
+    @Inject
+    private EscapingService escapingService;
+
+    /**
+     * Service for retrieving LDAP server configuration information.
+     */
+    @Inject
+    private ConfigurationService confService;
+
+    /**
+     * Adds all Guacamole users accessible to the user currently bound under
+     * the given LDAP connection to the provided map. Only users with the
+     * specified attribute are added. If the same username is encountered
+     * multiple times, warnings about possible ambiguity will be logged.
+     *
+     * @param ldapConnection
+     *     The current connection to the LDAP server, associated with the
+     *     current user.
+     *
+     * @return
+     *     All users accessible to the user currently bound under the given
+     *     LDAP connection, as a map of connection identifier to corresponding
+     *     user object.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs preventing retrieval of users.
+     */
+    private void putAllUsers(Map<String, User> users, LDAPConnection ldapConnection,
+            String usernameAttribute) throws GuacamoleException {
+
+        try {
+
+            // Find all Guacamole users underneath base DN
+            LDAPSearchResults results = ldapConnection.search(
+                confService.getUserBaseDN(),
+                LDAPConnection.SCOPE_SUB,
+                "(&(objectClass=*)(" + escapingService.escapeLDAPSearchFilter(usernameAttribute) + "=*))",
+                null,
+                false
+            );
+
+            // Read all visible users
+            while (results.hasMore()) {
+
+                LDAPEntry entry = results.next();
+
+                // Get username from record
+                LDAPAttribute username = entry.getAttribute(usernameAttribute);
+                if (username == null) {
+                    logger.warn("Queried user is missing the username attribute \"{}\".", usernameAttribute);
+                    continue;
+                }
+
+                // Store user using their username as the identifier
+                String identifier = username.getStringValue();
+                if (users.put(identifier, new SimpleUser(identifier)) != null)
+                    logger.warn("Possibly ambiguous user account: \"{}\".", identifier);
+
+            }
+
+        }
+        catch (LDAPException e) {
+            throw new GuacamoleServerException("Error while querying users.", e);
+        }
+
+    }
+
+    /**
+     * Returns all Guacamole users accessible to the user currently bound under
+     * the given LDAP connection.
+     *
+     * @param ldapConnection
+     *     The current connection to the LDAP server, associated with the
+     *     current user.
+     *
+     * @return
+     *     All users accessible to the user currently bound under the given
+     *     LDAP connection, as a map of connection identifier to corresponding
+     *     user object.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs preventing retrieval of users.
+     */
+    public Map<String, User> getUsers(LDAPConnection ldapConnection)
+            throws GuacamoleException {
+
+        // Build map of users by querying each username attribute separately
+        Map<String, User> users = new HashMap<String, User>();
+        for (String usernameAttribute : confService.getUsernameAttributes()) {
+
+            // Attempt to pull all users with given attribute
+            try {
+                putAllUsers(users, ldapConnection, usernameAttribute);
+            }
+
+            // Log any errors non-fatally
+            catch (GuacamoleException e) {
+                logger.warn("Could not query list of all users for attribute \"{}\": {}",
+                        usernameAttribute, e.getMessage());
+                logger.debug("Error querying list of all users.", e);
+            }
+
+        }
+
+        // Return map of all users
+        return users;
+
+    }
+
+    /**
+     * Generates a properly-escaped LDAP query which finds all objects having
+     * at least one username attribute set to the specified username, where
+     * the possible username attributes are defined within
+     * guacamole.properties.
+     *
+     * @param username
+     *     The username that the resulting LDAP query should search for within
+     *     objects within the LDAP directory.
+     *
+     * @return
+     *     An LDAP query which will search for arbitrary LDAP objects
+     *     containing at least one username attribute set to the specified
+     *     username.
+     *
+     * @throws GuacamoleException
+     *     If the LDAP query cannot be generated because the list of username
+     *     attributes cannot be parsed from guacamole.properties.
+     */
+    private String generateLDAPQuery(String username)
+            throws GuacamoleException {
+
+        List<String> usernameAttributes = confService.getUsernameAttributes();
+
+        // Build LDAP query for users having at least one username attribute
+        // with the specified username as its value
+        StringBuilder ldapQuery = new StringBuilder("(&(objectClass=*)");
+
+        // Include all attributes within OR clause if there are more than one
+        if (usernameAttributes.size() > 1)
+            ldapQuery.append("(|");
+
+        // Add equality comparison for each possible username attribute
+        for (String usernameAttribute : usernameAttributes) {
+            ldapQuery.append("(");
+            ldapQuery.append(escapingService.escapeLDAPSearchFilter(usernameAttribute));
+            ldapQuery.append("=");
+            ldapQuery.append(escapingService.escapeLDAPSearchFilter(username));
+            ldapQuery.append(")");
+        }
+
+        // Close OR clause, if any
+        if (usernameAttributes.size() > 1)
+            ldapQuery.append(")");
+
+        // Close overall query (AND clause)
+        ldapQuery.append(")");
+
+        return ldapQuery.toString();
+
+    }
+
+    /**
+     * Returns a list of all DNs corresponding to the users having the given
+     * username. If multiple username attributes are defined, or if uniqueness
+     * is not enforced across the username attribute, it is possible that this
+     * will return multiple DNs.
+     *
+     * @param ldapConnection
+     *     The connection to the LDAP server to use when querying user DNs.
+     *
+     * @param username
+     *     The username of the user whose corresponding user account DNs are
+     *     to be retrieved.
+     *
+     * @return
+     *     A list of all DNs corresponding to the users having the given
+     *     username. If no such DNs exist, this list will be empty.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while querying the user DNs, or if the username
+     *     attribute property cannot be parsed within guacamole.properties.
+     */
+    public List<String> getUserDNs(LDAPConnection ldapConnection,
+            String username) throws GuacamoleException {
+
+        try {
+
+            List<String> userDNs = new ArrayList<String>();
+
+            // Find all Guacamole users underneath base DN and matching the
+            // specified username
+            LDAPSearchResults results = ldapConnection.search(
+                confService.getUserBaseDN(),
+                LDAPConnection.SCOPE_SUB,
+                generateLDAPQuery(username),
+                null,
+                false
+            );
+
+            // Add all DNs for found users
+            while (results.hasMore()) {
+                LDAPEntry entry = results.next();
+                userDNs.add(entry.getDN());
+            }
+
+            // Return all discovered DNs (if any)
+            return userDNs;
+
+        }
+        catch (LDAPException e) {
+            throw new GuacamoleServerException("Error while query user DNs.", e);
+        }
+
+    }
+
+    /**
+     * Determines the DN which corresponds to the user having the given
+     * username. The DN will either be derived directly from the user base DN,
+     * or queried from the LDAP server, depending on how LDAP authentication
+     * has been configured.
+     *
+     * @param username
+     *     The username of the user whose corresponding DN should be returned.
+     *
+     * @return
+     *     The DN which corresponds to the user having the given username.
+     *
+     * @throws GuacamoleException
+     *     If required properties are missing, and thus the user DN cannot be
+     *     determined.
+     */
+    public String deriveUserDN(String username)
+            throws GuacamoleException {
+
+        // Pull username attributes from properties
+        List<String> usernameAttributes = confService.getUsernameAttributes();
+
+        // We need exactly one base DN to derive the user DN
+        if (usernameAttributes.size() != 1) {
+            logger.warn(String.format("Cannot directly derive user DN when "
+                      + "multiple username attributes are specified. Please "
+                      + "define an LDAP search DN using the \"%s\" property "
+                      + "in your \"guacamole.properties\".",
+                      LDAPGuacamoleProperties.LDAP_SEARCH_BIND_DN.getName()));
+            return null;
+        }
+
+        // Derive user DN from base DN
+        return
+                    escapingService.escapeDN(usernameAttributes.get(0))
+            + "=" + escapingService.escapeDN(username)
+            + "," + confService.getUserBaseDN();
+
+    }
+
+}
diff --git a/extensions/guacamole-auth-ldap/src/main/resources/guac-manifest.json b/extensions/guacamole-auth-ldap/src/main/resources/guac-manifest.json
new file mode 100644
index 0000000..e9448cf
--- /dev/null
+++ b/extensions/guacamole-auth-ldap/src/main/resources/guac-manifest.json
@@ -0,0 +1,16 @@
+{
+
+    "guacamoleVersion" : "0.9.9",
+
+    "name"      : "LDAP Authentication",
+    "namespace" : "guac-ldap",
+
+    "authProviders" : [
+        "net.sourceforge.guacamole.net.auth.ldap.LDAPAuthenticationProvider"
+    ],
+
+    "translations" : [
+        "translations/en.json"
+    ]
+
+}
diff --git a/extensions/guacamole-auth-ldap/src/main/resources/translations/en.json b/extensions/guacamole-auth-ldap/src/main/resources/translations/en.json
new file mode 100644
index 0000000..a1d6ae9
--- /dev/null
+++ b/extensions/guacamole-auth-ldap/src/main/resources/translations/en.json
@@ -0,0 +1,7 @@
+{
+
+    "DATA_SOURCE_LDAP" : {
+        "NAME" : "LDAP"
+    }
+
+}
diff --git a/extensions/guacamole-auth-mysql/README b/extensions/guacamole-auth-mysql/README
deleted file mode 100644
index 5543c12..0000000
--- a/extensions/guacamole-auth-mysql/README
+++ /dev/null
@@ -1,171 +0,0 @@
-
-------------------------------------------------------------
- About this README
-------------------------------------------------------------
-
-This README is intended to provide quick and to-the-point documentation for
-technical users intending to compile parts of Guacamole themselves.
-
-Distribution-specific packages are available from the files section of the main
-project page:
- 
-    http://sourceforge.net/projects/guacamole/files/
-
-Distribution-specific documentation is provided on the Guacamole wiki:
-
-    http://guac-dev.org/
-
-
-------------------------------------------------------------
- What is guacamole-auth-mysql?
-------------------------------------------------------------
-
-guacamole-auth-ldap is a Java library for use with the Guacamole web
-application to provide MySQL based authentication.
-
-guacamole-auth-mysql provides an authentication provider which can be
-set in guacamole.properties to allow MySQL authentication of Guacamole
-users. Additional properties are required to configure the mysql
-connection parameters.
-
-A schema file are provided to create the required tables in your
-mysql database.
-
-
-------------------------------------------------------------
- Compiling and installing guacamole-auth-mysql
-------------------------------------------------------------
-
-guacamole-auth-mysql is built using Maven. Building guacamole-auth-mysql
-compiles all classes and packages them into a redistributable .jar file. This
-.jar file can be installed in the library directory configured in
-guacamole.properties such that the authentication provider is available.
-
-1) Set up a MySQL database with the Guacamole schema.
-
-    When guacamole-auth-mysql is compiling, it needs to generate source
-    based on a database schema. Because the source generator uses a
-    connection to an actual database to do this, you must have a MySQL
-    database running with the Guacamole schema set up.
-
-    First, create a database. For the sake of these instructions, we will
-    call the database "guacamole", and will run all scripts as the root user:
-
-    $ mysql -u root -p
-    Enter password:
-    mysql> CREATE DATABASE guacamole;
-    Query OK, 1 row affected (0.00 sec)
-
-    mysql> exit
-    Bye
-
-    The schema files are in the schema/ subdirectory of the source. If run
-    in order, they will create the schema and a default user:
-
-    $ cat schema/*.sql | mysql -u root -p guacamole
-
-2) Set up your ~/.m2/settings.xml
-
-    Once the database is set up, Maven will need to have the credentials
-    required to connect to it and query the schema. This information is
-    specified in properties inside your ~/.m2/settings.xml file. If this
-    file does not exist yet, simply create it.
-
-    For ease of compilation, we've included an example settings.xml
-    defining the required properties in doc/example/settings.xml. You can
-    simply copy this file into ~/.m2 and edit as necessary.
-
-    If you wish to write the file yourself, the file should look like this in
-    general:
-
-    <settings>
-        <profiles>
-            ...profiles...
-        </profiles>
-    </settings>
-
-    We need to add a profile which defines the required properties by
-    placing a section like the following within the "profiles" section of your
-    settings.xml:
-
-    <profile>
-        <id>guacamole-mybatis</id>
-        <properties>
-            <guacamole.database.catalog>DATABASE</guacamole.database.catalog>
-            <guacamole.database.user>USERNAME</guacamole.database.user>
-            <guacamole.database.password>PASSWORD</guacamole.database.password>
-        </properties>
-    </profile>
-
-    Obviously, the DATABASE, USERNAME, and PASSWORD placeholders above must
-    be replaced with the appropriate values for your system.
-
-    Finally, to make the profile available to the build, it must be activated.
-    Place a section like the following at the bottom of your settings.xml,
-    right after the profiles section:
-
-    <activeProfiles>
-        <activeProfile>guacamole-mybatis</activeProfile>
-    </activeProfiles>
-
-    Maven's documentation has more details on writing the settings.xml file
-    if you have different needs or the above directions are not clear.
-
-3) Run mvn package
-
-    $ mvn package
-
-    Maven will download any needed dependencies for building the .jar file.
-    Once all dependencies have been downloaded, the .jar file will be
-    created in the target/ subdirectory of the current directory.
-
-    If this process fails, check the build errors, and verify that the
-    contents of your settings.xml file is correct.
-
-4) Extract the .tar.gz file now present in the target/ directory, and
-   place the .jar files in the extracted lib/ subdirectory in the library 
-   directory specified in guacamole.properties.
-
-    You will likely need to do this as root.
-
-    If you do not have a library directory configured in your
-    guacamole.properties, you will need to specify one. The directory
-    is specified using the "lib-directory" property.
-
-5) Set up your MySQL database to authenticate Guacamole users
-
-    A schema file is provided in the schema directory for creating
-    the guacamole authentication tables in your MySQL database.
-
-    Additionally, a script is provided to create a default admin user
-    with username 'guacadmin' and password 'guacadmin'. This user can 
-    be used to set up any other connections and users.
-
-6) Configure guacamole.properties for MySQL
-
-    There are additional properties required by the MySQL JDBC driver
-    which must be added/changed in your guacamole.properties:
-
-    # Configuration for MySQL connection
-    mysql-hostname:           mysql.host.name
-    mysql-port:               3306
-    mysql-database:           guacamole.database.name
-    mysql-username:           user
-    mysql-password:           pass
-
-    Optionally, the authentication provider can be configured
-    not to allow multiple users to use the same connection
-    at the same time:
-
-    mysql-disallow-simultaneous-connections: true
-
-
-------------------------------------------------------------
- Reporting problems
-------------------------------------------------------------
-
-Please report any bugs encountered by opening a new ticket at the Trac system
-hosted at:
-    
-    http://guac-dev.org/trac/
-
diff --git a/extensions/guacamole-auth-mysql/doc/example/settings.xml b/extensions/guacamole-auth-mysql/doc/example/settings.xml
deleted file mode 100644
index d0fb6d5..0000000
--- a/extensions/guacamole-auth-mysql/doc/example/settings.xml
+++ /dev/null
@@ -1,21 +0,0 @@
-<settings>
-
-    <!-- Profile defining the properties required for a MyBatis build -->
-    <profiles>
-        <profile>
-            <id>guacamole-mybatis</id>
-            <properties>
-                <guacamole.database.catalog>SCHEMA</guacamole.database.catalog>
-                <guacamole.database.schema>DATABASE</guacamole.database.schema>
-                <guacamole.database.user>USER</guacamole.database.user>
-                <guacamole.database.password>PASS</guacamole.database.password>
-            </properties>
-        </profile>
-    </profiles>
-
-    <!-- Activate by default -->
-    <activeProfiles>
-        <activeProfile>guacamole-mybatis</activeProfile>
-    </activeProfiles>
-
-</settings>
diff --git a/extensions/guacamole-auth-mysql/pom.xml b/extensions/guacamole-auth-mysql/pom.xml
deleted file mode 100644
index 00dc729..0000000
--- a/extensions/guacamole-auth-mysql/pom.xml
+++ /dev/null
@@ -1,131 +0,0 @@
-<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
-    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
-
-    <modelVersion>4.0.0</modelVersion>
-    <groupId>org.glyptodon.guacamole</groupId>
-    <artifactId>guacamole-auth-mysql</artifactId>
-    <packaging>jar</packaging>
-    <version>0.8.2</version>
-    <name>guacamole-auth-mysql</name>
-    <url>http://guac-dev.org/</url>
-
-    <properties>
-        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
-    </properties>
-
-    <build>
-        <plugins>
-
-            <!-- Written for 1.6 -->
-            <plugin>
-                <groupId>org.apache.maven.plugins</groupId>
-                <artifactId>maven-compiler-plugin</artifactId>
-                <configuration>
-                    <source>1.6</source>
-                    <target>1.6</target>
-                </configuration>
-            </plugin>
-
-            <!-- Assembly plugin - for easy distribution -->
-            <plugin>
-                <artifactId>maven-assembly-plugin</artifactId>
-                <version>2.2-beta-5</version>
-                <configuration>
-                    <finalName>${project.artifactId}-${project.version}</finalName>
-                    <appendAssemblyId>false</appendAssemblyId>
-                    <descriptors>
-                        <descriptor>src/main/assembly/dist.xml</descriptor>
-                    </descriptors>
-                </configuration>
-                <executions>
-                    <execution>
-                        <id>make-dist-archive</id>
-                        <phase>package</phase>
-                        <goals>
-                            <goal>single</goal>
-                        </goals>
-                    </execution>
-                </executions>
-            </plugin>
-
-            <!-- MyBatis Generator plugin -->
-            <plugin>
-                <groupId>org.mybatis.generator</groupId>
-                <artifactId>mybatis-generator-maven-plugin</artifactId>
-                <version>1.3.2</version>
-
-                <executions>
-                    <execution>
-                        <id>Generate MyBatis Artifacts</id>
-                        <goals>
-                            <goal>generate</goal>
-                        </goals>
-                    </execution>
-                </executions>
-
-                <!-- MySQL Connector -->
-                <dependencies>
-                    <dependency>
-                        <groupId>mysql</groupId>
-                        <artifactId>mysql-connector-java</artifactId>
-                        <version>5.1.23</version>
-                    </dependency>
-                </dependencies>
-
-            </plugin>
-
-        </plugins>
-    </build>
-
-    <dependencies>
-
-        <!-- Guacamole Java API -->
-        <dependency>
-            <groupId>org.glyptodon.guacamole</groupId>
-            <artifactId>guacamole-common</artifactId>
-            <version>0.8.0</version>
-        </dependency>
-
-        <!-- Guacamole Extension API -->
-        <dependency>
-            <groupId>org.glyptodon.guacamole</groupId>
-            <artifactId>guacamole-ext</artifactId>
-            <version>0.8.1</version>
-        </dependency>
-
-        <!-- SLF4J - logging -->
-        <dependency>
-            <groupId>org.slf4j</groupId>
-            <artifactId>slf4j-api</artifactId>
-            <version>1.6.1</version>
-        </dependency>
-        <dependency>
-            <groupId>org.slf4j</groupId>
-            <artifactId>slf4j-jcl</artifactId>
-            <version>1.6.1</version>
-            <scope>runtime</scope>
-        </dependency>
-
-        <!-- MyBatis -->
-        <dependency>
-            <groupId>org.mybatis</groupId>
-            <artifactId>mybatis</artifactId>
-            <version>3.1.1</version>
-        </dependency>
-        
-        <!-- MyBatis Guice -->
-        <dependency>
-            <groupId>org.mybatis</groupId>
-            <artifactId>mybatis-guice</artifactId>
-            <version>3.2</version>
-        </dependency>
-        
-        <!-- Google Collections -->
-        <dependency>
-            <groupId>com.google.collections</groupId>
-            <artifactId>google-collections</artifactId>
-            <version>1.0</version>
-        </dependency>
-    </dependencies>
-
-</project>
diff --git a/extensions/guacamole-auth-mysql/schema/001-create-schema.sql b/extensions/guacamole-auth-mysql/schema/001-create-schema.sql
deleted file mode 100644
index 7fb6b63..0000000
--- a/extensions/guacamole-auth-mysql/schema/001-create-schema.sql
+++ /dev/null
@@ -1,207 +0,0 @@
-
---
--- Table of connection groups. Each connection group has a name.
---
-
-CREATE TABLE `guacamole_connection_group` (
-
-  `connection_group_id`   int(11)      NOT NULL AUTO_INCREMENT,
-  `parent_id`             int(11),
-  `connection_group_name` varchar(128) NOT NULL,
-  `type`                  enum('ORGANIZATIONAL',
-                               'BALANCING') NOT NULL DEFAULT 'ORGANIZATIONAL',
-
-  PRIMARY KEY (`connection_group_id`),
-  UNIQUE KEY `connection_group_name_parent` (`connection_group_name`, `parent_id`),
-
-  CONSTRAINT `guacamole_connection_group_ibfk_1`
-    FOREIGN KEY (`parent_id`)
-    REFERENCES `guacamole_connection_group` (`connection_group_id`) ON DELETE CASCADE
-
-) ENGINE=InnoDB DEFAULT CHARSET=utf8;
-
---
--- Table of connections. Each connection has a name, protocol, and
--- associated set of parameters.
--- A connection may belong to a connection group.
---
-
-CREATE TABLE `guacamole_connection` (
-
-  `connection_id`       int(11)      NOT NULL AUTO_INCREMENT,
-  `connection_name`     varchar(128) NOT NULL,
-  `parent_id`           int(11),
-  `protocol`            varchar(32)  NOT NULL,
-  
-  PRIMARY KEY (`connection_id`),
-  UNIQUE KEY `connection_name_parent` (`connection_name`, `parent_id`),
-
-  CONSTRAINT `guacamole_connection_ibfk_1`
-    FOREIGN KEY (`parent_id`)
-    REFERENCES `guacamole_connection_group` (`connection_group_id`) ON DELETE CASCADE
-
-) ENGINE=InnoDB DEFAULT CHARSET=utf8;
-
---
--- Table of users. Each user has a unique username and a hashed password
--- with corresponding salt.
---
-
-CREATE TABLE `guacamole_user` (
-
-  `user_id`       int(11)      NOT NULL AUTO_INCREMENT,
-  `username`      varchar(128) NOT NULL,
-  `password_hash` binary(32)   NOT NULL,
-  `password_salt` binary(32)   NOT NULL,
-
-  PRIMARY KEY (`user_id`),
-  UNIQUE KEY `username` (`username`)
-
-) ENGINE=InnoDB DEFAULT CHARSET=utf8;
-
---
--- Table of connection parameters. Each parameter is simply a name/value pair
--- associated with a connection.
---
-
-CREATE TABLE `guacamole_connection_parameter` (
-
-  `connection_id`   int(11)       NOT NULL,
-  `parameter_name`  varchar(128)  NOT NULL,
-  `parameter_value` varchar(4096) NOT NULL,
-
-  PRIMARY KEY (`connection_id`,`parameter_name`),
-
-  CONSTRAINT `guacamole_connection_parameter_ibfk_1`
-    FOREIGN KEY (`connection_id`)
-    REFERENCES `guacamole_connection` (`connection_id`) ON DELETE CASCADE
-
-) ENGINE=InnoDB DEFAULT CHARSET=utf8;
-
---
--- Table of connection permissions. Each connection permission grants a user
--- specific access to a connection.
---
-
-CREATE TABLE `guacamole_connection_permission` (
-
-  `user_id`       int(11) NOT NULL,
-  `connection_id` int(11) NOT NULL,
-  `permission`    enum('READ',
-                       'UPDATE',
-                       'DELETE',
-                       'ADMINISTER') NOT NULL,
-
-  PRIMARY KEY (`user_id`,`connection_id`,`permission`),
-
-  CONSTRAINT `guacamole_connection_permission_ibfk_1`
-    FOREIGN KEY (`connection_id`)
-    REFERENCES `guacamole_connection` (`connection_id`) ON DELETE CASCADE,
-
-  CONSTRAINT `guacamole_connection_permission_ibfk_2`
-    FOREIGN KEY (`user_id`)
-    REFERENCES `guacamole_user` (`user_id`) ON DELETE CASCADE
-
-) ENGINE=InnoDB DEFAULT CHARSET=utf8;
-
---
--- Table of connection group permissions. Each group permission grants a user
--- specific access to a connection group.
---
-
-CREATE TABLE `guacamole_connection_group_permission` (
-
-  `user_id`             int(11) NOT NULL,
-  `connection_group_id` int(11) NOT NULL,
-  `permission`          enum('READ',
-                             'UPDATE',
-                             'DELETE',
-                             'ADMINISTER') NOT NULL,
-
-  PRIMARY KEY (`user_id`,`connection_group_id`,`permission`),
-
-  CONSTRAINT `guacamole_connection_group_permission_ibfk_1`
-    FOREIGN KEY (`connection_group_id`)
-    REFERENCES `guacamole_connection_group` (`connection_group_id`) ON DELETE CASCADE,
-
-  CONSTRAINT `guacamole_connection_group_permission_ibfk_2`
-    FOREIGN KEY (`user_id`)
-    REFERENCES `guacamole_user` (`user_id`) ON DELETE CASCADE
-
-) ENGINE=InnoDB DEFAULT CHARSET=utf8;
-
---
--- Table of system permissions. Each system permission grants a user a
--- system-level privilege of some kind.
---
-
-CREATE TABLE `guacamole_system_permission` (
-
-  `user_id`    int(11) NOT NULL,
-  `permission` enum('CREATE_CONNECTION',
-		    'CREATE_CONNECTION_GROUP',
-                    'CREATE_USER',
-                    'ADMINISTER') NOT NULL,
-
-  PRIMARY KEY (`user_id`,`permission`),
-
-  CONSTRAINT `guacamole_system_permission_ibfk_1`
-    FOREIGN KEY (`user_id`)
-    REFERENCES `guacamole_user` (`user_id`) ON DELETE CASCADE
-
-) ENGINE=InnoDB DEFAULT CHARSET=utf8;
-
---
--- Table of user permissions. Each user permission grants a user access to
--- another user (the "affected" user) for a specific type of operation.
---
-
-CREATE TABLE `guacamole_user_permission` (
-
-  `user_id`          int(11) NOT NULL,
-  `affected_user_id` int(11) NOT NULL,
-  `permission`       enum('READ',
-                          'UPDATE',
-                          'DELETE',
-                          'ADMINISTER') NOT NULL,
-
-  PRIMARY KEY (`user_id`,`affected_user_id`,`permission`),
-
-  CONSTRAINT `guacamole_user_permission_ibfk_1`
-    FOREIGN KEY (`affected_user_id`)
-    REFERENCES `guacamole_user` (`user_id`) ON DELETE CASCADE,
-
-  CONSTRAINT `guacamole_user_permission_ibfk_2`
-    FOREIGN KEY (`user_id`)
-    REFERENCES `guacamole_user` (`user_id`) ON DELETE CASCADE
-
-) ENGINE=InnoDB DEFAULT CHARSET=utf8;
-
---
--- Table of connection history records. Each record defines a specific user's
--- session, including the connection used, the start time, and the end time
--- (if any).
---
-
-CREATE TABLE `guacamole_connection_history` (
-
-  `history_id`    int(11)  NOT NULL AUTO_INCREMENT,
-  `user_id`       int(11)  NOT NULL,
-  `connection_id` int(11)  NOT NULL,
-  `start_date`    datetime NOT NULL,
-  `end_date`      datetime DEFAULT NULL,
-
-  PRIMARY KEY (`history_id`),
-  KEY `user_id` (`user_id`),
-  KEY `connection_id` (`connection_id`),
-
-  CONSTRAINT `guacamole_connection_history_ibfk_1`
-    FOREIGN KEY (`user_id`)
-    REFERENCES `guacamole_user` (`user_id`) ON DELETE CASCADE,
-
-  CONSTRAINT `guacamole_connection_history_ibfk_2`
-    FOREIGN KEY (`connection_id`)
-    REFERENCES `guacamole_connection` (`connection_id`) ON DELETE CASCADE
-
-) ENGINE=InnoDB DEFAULT CHARSET=utf8;
-
diff --git a/extensions/guacamole-auth-mysql/schema/002-create-admin-user.sql b/extensions/guacamole-auth-mysql/schema/002-create-admin-user.sql
deleted file mode 100644
index 824ddf6..0000000
--- a/extensions/guacamole-auth-mysql/schema/002-create-admin-user.sql
+++ /dev/null
@@ -1,17 +0,0 @@
-
--- Create default user "guacadmin" with password "guacadmin"
-insert into guacamole_user values(1, 'guacadmin',
-    x'CA458A7D494E3BE824F5E1E175A1556C0F8EEF2C2D7DF3633BEC4A29C4411960',  -- 'guacadmin'
-    x'FE24ADC5E11E2B25288D1704ABE67A79E342ECC26064CE69C5B3177795A82264');
-
--- Grant this user create permissions
-insert into guacamole_system_permission values(1, 'CREATE_CONNECTION');
-insert into guacamole_system_permission values(1, 'CREATE_CONNECTION_GROUP');
-insert into guacamole_system_permission values(1, 'CREATE_USER');
-insert into guacamole_system_permission values(1, 'ADMINISTER');
-
--- Grant admin permission to read/update/administer self
-insert into guacamole_user_permission values(1, 1, 'READ');
-insert into guacamole_user_permission values(1, 1, 'UPDATE');
-insert into guacamole_user_permission values(1, 1, 'ADMINISTER');
-
diff --git a/extensions/guacamole-auth-mysql/schema/upgrade/upgrade-pre-0.8.2.sql b/extensions/guacamole-auth-mysql/schema/upgrade/upgrade-pre-0.8.2.sql
deleted file mode 100644
index 160f3f7..0000000
--- a/extensions/guacamole-auth-mysql/schema/upgrade/upgrade-pre-0.8.2.sql
+++ /dev/null
@@ -1,68 +0,0 @@
-
---
--- Table of connection groups. Each connection group has a name.
---
-
-CREATE TABLE `guacamole_connection_group` (
-
-  `connection_group_id`   int(11)      NOT NULL AUTO_INCREMENT,
-  `parent_id`             int(11),
-  `connection_group_name` varchar(128) NOT NULL,
-  `type`                  enum('ORGANIZATIONAL',
-                               'BALANCING') NOT NULL DEFAULT 'ORGANIZATIONAL',
-
-
-  PRIMARY KEY (`connection_group_id`),
-  UNIQUE KEY `connection_group_name_parent` (`connection_group_name`, `parent_id`),
-
-  CONSTRAINT `guacamole_connection_group_ibfk_1`
-    FOREIGN KEY (`parent_id`)
-    REFERENCES `guacamole_connection_group` (`connection_group_id`) ON DELETE CASCADE
-
-) ENGINE=InnoDB DEFAULT CHARSET=utf8;
-
-
---
--- Changes to connection table to support grouping.
---
-
-ALTER TABLE `guacamole_connection` ADD COLUMN `parent_id` int(11) AFTER `connection_name`;
-
-ALTER TABLE `guacamole_connection` DROP INDEX `connection_name`;
-ALTER TABLE `guacamole_connection` ADD UNIQUE KEY `connection_name_parent` (`connection_name`, `parent_id`);
-
-ALTER TABLE `guacamole_connection` ADD CONSTRAINT `guacamole_connection_ibfk_1`
-    FOREIGN KEY (`parent_id`)
-    REFERENCES `guacamole_connection_group` (`connection_group_id`) ON DELETE CASCADE;
-
---
--- Table of connection group permissions. Each group permission grants a user
--- specific access to a connection group.
---
-
-CREATE TABLE `guacamole_connection_group_permission` (
-
-  `user_id`             int(11) NOT NULL,
-  `connection_group_id` int(11) NOT NULL,
-  `permission`          enum('READ',
-                             'UPDATE',
-                             'DELETE',
-                             'ADMINISTER') NOT NULL,
-
-  PRIMARY KEY (`user_id`,`connection_group_id`,`permission`),
-
-  CONSTRAINT `guacamole_connection_group_permission_ibfk_1`
-    FOREIGN KEY (`connection_group_id`)
-    REFERENCES `guacamole_connection_group` (`connection_group_id`) ON DELETE CASCADE,
-
-  CONSTRAINT `guacamole_connection_group_permission_ibfk_2`
-    FOREIGN KEY (`user_id`)
-    REFERENCES `guacamole_user` (`user_id`) ON DELETE CASCADE
-
-) ENGINE=InnoDB DEFAULT CHARSET=utf8;
-
-ALTER TABLE `guacamole_system_permission` MODIFY `permission` 
-    enum('CREATE_CONNECTION',
-         'CREATE_CONNECTION_GROUP',
-         'CREATE_USER',
-         'ADMINISTER') NOT NULL;
diff --git a/extensions/guacamole-auth-mysql/src/main/assembly/dist.xml b/extensions/guacamole-auth-mysql/src/main/assembly/dist.xml
deleted file mode 100644
index 0628ad6..0000000
--- a/extensions/guacamole-auth-mysql/src/main/assembly/dist.xml
+++ /dev/null
@@ -1,54 +0,0 @@
-<assembly
-    xmlns="http://maven.apache.org/plugins/maven-assembly-plugin/assembly/1.1.0"
-    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
-    xsi:schemaLocation="http://maven.apache.org/plugins/maven-assembly-plugin/assembly/1.1.0 http://maven.apache.org/xsd/assembly-1.1.0.xsd">
-    
-    <id>dist</id>
-    <baseDirectory>${project.artifactId}-${project.version}</baseDirectory>
-
-    <!-- Output tar.gz -->
-    <formats>
-        <format>tar.gz</format>
-    </formats>
-
-    <!-- Include docs and schema -->
-    <fileSets>
-
-            <!-- Include docs -->
-            <fileSet>
-                <outputDirectory>/</outputDirectory>
-                <directory>doc</directory>
-            </fileSet>
-
-            <!-- Include schema -->
-            <fileSet>
-                <outputDirectory>/schema</outputDirectory>
-                <directory>schema</directory>
-            </fileSet>
-
-    </fileSets>
-
-    <!-- Include self and all dependencies except guacamole-common 
-         and guacamole-ext -->
-    <dependencySets>
-        <dependencySet>
-
-            <outputDirectory>/lib</outputDirectory>
-            <scope>runtime</scope>
-            <unpack>false</unpack>
-            <useProjectArtifact>true</useProjectArtifact>
-            <useTransitiveFiltering>true</useTransitiveFiltering>
-
-            <excludes>
-
-                <!-- Do not include guacamole-common -->
-                <exclude>org.glyptodon.guacamole:guacamole-common</exclude>
-
-                <!-- Do not include guacamole-ext -->
-                <exclude>org.glyptodon.guacamole:guacamole-ext</exclude>
-
-            </excludes>
-        </dependencySet>
-    </dependencySets>
-
-</assembly>
diff --git a/extensions/guacamole-auth-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/ActiveConnectionMap.java b/extensions/guacamole-auth-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/ActiveConnectionMap.java
deleted file mode 100644
index 06965eb..0000000
--- a/extensions/guacamole-auth-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/ActiveConnectionMap.java
+++ /dev/null
@@ -1,515 +0,0 @@
-
-package net.sourceforge.guacamole.net.auth.mysql;
-
-/* ***** BEGIN LICENSE BLOCK *****
- * Version: MPL 1.1/GPL 2.0/LGPL 2.1
- *
- * The contents of this file are subject to the Mozilla Public License Version
- * 1.1 (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.mozilla.org/MPL/
- *
- * Software distributed under the License is distributed on an "AS IS" basis,
- * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- * for the specific language governing rights and limitations under the
- * License.
- *
- * The Original Code is guacamole-auth-mysql.
- *
- * The Initial Developer of the Original Code is
- * James Muehlner.
- * Portions created by the Initial Developer are Copyright (C) 2010
- * the Initial Developer. All Rights Reserved.
- *
- * Contributor(s):
- *
- * Alternatively, the contents of this file may be used under the terms of
- * either the GNU General Public License Version 2 or later (the "GPL"), or
- * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
- * in which case the provisions of the GPL or the LGPL are applicable instead
- * of those above. If you wish to allow use of your version of this file only
- * under the terms of either the GPL or the LGPL, and not to allow others to
- * use your version of this file under the terms of the MPL, indicate your
- * decision by deleting the provisions above and replace them with the notice
- * and other provisions required by the GPL or the LGPL. If you do not delete
- * the provisions above, a recipient may use your version of this file under
- * the terms of any one of the MPL, the GPL or the LGPL.
- *
- * ***** END LICENSE BLOCK ***** */
-
-import com.google.inject.Inject;
-import java.util.Collection;
-import java.util.Date;
-import java.util.HashMap;
-import java.util.Map;
-import org.glyptodon.guacamole.GuacamoleException;
-import net.sourceforge.guacamole.net.auth.mysql.dao.ConnectionHistoryMapper;
-import net.sourceforge.guacamole.net.auth.mysql.model.ConnectionHistory;
-
-/**
- * Represents the map of currently active Connections to the count of the number
- * of current users. Whenever a socket is opened, the connection count should be
- * incremented, and whenever a socket is closed, the connection count should be 
- * decremented.
- *
- * @author James Muehlner
- */
-public class ActiveConnectionMap {
-    
-    /**
-     * Represents the count of users currently using a MySQL connection.
-     */
-    public class Connection {
-        
-        /**
-         * The ID of the MySQL connection that this Connection represents.
-         */
-        private int connectionID;
-        
-        /**
-         * The number of users currently using this connection.
-         */
-        private int currentUserCount;
-        
-        /**
-         * Returns the ID of the MySQL connection that this Connection 
-         * represents.
-         * 
-         * @return the ID of the MySQL connection that this Connection 
-         * represents.
-         */
-        public int getConnectionID() {
-            return connectionID;
-        }
-        
-        /**
-         * Returns the number of users currently using this connection.
-         * 
-         * @return the number of users currently using this connection.
-         */
-        public int getCurrentUserCount() {
-            return currentUserCount;
-        }
-        
-        /**
-         * Set the current user count for this connection.
-         * 
-         * @param currentUserCount The new user count for this Connection.
-         */
-        public void setCurrentUserCount(int currentUserCount) {
-            this.currentUserCount = currentUserCount;
-        }
-        
-        /**
-         * Create a new Connection for the given connectionID with a zero
-         * current user count.
-         * 
-         * @param connectionID The ID of the MySQL connection that this 
-         *                     Connection represents.
-         */
-        public Connection(int connectionID) {
-            this.connectionID = connectionID;
-            this.currentUserCount = 0;
-        }
-    }
-    
-    /*
-     * Represents a user connected to a connection or BALANCING connection group.
-     */
-    public class ConnectionUser {
-        /**
-         * The ID of the connection or connection group that this ConnectionUser refers to.
-         */
-        private int identifier; 
-        
-        /**
-         * The user that this ConnectionUser refers to.
-         */
-        private int userID;
-
-        /**
-         * Returns ID of the connection or connection group that this ConnectionUser refers to.
-         * @return ID of the connection or connection group that this ConnectionUser refers to.
-         */
-        public int getIdentifier() {
-            return identifier;
-        }
-
-        /**
-         * Returns the user ID that this ConnectionUser refers to.
-         * @return the user ID that this ConnectionUser refers to.
-         */
-        public int getUserID() {
-            return userID;
-        }
-        
-        /**
-         * Create a ConnectionUser with the given connection or connection group
-         * ID and user ID.
-         * 
-         * @param identifier The connection or connection group ID that this 
-         *                   ConnectionUser refers to.
-         * @param userID The user ID that this ConnectionUser refers to.
-         */
-        public ConnectionUser(int identifier, int userID) {
-            this.identifier = identifier;
-            this.userID = userID;
-        }
-        
-        @Override
-        public boolean equals(Object other) {
-            
-            // Only another ConnectionUser can equal this ConnectionUser
-            if(!(other instanceof ConnectionUser))
-                return false;
-            
-            ConnectionUser otherConnectionGroupUser = 
-                    (ConnectionUser)other;
-            
-            /* 
-             * Two ConnectionGroupUsers are equal iff they represent the exact 
-             * same pairing of connection or connection group and user.
-             */
-            return this.identifier == otherConnectionGroupUser.identifier
-                    && this.userID == otherConnectionGroupUser.userID;
-        }
-
-        @Override
-        public int hashCode() {
-            int hash = 3;
-            hash = 23 * hash + this.identifier;
-            hash = 23 * hash + this.userID;
-            return hash;
-        }
-    }
-
-    /**
-     * DAO for accessing connection history.
-     */
-    @Inject
-    private ConnectionHistoryMapper connectionHistoryDAO;
-
-    /**
-     * Map of all the connections that are currently active to the
-     * count of current users.
-     */
-    private Map<Integer, Connection> activeConnectionMap =
-            new HashMap<Integer, Connection>();
-
-    /**
-     * Map of all the connection group users to the count of current usages.
-     */
-    private Map<ConnectionUser, Integer> activeConnectionGroupUserMap =
-            new HashMap<ConnectionUser, Integer>();
-
-    /**
-     * Map of all the connection users to the count of current usages.
-     */
-    private Map<ConnectionUser, Integer> activeConnectionUserMap =
-            new HashMap<ConnectionUser, Integer>();
-    
-    /**
-     * Returns the number of connections opened by the given user using 
-     * the given ConnectionGroup.
-     * 
-     * @param connectionGroupID The connection group ID that this 
-     *                          ConnectionUser refers to.
-     * @param userID The user ID that this ConnectionUser refers to.
-     * 
-     * @return The number of connections opened by the given user to the given
-     *         ConnectionGroup.
-     */
-    public int getConnectionGroupUserCount(int connectionGroupID, int userID) {
-        Integer count = activeConnectionGroupUserMap.get
-                (new ConnectionUser(connectionGroupID, userID));
-        
-        // No ConnectionUser found means this combination was never used
-        if(count == null)
-            return 0;
-        
-        return count;
-    }
-    
-    /**
-     * Checks if the given user is currently connected to the given BALANCING
-     * connection group.
-     * 
-     * @param connectionGroupID The connection group ID that this 
-     *                          ConnectionUser refers to.
-     * @param userID The user ID that this ConnectionUser refers to.
-     * 
-     * @return True if the given user is currently connected to the given 
-     *         BALANCING connection group, false otherwise.
-     */
-    public boolean isConnectionGroupUserActive(int connectionGroupID, int userID) {
-        Integer count = activeConnectionGroupUserMap.get
-                (new ConnectionUser(connectionGroupID, userID));
-        
-        // The connection group is in use if the ConnectionUser count > 0
-        return count != null && count > 0;
-    }
-    
-    /**
-     * Increment the count of the number of connections opened by the given user
-     * to the given ConnectionGroup.
-     * 
-     * @param connectionGroupID The connection group ID that this 
-     *                          ConnectionUser refers to.
-     * @param userID The user ID that this ConnectionUser refers to.
-     */
-    private void incrementConnectionGroupUserCount(int connectionGroupID, int userID) {
-        int currentCount = getConnectionGroupUserCount(connectionGroupID, userID);
-        
-        activeConnectionGroupUserMap.put
-                (new ConnectionUser(connectionGroupID, userID), currentCount + 1);
-    }
-    
-    /**
-     * Decrement the count of the number of connections opened by the given user
-     * to the given ConnectionGroup.
-     * 
-     * @param connectionGroupID The connection group ID that this 
-     *                          ConnectionUser refers to.
-     * @param userID The user ID that this ConnectionUser refers to.
-     */
-    private void decrementConnectionGroupUserCount(int connectionGroupID, int userID) {
-        int currentCount = getConnectionGroupUserCount(connectionGroupID, userID);
-        
-        activeConnectionGroupUserMap.put
-                (new ConnectionUser(connectionGroupID, userID), currentCount - 1);
-    }
-    
-    /**
-     * Returns the number of connections opened by the given user using 
-     * the given Connection.
-     * 
-     * @param connectionID The connection ID that this ConnectionUser refers to.
-     * @param userID The user ID that this ConnectionUser refers to.
-     * 
-     * @return The number of connections opened by the given user to the given
-     *         connection.
-     */
-    public int getConnectionUserCount(int connectionID, int userID) {
-        Integer count = activeConnectionUserMap.get
-                (new ConnectionUser(connectionID, userID));
-        
-        // No ConnectionUser found means this combination was never used
-        if(count == null)
-            return 0;
-        
-        return count;
-    }
-    
-    /**
-     * Checks if the given user is currently connected to the given connection.
-     * 
-     * @param connectionID The connection ID that this ConnectionUser refers to.
-     * @param userID The user ID that this ConnectionUser refers to.
-     * 
-     * @return True if the given user is currently connected to the given 
-     *         connection, false otherwise.
-     */
-    public boolean isConnectionUserActive(int connectionID, int userID) {
-        Integer count = activeConnectionUserMap.get
-                (new ConnectionUser(connectionID, userID));
-        
-        // The connection is in use if the ConnectionUser count > 0
-        return count != null && count > 0;
-    }
-    
-    /**
-     * Increment the count of the number of connections opened by the given user
-     * to the given Connection.
-     * 
-     * @param connectionID The connection ID that this ConnectionUser refers to.
-     * @param userID The user ID that this ConnectionUser refers to.
-     */
-    private void incrementConnectionUserCount(int connectionID, int userID) {
-        int currentCount = getConnectionGroupUserCount(connectionID, userID);
-        
-        activeConnectionUserMap.put
-                (new ConnectionUser(connectionID, userID), currentCount + 1);
-    }
-    
-    /**
-     * Decrement the count of the number of connections opened by the given user
-     * to the given Connection.
-     * 
-     * @param connectionID The connection ID that this ConnectionUser refers to.
-     * @param userID The user ID that this ConnectionUser refers to.
-     */
-    private void decrementConnectionUserCount(int connectionID, int userID) {
-        int currentCount = getConnectionGroupUserCount(connectionID, userID);
-        
-        activeConnectionUserMap.put
-                (new ConnectionUser(connectionID, userID), currentCount - 1);
-    }
-    
-    /**
-     * Returns the ID of the connection with the lowest number of current
-     * active users, if found.
-     * 
-     * @param connectionIDs The subset of connection IDs to find the least
-     *                      used connection within.
-     * 
-     * @return The ID of the connection with the lowest number of current
-     *         active users, if found.
-     */
-    public Integer getLeastUsedConnection(Collection<Integer> connectionIDs) {
-        
-        if(connectionIDs.isEmpty())
-            return null;
-        
-        int minUserCount = Integer.MAX_VALUE;
-        Integer minConnectionID = null;
-        
-        for(Integer connectionID : connectionIDs) {
-            Connection connection = activeConnectionMap.get(connectionID);
-            
-            /*
-             * If the connection is not found in the map, it has not been used,
-             * and therefore will be count 0.
-             */
-            if(connection == null) {
-                minUserCount = 0;
-                minConnectionID = connectionID;
-            }
-            // If this is the least active connection
-            else if(connection.getCurrentUserCount() < minUserCount) {
-                minUserCount = connection.getCurrentUserCount();
-                minConnectionID = connection.getConnectionID();
-            }
-        }
-        
-        return minConnectionID;
-    }
-    
-    /**
-     * Returns the count of currently active users for the given connectionID.
-     * @return the count of currently active users for the given connectionID.
-     */
-    public int getCurrentUserCount(int connectionID) {
-        Connection connection = activeConnectionMap.get(connectionID);
-        
-        if(connection == null)
-            return 0;
-        
-        return connection.getCurrentUserCount();
-    }
-    
-    /**
-     * Decrement the current user count for this Connection.
-     * 
-     * @param connectionID The ID of the MySQL connection that this 
-     *                     Connection represents.
-     * 
-     * @throws GuacamoleException If the connection is not found.
-     */
-    private void decrementUserCount(int connectionID)
-            throws GuacamoleException {
-        Connection connection = activeConnectionMap.get(connectionID);
-        
-        if(connection == null)
-            throw new GuacamoleException
-                    ("Connection to decrement does not exist.");
-        
-        // Decrement the current user count
-        connection.setCurrentUserCount(connection.getCurrentUserCount() - 1);
-    }
-    
-    /**
-     * Increment the current user count for this Connection.
-     * 
-     * @param connectionID The ID of the MySQL connection that this 
-     *                     Connection represents.
-     * 
-     * @throws GuacamoleException If the connection is not found.
-     */
-    private void incrementUserCount(int connectionID) {
-        Connection connection = activeConnectionMap.get(connectionID);
-        
-        // If the Connection does not exist, it should be created
-        if(connection == null) {
-            connection = new Connection(connectionID);
-            activeConnectionMap.put(connectionID, connection);
-        }
-        
-        // Increment the current user count
-        connection.setCurrentUserCount(connection.getCurrentUserCount() + 1);
-    }
-
-    /**
-     * Check if a connection is currently in use.
-     * @param connectionID The connection to check the status of.
-     * @return true if the connection is currently in use.
-     */
-    public boolean isActive(int connectionID) {
-        return getCurrentUserCount(connectionID) > 0;
-    }
-
-    /**
-     * Set a connection as open.
-     * @param connectionID The ID of the connection that is being opened.
-     * @param userID The ID of the user who is opening the connection.
-     * @param connectionGroupID The ID of the BALANCING connection group that is
-     *                          being connected to; null if not used.
-     * @return The ID of the history record created for this open connection.
-     */
-    public int openConnection(int connectionID, int userID, Integer connectionGroupID) {
-
-        // Create the connection history record
-        ConnectionHistory connectionHistory = new ConnectionHistory();
-        connectionHistory.setConnection_id(connectionID);
-        connectionHistory.setUser_id(userID);
-        connectionHistory.setStart_date(new Date());
-        connectionHistoryDAO.insert(connectionHistory);
-
-        // Increment the user count
-        incrementUserCount(connectionID);
-        
-        // Increment the connection user count
-        incrementConnectionUserCount(connectionID, userID);
-        
-        // If this is a connection to a BALANCING ConnectionGroup, increment the count
-        if(connectionGroupID != null)
-            incrementConnectionGroupUserCount(connectionGroupID, userID);
-
-        return connectionHistory.getHistory_id();
-    }
-
-    /**
-     * Set a connection as closed.
-     * @param historyID The ID of the history record about the open connection.
-     * @param connectionGroupID The ID of the BALANCING connection group that is
-     *                          being connected to; null if not used.
-     * @throws GuacamoleException If the open connection history is not found.
-     */
-    public void closeConnection(int historyID, Integer connectionGroupID) 
-            throws GuacamoleException {
-
-        // Get the existing history record
-        ConnectionHistory connectionHistory =
-                connectionHistoryDAO.selectByPrimaryKey(historyID);
-
-        if(connectionHistory == null)
-            throw new GuacamoleException("History record not found.");
-        
-        // Get the connection and user IDs
-        int connectionID = connectionHistory.getConnection_id();
-        int userID = connectionHistory.getUser_id();
-
-        // Update the connection history record to mark that it is now closed
-        connectionHistory.setEnd_date(new Date());
-        connectionHistoryDAO.updateByPrimaryKey(connectionHistory);
-
-        // Decrement the user count.
-        decrementUserCount(connectionID);
-        
-        // Decrement the connection user count
-        decrementConnectionUserCount(connectionID, userID);
-        
-        // If this is a connection to a BALANCING ConnectionGroup, decrement the count
-        if(connectionGroupID != null)
-            decrementConnectionGroupUserCount(connectionGroupID, userID);
-    }
-}
diff --git a/extensions/guacamole-auth-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/ConnectionDirectory.java b/extensions/guacamole-auth-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/ConnectionDirectory.java
deleted file mode 100644
index 232cdd1..0000000
--- a/extensions/guacamole-auth-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/ConnectionDirectory.java
+++ /dev/null
@@ -1,342 +0,0 @@
-
-package net.sourceforge.guacamole.net.auth.mysql;
-
-/* ***** BEGIN LICENSE BLOCK *****
- * Version: MPL 1.1/GPL 2.0/LGPL 2.1
- *
- * The contents of this file are subject to the Mozilla Public License Version
- * 1.1 (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.mozilla.org/MPL/
- *
- * Software distributed under the License is distributed on an "AS IS" basis,
- * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- * for the specific language governing rights and limitations under the
- * License.
- *
- * The Original Code is guacamole-auth-mysql.
- *
- * The Initial Developer of the Original Code is
- * James Muehlner.
- * Portions created by the Initial Developer are Copyright (C) 2010
- * the Initial Developer. All Rights Reserved.
- *
- * Contributor(s):
- *
- * Alternatively, the contents of this file may be used under the terms of
- * either the GNU General Public License Version 2 or later (the "GPL"), or
- * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
- * in which case the provisions of the GPL or the LGPL are applicable instead
- * of those above. If you wish to allow use of your version of this file only
- * under the terms of either the GPL or the LGPL, and not to allow others to
- * use your version of this file under the terms of the MPL, indicate your
- * decision by deleting the provisions above and replace them with the notice
- * and other provisions required by the GPL or the LGPL. If you do not delete
- * the provisions above, a recipient may use your version of this file under
- * the terms of any one of the MPL, the GPL or the LGPL.
- *
- * ***** END LICENSE BLOCK ***** */
-
-import com.google.inject.Inject;
-import java.util.Set;
-import org.glyptodon.guacamole.GuacamoleClientException;
-import org.glyptodon.guacamole.GuacamoleException;
-import org.glyptodon.guacamole.net.auth.Connection;
-import org.glyptodon.guacamole.net.auth.Directory;
-import net.sourceforge.guacamole.net.auth.mysql.dao.ConnectionParameterMapper;
-import net.sourceforge.guacamole.net.auth.mysql.dao.ConnectionPermissionMapper;
-import net.sourceforge.guacamole.net.auth.mysql.model.ConnectionParameter;
-import net.sourceforge.guacamole.net.auth.mysql.model.ConnectionParameterExample;
-import net.sourceforge.guacamole.net.auth.mysql.model.ConnectionPermissionKey;
-import net.sourceforge.guacamole.net.auth.mysql.service.ConnectionGroupService;
-import net.sourceforge.guacamole.net.auth.mysql.service.ConnectionService;
-import net.sourceforge.guacamole.net.auth.mysql.service.PermissionCheckService;
-import org.glyptodon.guacamole.protocol.GuacamoleConfiguration;
-import org.mybatis.guice.transactional.Transactional;
-
-/**
- * A MySQL-based implementation of the connection directory.
- *
- * @author James Muehlner
- */
-public class ConnectionDirectory implements Directory<String, Connection>{
-
-    /**
-     * The ID of the user who this connection directory belongs to.
-     * Access is based on his/her permission settings.
-     */
-    private int user_id;
-
-    /**
-     * The ID of the parent connection group.
-     */
-    private Integer parentID;
-
-    /**
-     * Service for checking permissions.
-     */
-    @Inject
-    private PermissionCheckService permissionCheckService;
-
-    /**
-     * Service managing connections.
-     */
-    @Inject
-    private ConnectionService connectionService;
-
-    /**
-     * Service managing connection groups.
-     */
-    @Inject
-    private ConnectionGroupService connectionGroupService;
-
-    /**
-     * Service for manipulating connection permissions in the database.
-     */
-    @Inject
-    private ConnectionPermissionMapper connectionPermissionDAO;
-
-    /**
-     * Service for manipulating connection parameters in the database.
-     */
-    @Inject
-    private ConnectionParameterMapper connectionParameterDAO;
-
-    /**
-     * Set the user and parentID for this directory.
-     *
-     * @param user_id The ID of the user owning this connection directory.
-     * @param parentID The ID of the parent connection group.
-     */
-    public void init(int user_id, Integer parentID) {
-        this.parentID = parentID;
-        this.user_id = user_id;
-    }
-
-    @Transactional
-    @Override
-    public Connection get(String identifier) throws GuacamoleException {
-
-        // Get connection
-        MySQLConnection connection =
-                connectionService.retrieveConnection(identifier, user_id);
-        
-        if(connection == null)
-            return null;
-        
-        // Verify permission to use the parent connection group for organizational purposes
-        permissionCheckService.verifyConnectionGroupUsageAccess
-                (connection.getParentID(), user_id, MySQLConstants.CONNECTION_GROUP_ORGANIZATIONAL);
-
-        // Verify access is granted
-        permissionCheckService.verifyConnectionAccess(
-                this.user_id,
-                connection.getConnectionID(),
-                MySQLConstants.CONNECTION_READ);
-
-        // Return connection
-        return connection;
-
-    }
-
-    @Transactional
-    @Override
-    public Set<String> getIdentifiers() throws GuacamoleException {
-        
-        // Verify permission to use the connection group for organizational purposes
-        permissionCheckService.verifyConnectionGroupUsageAccess
-                (parentID, user_id, MySQLConstants.CONNECTION_GROUP_ORGANIZATIONAL);
-        
-        return permissionCheckService.retrieveConnectionIdentifiers(user_id, 
-                parentID, MySQLConstants.CONNECTION_READ);
-    }
-
-    @Transactional
-    @Override
-    public void add(Connection object) throws GuacamoleException {
-
-        String name = object.getName().trim();
-        if(name.isEmpty())
-            throw new GuacamoleClientException("The connection name cannot be blank.");
-        
-        // Verify permission to create
-        permissionCheckService.verifySystemAccess(this.user_id,
-                MySQLConstants.SYSTEM_CONNECTION_CREATE);
-        
-        // Verify permission to edit the connection group
-        permissionCheckService.verifyConnectionGroupAccess(this.user_id, 
-                this.parentID, MySQLConstants.CONNECTION_GROUP_UPDATE);
-        
-        // Verify permission to use the connection group for organizational purposes
-        permissionCheckService.verifyConnectionGroupUsageAccess
-                (parentID, user_id, MySQLConstants.CONNECTION_GROUP_ORGANIZATIONAL);
-
-        // Verify that no connection already exists with this name.
-        MySQLConnection previousConnection =
-                connectionService.retrieveConnection(name, parentID, user_id);
-        if(previousConnection != null)
-            throw new GuacamoleClientException("That connection name is already in use.");
-
-        // Create connection
-        MySQLConnection connection = connectionService.createConnection(
-                name, object.getConfiguration().getProtocol(), user_id, parentID);
-
-        // Add connection parameters
-        createConfigurationValues(connection.getConnectionID(),
-                object.getConfiguration());
-
-        // Finally, give the current user full access to the newly created
-        // connection.
-        ConnectionPermissionKey newConnectionPermission = new ConnectionPermissionKey();
-        newConnectionPermission.setUser_id(this.user_id);
-        newConnectionPermission.setConnection_id(connection.getConnectionID());
-
-        // Read permission
-        newConnectionPermission.setPermission(MySQLConstants.CONNECTION_READ);
-        connectionPermissionDAO.insert(newConnectionPermission);
-
-        // Update permission
-        newConnectionPermission.setPermission(MySQLConstants.CONNECTION_UPDATE);
-        connectionPermissionDAO.insert(newConnectionPermission);
-
-        // Delete permission
-        newConnectionPermission.setPermission(MySQLConstants.CONNECTION_DELETE);
-        connectionPermissionDAO.insert(newConnectionPermission);
-
-        // Administer permission
-        newConnectionPermission.setPermission(MySQLConstants.CONNECTION_ADMINISTER);
-        connectionPermissionDAO.insert(newConnectionPermission);
-
-    }
-
-    /**
-     * Inserts all parameter values from the given configuration into the
-     * database, associating them with the connection having the givenID.
-     *
-     * @param connection_id The ID of the connection to associate all
-     *                      parameters with.
-     * @param config The GuacamoleConfiguration to read parameters from.
-     */
-    private void createConfigurationValues(int connection_id,
-            GuacamoleConfiguration config) {
-
-        // Insert new parameters for each parameter in the config
-        for (String name : config.getParameterNames()) {
-
-            // Create a ConnectionParameter based on the current parameter
-            ConnectionParameter parameter = new ConnectionParameter();
-            parameter.setConnection_id(connection_id);
-            parameter.setParameter_name(name);
-            parameter.setParameter_value(config.getParameter(name));
-
-            // Insert connection parameter
-            connectionParameterDAO.insert(parameter);
-        }
-
-    }
-
-    @Transactional
-    @Override
-    public void update(Connection object) throws GuacamoleException {
-
-        // If connection not actually from this auth provider, we can't handle
-        // the update
-        if (!(object instanceof MySQLConnection))
-            throw new GuacamoleException("Connection not from database.");
-
-        MySQLConnection mySQLConnection = (MySQLConnection) object;
-
-        // Verify permission to update
-        permissionCheckService.verifyConnectionAccess(this.user_id,
-                mySQLConnection.getConnectionID(),
-                MySQLConstants.CONNECTION_UPDATE);
-
-        // Perform update
-        connectionService.updateConnection(mySQLConnection);
-
-        // Delete old connection parameters
-        ConnectionParameterExample parameterExample = new ConnectionParameterExample();
-        parameterExample.createCriteria().andConnection_idEqualTo(mySQLConnection.getConnectionID());
-        connectionParameterDAO.deleteByExample(parameterExample);
-
-        // Add connection parameters
-        createConfigurationValues(mySQLConnection.getConnectionID(),
-                object.getConfiguration());
-
-    }
-
-    @Transactional
-    @Override
-    public void remove(String identifier) throws GuacamoleException {
-
-        // Get connection
-        MySQLConnection mySQLConnection =
-                connectionService.retrieveConnection(identifier, user_id);
-        
-        if(mySQLConnection == null)
-            throw new GuacamoleException("Connection not found.");
-        
-        // Verify permission to use the parent connection group for organizational purposes
-        permissionCheckService.verifyConnectionGroupUsageAccess
-                (mySQLConnection.getParentID(), user_id, MySQLConstants.CONNECTION_GROUP_ORGANIZATIONAL);
-
-        // Verify permission to delete
-        permissionCheckService.verifyConnectionAccess(this.user_id,
-                mySQLConnection.getConnectionID(),
-                MySQLConstants.CONNECTION_DELETE);
-
-        // Delete the connection itself
-        connectionService.deleteConnection(mySQLConnection.getConnectionID());
-
-    }
-
-    @Override
-    public void move(String identifier, Directory<String, Connection> directory) 
-            throws GuacamoleException {
-        
-        if(!(directory instanceof ConnectionDirectory))
-            throw new GuacamoleClientException("Directory not from database");
-        
-        Integer toConnectionGroupID = ((ConnectionDirectory)directory).parentID;
-        
-        // Get connection
-        MySQLConnection mySQLConnection =
-                connectionService.retrieveConnection(identifier, user_id);
-        
-        if(mySQLConnection == null)
-            throw new GuacamoleClientException("Connection not found.");
-
-        // Verify permission to update the connection
-        permissionCheckService.verifyConnectionAccess(this.user_id,
-                mySQLConnection.getConnectionID(),
-                MySQLConstants.CONNECTION_UPDATE);
-        
-        // Verify permission to use the from connection group for organizational purposes
-        permissionCheckService.verifyConnectionGroupUsageAccess
-                (mySQLConnection.getParentID(), user_id, MySQLConstants.CONNECTION_GROUP_ORGANIZATIONAL);
-
-        // Verify permission to update the from connection group
-        permissionCheckService.verifyConnectionGroupAccess(this.user_id,
-                mySQLConnection.getParentID(), MySQLConstants.CONNECTION_GROUP_UPDATE);
-        
-        // Verify permission to use the to connection group for organizational purposes
-        permissionCheckService.verifyConnectionGroupUsageAccess
-                (toConnectionGroupID, user_id, MySQLConstants.CONNECTION_GROUP_ORGANIZATIONAL);
-
-        // Verify permission to update the to connection group
-        permissionCheckService.verifyConnectionGroupAccess(this.user_id,
-                toConnectionGroupID, MySQLConstants.CONNECTION_GROUP_UPDATE);
-
-        // Verify that no connection already exists with this name.
-        MySQLConnection previousConnection =
-                connectionService.retrieveConnection(mySQLConnection.getName(), 
-                toConnectionGroupID, user_id);
-        if(previousConnection != null)
-            throw new GuacamoleClientException("That connection name is already in use.");
-        
-        // Update the connection
-        mySQLConnection.setParentID(toConnectionGroupID);
-        connectionService.updateConnection(mySQLConnection);
-    }
-
-}
diff --git a/extensions/guacamole-auth-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/ConnectionGroupDirectory.java b/extensions/guacamole-auth-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/ConnectionGroupDirectory.java
deleted file mode 100644
index fdcb862..0000000
--- a/extensions/guacamole-auth-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/ConnectionGroupDirectory.java
+++ /dev/null
@@ -1,306 +0,0 @@
-
-package net.sourceforge.guacamole.net.auth.mysql;
-
-/* ***** BEGIN LICENSE BLOCK *****
- * Version: MPL 1.1/GPL 2.0/LGPL 2.1
- *
- * The contents of this file are subject to the Mozilla Public License Version
- * 1.1 (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.mozilla.org/MPL/
- *
- * Software distributed under the License is distributed on an "AS IS" basis,
- * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- * for the specific language governing rights and limitations under the
- * License.
- *
- * The Original Code is guacamole-auth-mysql.
- *
- * The Initial Developer of the Original Code is
- * James Muehlner.
- * Portions created by the Initial Developer are Copyright (C) 2010
- * the Initial Developer. All Rights Reserved.
- *
- * Contributor(s):
- *
- * Alternatively, the contents of this file may be used under the terms of
- * either the GNU General Public License Version 2 or later (the "GPL"), or
- * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
- * in which case the provisions of the GPL or the LGPL are applicable instead
- * of those above. If you wish to allow use of your version of this file only
- * under the terms of either the GPL or the LGPL, and not to allow others to
- * use your version of this file under the terms of the MPL, indicate your
- * decision by deleting the provisions above and replace them with the notice
- * and other provisions required by the GPL or the LGPL. If you do not delete
- * the provisions above, a recipient may use your version of this file under
- * the terms of any one of the MPL, the GPL or the LGPL.
- *
- * ***** END LICENSE BLOCK ***** */
-
-import com.google.inject.Inject;
-import java.util.Set;
-import org.glyptodon.guacamole.GuacamoleClientException;
-import org.glyptodon.guacamole.GuacamoleException;
-import org.glyptodon.guacamole.net.auth.ConnectionGroup;
-import org.glyptodon.guacamole.net.auth.ConnectionGroup.Type;
-import org.glyptodon.guacamole.net.auth.Directory;
-import net.sourceforge.guacamole.net.auth.mysql.dao.ConnectionGroupPermissionMapper;
-import net.sourceforge.guacamole.net.auth.mysql.model.ConnectionGroupPermissionKey;
-import net.sourceforge.guacamole.net.auth.mysql.service.ConnectionGroupService;
-import net.sourceforge.guacamole.net.auth.mysql.service.PermissionCheckService;
-import org.mybatis.guice.transactional.Transactional;
-
-/**
- * A MySQL-based implementation of the connection group directory.
- *
- * @author James Muehlner
- */
-public class ConnectionGroupDirectory implements Directory<String, ConnectionGroup>{
-
-    /**
-     * The ID of the user who this connection directory belongs to.
-     * Access is based on his/her permission settings.
-     */
-    private int user_id;
-
-    /**
-     * The ID of the parent connection group.
-     */
-    private Integer parentID;
-
-    /**
-     * Service for checking permissions.
-     */
-    @Inject
-    private PermissionCheckService permissionCheckService;
-
-    /**
-     * Service managing connection groups.
-     */
-    @Inject
-    private ConnectionGroupService connectionGroupService;
-
-    /**
-     * Service for manipulating connection group permissions in the database.
-     */
-    @Inject
-    private ConnectionGroupPermissionMapper connectionGroupPermissionDAO;
-
-    /**
-     * Set the user and parentID for this directory.
-     *
-     * @param user_id The ID of the user owning this connection group directory.
-     * @param parentID The ID of the parent connection group.
-     */
-    public void init(int user_id, Integer parentID) {
-        this.parentID = parentID;
-        this.user_id = user_id;
-    }
-
-    @Transactional
-    @Override
-    public ConnectionGroup get(String identifier) throws GuacamoleException {
-
-        // Get connection
-        MySQLConnectionGroup connectionGroup =
-                connectionGroupService.retrieveConnectionGroup(identifier, user_id);
-        
-        if(connectionGroup == null)
-            return null;
-        
-        // Verify permission to use the parent connection group for organizational purposes
-        permissionCheckService.verifyConnectionGroupUsageAccess
-                (connectionGroup.getParentID(), user_id, MySQLConstants.CONNECTION_GROUP_ORGANIZATIONAL);
-
-        // Verify access is granted
-        permissionCheckService.verifyConnectionGroupAccess(
-                this.user_id,
-                connectionGroup.getConnectionGroupID(),
-                MySQLConstants.CONNECTION_GROUP_READ);
-
-        // Return connection group
-        return connectionGroup;
-
-    }
-
-    @Transactional
-    @Override
-    public Set<String> getIdentifiers() throws GuacamoleException {
-        
-        // Verify permission to use the connection group for organizational purposes
-        permissionCheckService.verifyConnectionGroupUsageAccess
-                (parentID, user_id, MySQLConstants.CONNECTION_GROUP_ORGANIZATIONAL);
-        
-        return permissionCheckService.retrieveConnectionGroupIdentifiers(user_id, 
-                parentID, MySQLConstants.CONNECTION_GROUP_READ);
-    }
-
-    @Transactional
-    @Override
-    public void add(ConnectionGroup object) throws GuacamoleException {
-
-        String name = object.getName().trim();
-        if(name.isEmpty())
-            throw new GuacamoleClientException("The connection group name cannot be blank.");
-        
-        Type type = object.getType();
-        
-        String mySQLType = MySQLConstants.getConnectionGroupTypeConstant(type);
-        
-        // Verify permission to create
-        permissionCheckService.verifySystemAccess(this.user_id,
-                MySQLConstants.SYSTEM_CONNECTION_GROUP_CREATE);
-        
-        // Verify permission to edit the parent connection group
-        permissionCheckService.verifyConnectionGroupAccess(this.user_id, 
-                this.parentID, MySQLConstants.CONNECTION_GROUP_UPDATE);
-        
-        // Verify permission to use the parent connection group for organizational purposes
-        permissionCheckService.verifyConnectionGroupUsageAccess
-                (parentID, user_id, MySQLConstants.CONNECTION_GROUP_ORGANIZATIONAL);
-
-        // Verify that no connection already exists with this name.
-        MySQLConnectionGroup previousConnectionGroup =
-                connectionGroupService.retrieveConnectionGroup(name, parentID, user_id);
-        if(previousConnectionGroup != null)
-            throw new GuacamoleClientException("That connection group name is already in use.");
-
-        // Create connection group
-        MySQLConnectionGroup connectionGroup = connectionGroupService
-                .createConnectionGroup(name, user_id, parentID, mySQLType);
-
-        // Finally, give the current user full access to the newly created
-        // connection group.
-        ConnectionGroupPermissionKey newConnectionGroupPermission = new ConnectionGroupPermissionKey();
-        newConnectionGroupPermission.setUser_id(this.user_id);
-        newConnectionGroupPermission.setConnection_group_id(connectionGroup.getConnectionGroupID());
-
-        // Read permission
-        newConnectionGroupPermission.setPermission(MySQLConstants.CONNECTION_GROUP_READ);
-        connectionGroupPermissionDAO.insert(newConnectionGroupPermission);
-
-        // Update permission
-        newConnectionGroupPermission.setPermission(MySQLConstants.CONNECTION_GROUP_UPDATE);
-        connectionGroupPermissionDAO.insert(newConnectionGroupPermission);
-
-        // Delete permission
-        newConnectionGroupPermission.setPermission(MySQLConstants.CONNECTION_GROUP_DELETE);
-        connectionGroupPermissionDAO.insert(newConnectionGroupPermission);
-
-        // Administer permission
-        newConnectionGroupPermission.setPermission(MySQLConstants.CONNECTION_GROUP_ADMINISTER);
-        connectionGroupPermissionDAO.insert(newConnectionGroupPermission);
-
-    }
-
-    @Transactional
-    @Override
-    public void update(ConnectionGroup object) throws GuacamoleException {
-
-        // If connection not actually from this auth provider, we can't handle
-        // the update
-        if (!(object instanceof MySQLConnectionGroup))
-            throw new GuacamoleException("Connection not from database.");
-
-        MySQLConnectionGroup mySQLConnectionGroup = (MySQLConnectionGroup) object;
-
-        // Verify permission to update
-        permissionCheckService.verifyConnectionAccess(this.user_id,
-                mySQLConnectionGroup.getConnectionGroupID(),
-                MySQLConstants.CONNECTION_UPDATE);
-
-        // Perform update
-        connectionGroupService.updateConnectionGroup(mySQLConnectionGroup);
-    }
-
-    @Transactional
-    @Override
-    public void remove(String identifier) throws GuacamoleException {
-
-        // Get connection
-        MySQLConnectionGroup mySQLConnectionGroup =
-                connectionGroupService.retrieveConnectionGroup(identifier, user_id);
-        
-        if(mySQLConnectionGroup == null)
-            throw new GuacamoleException("Connection group not found.");
-        
-        // Verify permission to use the parent connection group for organizational purposes
-        permissionCheckService.verifyConnectionGroupUsageAccess
-                (mySQLConnectionGroup.getParentID(), user_id, MySQLConstants.CONNECTION_GROUP_ORGANIZATIONAL);
-
-        // Verify permission to delete
-        permissionCheckService.verifyConnectionGroupAccess(this.user_id,
-                mySQLConnectionGroup.getConnectionGroupID(),
-                MySQLConstants.CONNECTION_GROUP_DELETE);
-
-        // Delete the connection group itself
-        connectionGroupService.deleteConnectionGroup
-                (mySQLConnectionGroup.getConnectionGroupID());
-
-    }
-
-    @Override
-    public void move(String identifier, Directory<String, ConnectionGroup> directory) 
-            throws GuacamoleException {
-        
-        if(MySQLConstants.CONNECTION_GROUP_ROOT_IDENTIFIER.equals(identifier))
-            throw new GuacamoleClientException("The root connection group cannot be moved.");
-        
-        if(!(directory instanceof ConnectionGroupDirectory))
-            throw new GuacamoleClientException("Directory not from database");
-        
-        Integer toConnectionGroupID = ((ConnectionGroupDirectory)directory).parentID;
-
-        // Get connection group
-        MySQLConnectionGroup mySQLConnectionGroup =
-                connectionGroupService.retrieveConnectionGroup(identifier, user_id);
-        
-        if(mySQLConnectionGroup == null)
-            throw new GuacamoleClientException("Connection group not found.");
-
-        // Verify permission to update the connection
-        permissionCheckService.verifyConnectionAccess(this.user_id,
-                mySQLConnectionGroup.getConnectionGroupID(),
-                MySQLConstants.CONNECTION_GROUP_UPDATE);
-        
-        // Verify permission to use the from connection group for organizational purposes
-        permissionCheckService.verifyConnectionGroupUsageAccess
-                (mySQLConnectionGroup.getParentID(), user_id, MySQLConstants.CONNECTION_GROUP_ORGANIZATIONAL);
-
-        // Verify permission to update the from connection group
-        permissionCheckService.verifyConnectionGroupAccess(this.user_id,
-                mySQLConnectionGroup.getParentID(), MySQLConstants.CONNECTION_GROUP_UPDATE);
-        
-        // Verify permission to use the to connection group for organizational purposes
-        permissionCheckService.verifyConnectionGroupUsageAccess
-                (toConnectionGroupID, user_id, MySQLConstants.CONNECTION_GROUP_ORGANIZATIONAL);
-
-        // Verify permission to update the to connection group
-        permissionCheckService.verifyConnectionGroupAccess(this.user_id,
-                toConnectionGroupID, MySQLConstants.CONNECTION_GROUP_UPDATE);
-
-        // Verify that no connection already exists with this name.
-        MySQLConnectionGroup previousConnectionGroup =
-                connectionGroupService.retrieveConnectionGroup(mySQLConnectionGroup.getName(), 
-                toConnectionGroupID, user_id);
-        if(previousConnectionGroup != null)
-            throw new GuacamoleClientException("That connection group name is already in use.");
-        
-        // Verify that moving this connectionGroup would not cause a cycle
-        Integer relativeParentID = toConnectionGroupID;
-        while(relativeParentID != null) {
-            if(relativeParentID == mySQLConnectionGroup.getConnectionGroupID())
-                throw new GuacamoleClientException("Connection group cycle detected.");
-            
-            MySQLConnectionGroup relativeParentGroup = connectionGroupService.
-                    retrieveConnectionGroup(relativeParentID, user_id);
-            
-            relativeParentID = relativeParentGroup.getParentID();
-        }
-        
-        // Update the connection
-        mySQLConnectionGroup.setParentID(toConnectionGroupID);
-        connectionGroupService.updateConnectionGroup(mySQLConnectionGroup);
-    }
-
-}
diff --git a/extensions/guacamole-auth-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/MySQLAuthenticationProvider.java b/extensions/guacamole-auth-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/MySQLAuthenticationProvider.java
deleted file mode 100644
index 6877ca3..0000000
--- a/extensions/guacamole-auth-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/MySQLAuthenticationProvider.java
+++ /dev/null
@@ -1,197 +0,0 @@
-
-package net.sourceforge.guacamole.net.auth.mysql;
-
-/* ***** BEGIN LICENSE BLOCK *****
- * Version: MPL 1.1/GPL 2.0/LGPL 2.1
- *
- * The contents of this file are subject to the Mozilla Public License Version
- * 1.1 (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.mozilla.org/MPL/
- *
- * Software distributed under the License is distributed on an "AS IS" basis,
- * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- * for the specific language governing rights and limitations under the
- * License.
- *
- * The Original Code is guacamole-auth-mysql.
- *
- * The Initial Developer of the Original Code is
- * James Muehlner.
- * Portions created by the Initial Developer are Copyright (C) 2010
- * the Initial Developer. All Rights Reserved.
- *
- * Contributor(s):
- *
- * Alternatively, the contents of this file may be used under the terms of
- * either the GNU General Public License Version 2 or later (the "GPL"), or
- * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
- * in which case the provisions of the GPL or the LGPL are applicable instead
- * of those above. If you wish to allow use of your version of this file only
- * under the terms of either the GPL or the LGPL, and not to allow others to
- * use your version of this file under the terms of the MPL, indicate your
- * decision by deleting the provisions above and replace them with the notice
- * and other provisions required by the GPL or the LGPL. If you do not delete
- * the provisions above, a recipient may use your version of this file under
- * the terms of any one of the MPL, the GPL or the LGPL.
- *
- * ***** END LICENSE BLOCK ***** */
-
-import com.google.inject.Binder;
-import com.google.inject.Guice;
-import com.google.inject.Injector;
-import com.google.inject.Module;
-import com.google.inject.name.Names;
-import java.util.Properties;
-import org.glyptodon.guacamole.GuacamoleException;
-import org.glyptodon.guacamole.net.auth.AuthenticationProvider;
-import org.glyptodon.guacamole.net.auth.Credentials;
-import org.glyptodon.guacamole.net.auth.UserContext;
-import net.sourceforge.guacamole.net.auth.mysql.dao.ConnectionGroupMapper;
-import net.sourceforge.guacamole.net.auth.mysql.dao.ConnectionGroupPermissionMapper;
-import net.sourceforge.guacamole.net.auth.mysql.dao.ConnectionHistoryMapper;
-import net.sourceforge.guacamole.net.auth.mysql.dao.ConnectionMapper;
-import net.sourceforge.guacamole.net.auth.mysql.dao.ConnectionParameterMapper;
-import net.sourceforge.guacamole.net.auth.mysql.dao.ConnectionPermissionMapper;
-import net.sourceforge.guacamole.net.auth.mysql.dao.SystemPermissionMapper;
-import net.sourceforge.guacamole.net.auth.mysql.dao.UserMapper;
-import net.sourceforge.guacamole.net.auth.mysql.dao.UserPermissionMapper;
-import net.sourceforge.guacamole.net.auth.mysql.properties.MySQLGuacamoleProperties;
-import net.sourceforge.guacamole.net.auth.mysql.service.ConnectionGroupService;
-import net.sourceforge.guacamole.net.auth.mysql.service.ConnectionService;
-import net.sourceforge.guacamole.net.auth.mysql.service.PasswordEncryptionService;
-import net.sourceforge.guacamole.net.auth.mysql.service.PermissionCheckService;
-import net.sourceforge.guacamole.net.auth.mysql.service.SHA256PasswordEncryptionService;
-import net.sourceforge.guacamole.net.auth.mysql.service.SaltService;
-import net.sourceforge.guacamole.net.auth.mysql.service.SecureRandomSaltService;
-import net.sourceforge.guacamole.net.auth.mysql.service.UserService;
-import org.glyptodon.guacamole.properties.GuacamoleProperties;
-import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
-import org.mybatis.guice.MyBatisModule;
-import org.mybatis.guice.datasource.builtin.PooledDataSourceProvider;
-import org.mybatis.guice.datasource.helper.JdbcHelper;
-
-/**
- * Provides a MySQL based implementation of the AuthenticationProvider
- * functionality.
- *
- * @author James Muehlner
- */
-public class MySQLAuthenticationProvider implements AuthenticationProvider {
-
-    /**
-     * Set of all active connections.
-     */
-    private ActiveConnectionMap activeConnectionMap = new ActiveConnectionMap();
-
-    /**
-     * Injector which will manage the object graph of this authentication
-     * provider.
-     */
-    private Injector injector;
-
-    @Override
-    public UserContext getUserContext(Credentials credentials) throws GuacamoleException {
-
-        // Get user service
-        UserService userService = injector.getInstance(UserService.class);
-
-        // Get user
-        MySQLUser authenticatedUser = userService.retrieveUser(credentials);
-        if (authenticatedUser != null) {
-            MySQLUserContext context = injector.getInstance(MySQLUserContext.class);
-            context.init(authenticatedUser.getUserID());
-            return context;
-        }
-
-        // Otherwise, unauthorized
-        return null;
-
-    }
-
-    /**
-     * Creates a new MySQLAuthenticationProvider that reads and writes
-     * authentication data to a MySQL database defined by properties in
-     * guacamole.properties.
-     *
-     * @throws GuacamoleException If a required property is missing, or
-     *                            an error occurs while parsing a property.
-     */
-    public MySQLAuthenticationProvider() throws GuacamoleException {
-
-        final Properties myBatisProperties = new Properties();
-        final Properties driverProperties = new Properties();
-
-        // Set the mysql properties for MyBatis.
-        myBatisProperties.setProperty("mybatis.environment.id", "guacamole");
-        myBatisProperties.setProperty("JDBC.host", GuacamoleProperties.getRequiredProperty(MySQLGuacamoleProperties.MYSQL_HOSTNAME));
-        myBatisProperties.setProperty("JDBC.port", String.valueOf(GuacamoleProperties.getRequiredProperty(MySQLGuacamoleProperties.MYSQL_PORT)));
-        myBatisProperties.setProperty("JDBC.schema", GuacamoleProperties.getRequiredProperty(MySQLGuacamoleProperties.MYSQL_DATABASE));
-        myBatisProperties.setProperty("JDBC.username", GuacamoleProperties.getRequiredProperty(MySQLGuacamoleProperties.MYSQL_USERNAME));
-        myBatisProperties.setProperty("JDBC.password", GuacamoleProperties.getRequiredProperty(MySQLGuacamoleProperties.MYSQL_PASSWORD));
-        myBatisProperties.setProperty("JDBC.autoCommit", "false");
-        driverProperties.setProperty("characterEncoding","UTF-8");
-
-        // Set up Guice injector.
-        injector = Guice.createInjector(
-            JdbcHelper.MySQL,
-
-            new Module() {
-                @Override
-                public void configure(Binder binder) {
-                    Names.bindProperties(binder, myBatisProperties);
-                    binder.bind(Properties.class)
-                        .annotatedWith(Names.named("JDBC.driverProperties"))
-                        .toInstance(driverProperties);
-                }
-            },
-
-            new MyBatisModule() {
-                @Override
-                protected void initialize() {
-
-                    // Datasource
-                    bindDataSourceProviderType(PooledDataSourceProvider.class);
-
-                    // Transaction factory
-                    bindTransactionFactoryType(JdbcTransactionFactory.class);
-
-                    // Add MyBatis mappers
-                    addMapperClass(ConnectionHistoryMapper.class);
-                    addMapperClass(ConnectionMapper.class);
-                    addMapperClass(ConnectionGroupMapper.class);
-                    addMapperClass(ConnectionGroupPermissionMapper.class);
-                    addMapperClass(ConnectionParameterMapper.class);
-                    addMapperClass(ConnectionPermissionMapper.class);
-                    addMapperClass(SystemPermissionMapper.class);
-                    addMapperClass(UserMapper.class);
-                    addMapperClass(UserPermissionMapper.class);
-
-                    // Bind interfaces
-                    bind(MySQLUserContext.class);
-                    bind(UserDirectory.class);
-                    bind(MySQLUser.class);
-                    bind(SaltService.class).to(SecureRandomSaltService.class);
-                    bind(PasswordEncryptionService.class).to(SHA256PasswordEncryptionService.class);
-                    bind(PermissionCheckService.class);
-                    bind(ConnectionService.class);
-                    bind(ConnectionGroupService.class);
-                    bind(UserService.class);
-                    bind(ActiveConnectionMap.class).toInstance(activeConnectionMap);
-
-                }
-            } // end of mybatis module
-
-        );
-    } // end of constructor
-
-    @Override
-    public UserContext updateUserContext(UserContext context,
-        Credentials credentials) throws GuacamoleException {
-
-        // No need to update the context
-        return context;
-
-    }
-
-}
diff --git a/extensions/guacamole-auth-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/MySQLConnection.java b/extensions/guacamole-auth-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/MySQLConnection.java
deleted file mode 100644
index 96258aa..0000000
--- a/extensions/guacamole-auth-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/MySQLConnection.java
+++ /dev/null
@@ -1,156 +0,0 @@
-
-package net.sourceforge.guacamole.net.auth.mysql;
-
-/* ***** BEGIN LICENSE BLOCK *****
- * Version: MPL 1.1/GPL 2.0/LGPL 2.1
- *
- * The contents of this file are subject to the Mozilla Public License Version
- * 1.1 (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.mozilla.org/MPL/
- *
- * Software distributed under the License is distributed on an "AS IS" basis,
- * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- * for the specific language governing rights and limitations under the
- * License.
- *
- * The Original Code is guacamole-auth-mysql.
- *
- * The Initial Developer of the Original Code is
- * James Muehlner.
- * Portions created by the Initial Developer are Copyright (C) 2010
- * the Initial Developer. All Rights Reserved.
- *
- * Contributor(s):
- *
- * Alternatively, the contents of this file may be used under the terms of
- * either the GNU General Public License Version 2 or later (the "GPL"), or
- * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
- * in which case the provisions of the GPL or the LGPL are applicable instead
- * of those above. If you wish to allow use of your version of this file only
- * under the terms of either the GPL or the LGPL, and not to allow others to
- * use your version of this file under the terms of the MPL, indicate your
- * decision by deleting the provisions above and replace them with the notice
- * and other provisions required by the GPL or the LGPL. If you do not delete
- * the provisions above, a recipient may use your version of this file under
- * the terms of any one of the MPL, the GPL or the LGPL.
- *
- * ***** END LICENSE BLOCK ***** */
-
-import com.google.inject.Inject;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
-import org.glyptodon.guacamole.GuacamoleException;
-import org.glyptodon.guacamole.net.GuacamoleSocket;
-import org.glyptodon.guacamole.net.auth.AbstractConnection;
-import org.glyptodon.guacamole.net.auth.ConnectionRecord;
-import net.sourceforge.guacamole.net.auth.mysql.service.ConnectionService;
-import org.glyptodon.guacamole.protocol.GuacamoleClientInformation;
-import org.glyptodon.guacamole.protocol.GuacamoleConfiguration;
-
-/**
- * A MySQL based implementation of the Connection object.
- * @author James Muehlner
- */
-public class MySQLConnection extends AbstractConnection {
-
-    /**
-     * The ID associated with this connection in the database.
-     */
-    private Integer connectionID;
-
-    /**
-     * The ID of the parent connection group for this connection.
-     */
-    private Integer parentID;
-
-    /**
-     * The ID of the user who queried or created this connection.
-     */
-    private int userID;
-
-    /**
-     * History of this connection.
-     */
-    private List<ConnectionRecord> history = new ArrayList<ConnectionRecord>();
-
-    /**
-     * Service for managing connections.
-     */
-    @Inject
-    private ConnectionService connectionService;
-
-    /**
-     * Create a default, empty connection.
-     */
-    public MySQLConnection() {
-    }
-
-    /**
-     * Get the ID of the corresponding connection record.
-     * @return The ID of the corresponding connection, if any.
-     */
-    public Integer getConnectionID() {
-        return connectionID;
-    }
-
-    /**
-     * Sets the ID of the corresponding connection record.
-     * @param connectionID The ID to assign to this connection.
-     */
-    public void setConnectionID(Integer connectionID) {
-        this.connectionID = connectionID;
-    }
-
-    /**
-     * Get the ID of the parent connection group for this connection, if any.
-     * @return The ID of the parent connection group for this connection, if any.
-     */
-    public Integer getParentID() {
-        return parentID;
-    }
-
-    /**
-     * Sets the ID of the parent connection group for this connection.
-     * @param connectionID The ID of the parent connection group for this connection.
-     */
-    public void setParentID(Integer parentID) {
-        this.parentID = parentID;
-    }
-
-    /**
-     * Initialize from explicit values.
-     *
-     * @param connectionID The ID of the associated database record, if any.
-     * @param parentID The D of the parent connection group for this connection, if any.
-     * @param identifier The unique identifier associated with this connection.
-     * @param config The GuacamoleConfiguration associated with this connection.
-     * @param history All ConnectionRecords associated with this connection.
-     * @param userID The IID of the user who queried this connection.
-     */
-    public void init(Integer connectionID, Integer parentID, String name, 
-            String identifier, GuacamoleConfiguration config,
-            List<? extends ConnectionRecord> history, int userID) {
-
-        this.connectionID = connectionID;
-        this.parentID = parentID;
-        setName(name);
-        setIdentifier(identifier);
-        setConfiguration(config);
-        this.history.addAll(history);
-        this.userID = userID;
-
-    }
-
-    @Override
-    public GuacamoleSocket connect(GuacamoleClientInformation info) throws GuacamoleException {
-        return connectionService.connect(this, info, userID, null);
-    }
-
-    @Override
-    public List<? extends ConnectionRecord> getHistory() throws GuacamoleException {
-        return Collections.unmodifiableList(history);
-    }
-
-}
diff --git a/extensions/guacamole-auth-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/MySQLConnectionGroup.java b/extensions/guacamole-auth-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/MySQLConnectionGroup.java
deleted file mode 100644
index bba1ef1..0000000
--- a/extensions/guacamole-auth-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/MySQLConnectionGroup.java
+++ /dev/null
@@ -1,193 +0,0 @@
-
-package net.sourceforge.guacamole.net.auth.mysql;
-
-/* ***** BEGIN LICENSE BLOCK *****
- * Version: MPL 1.1/GPL 2.0/LGPL 2.1
- *
- * The contents of this file are subject to the Mozilla Public License Version
- * 1.1 (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.mozilla.org/MPL/
- *
- * Software distributed under the License is distributed on an "AS IS" basis,
- * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- * for the specific language governing rights and limitations under the
- * License.
- *
- * The Original Code is guacamole-auth-mysql.
- *
- * The Initial Developer of the Original Code is
- * James Muehlner.
- * Portions created by the Initial Developer are Copyright (C) 2010
- * the Initial Developer. All Rights Reserved.
- *
- * Contributor(s):
- *
- * Alternatively, the contents of this file may be used under the terms of
- * either the GNU General Public License Version 2 or later (the "GPL"), or
- * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
- * in which case the provisions of the GPL or the LGPL are applicable instead
- * of those above. If you wish to allow use of your version of this file only
- * under the terms of either the GPL or the LGPL, and not to allow others to
- * use your version of this file under the terms of the MPL, indicate your
- * decision by deleting the provisions above and replace them with the notice
- * and other provisions required by the GPL or the LGPL. If you do not delete
- * the provisions above, a recipient may use your version of this file under
- * the terms of any one of the MPL, the GPL or the LGPL.
- *
- * ***** END LICENSE BLOCK ***** */
-
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import org.glyptodon.guacamole.GuacamoleException;
-import org.glyptodon.guacamole.net.GuacamoleSocket;
-import org.glyptodon.guacamole.net.auth.AbstractConnectionGroup;
-import org.glyptodon.guacamole.net.auth.Connection;
-import org.glyptodon.guacamole.net.auth.ConnectionGroup;
-import org.glyptodon.guacamole.net.auth.Directory;
-import net.sourceforge.guacamole.net.auth.mysql.service.ConnectionGroupService;
-import net.sourceforge.guacamole.net.auth.mysql.service.PermissionCheckService;
-import org.glyptodon.guacamole.protocol.GuacamoleClientInformation;
-
-/**
- * A MySQL based implementation of the ConnectionGroup object.
- * @author James Muehlner
- */
-public class MySQLConnectionGroup extends AbstractConnectionGroup {
-
-    /**
-     * The ID associated with this connection group in the database.
-     */
-    private Integer connectionGroupID;
-
-    /**
-     * The ID of the parent connection group for this connection group.
-     */
-    private Integer parentID;
-
-    /**
-     * The ID of the user who queried or created this connection group.
-     */
-    private int userID;
-    
-    /**
-     * A Directory of connections that have this connection group as a parent.
-     */
-    private ConnectionDirectory connectionDirectory = null;
-    
-    /**
-     * A Directory of connection groups that have this connection group as a parent.
-     */
-    private ConnectionGroupDirectory connectionGroupDirectory = null;
-
-    /**
-     * Service managing connection groups.
-     */
-    @Inject
-    private ConnectionGroupService connectionGroupService;
-
-    /**
-     * Service for checking permissions.
-     */
-    @Inject
-    private PermissionCheckService permissionCheckService;
-    
-    /**
-     * Service for creating new ConnectionDirectory objects.
-     */
-    @Inject Provider<ConnectionDirectory> connectionDirectoryProvider;
-    
-    /**
-     * Service for creating new ConnectionGroupDirectory objects.
-     */
-    @Inject Provider<ConnectionGroupDirectory> connectionGroupDirectoryProvider;
-
-    /**
-     * Create a default, empty connection group.
-     */
-    public MySQLConnectionGroup() {
-    }
-
-    /**
-     * Get the ID of the corresponding connection group record.
-     * @return The ID of the corresponding connection group, if any.
-     */
-    public Integer getConnectionGroupID() {
-        return connectionGroupID;
-    }
-
-    /**
-     * Sets the ID of the corresponding connection group record.
-     * @param connectionID The ID to assign to this connection group.
-     */
-    public void setConnectionID(Integer connectionGroupID) {
-        this.connectionGroupID = connectionGroupID;
-    }
-
-    /**
-     * Get the ID of the parent connection group for this connection group, if any.
-     * @return The ID of the parent connection group for this connection group, if any.
-     */
-    public Integer getParentID() {
-        return parentID;
-    }
-
-    /**
-     * Sets the ID of the parent connection group for this connection group.
-     * @param connectionID The ID of the parent connection group for this connection group.
-     */
-    public void setParentID(Integer parentID) {
-        this.parentID = parentID;
-    }
-
-    /**
-     * Initialize from explicit values.
-     *
-     * @param connectionGroupID The ID of the associated database record, if any.
-     * @param parentID The ID of the parent connection group for this connection group, if any.
-     * @param identifier The unique identifier associated with this connection group.
-     * @param type The type of this connection group.
-     * @param userID The IID of the user who queried this connection.
-     */
-    public void init(Integer connectionGroupID, Integer parentID, String name, 
-            String identifier, ConnectionGroup.Type type, int userID) {
-        this.connectionGroupID = connectionGroupID;
-        this.parentID = parentID;
-        setName(name);
-        setIdentifier(identifier);
-        setType(type);
-        this.userID = userID;
-        
-        connectionDirectory = connectionDirectoryProvider.get();
-        connectionDirectory.init(userID, connectionGroupID);
-        
-        connectionGroupDirectory = connectionGroupDirectoryProvider.get();
-        connectionGroupDirectory.init(userID, connectionGroupID);
-    }
-
-    @Override
-    public GuacamoleSocket connect(GuacamoleClientInformation info) throws GuacamoleException {
-        
-        // Verify permission to use the connection group for balancing purposes
-        permissionCheckService.verifyConnectionGroupUsageAccess
-                (this.connectionGroupID, this.userID, MySQLConstants.CONNECTION_GROUP_BALANCING);
-
-        // Verify permission to delete
-        permissionCheckService.verifyConnectionGroupAccess(this.userID,
-                this.connectionGroupID,
-                MySQLConstants.CONNECTION_GROUP_READ);
-        
-        return connectionGroupService.connect(this, info, userID);
-    }
-    
-    @Override
-    public Directory<String, Connection> getConnectionDirectory() throws GuacamoleException {
-        return connectionDirectory;
-    }
-
-    @Override
-    public Directory<String, ConnectionGroup> getConnectionGroupDirectory() throws GuacamoleException {
-        return connectionGroupDirectory;
-    }
-
-}
diff --git a/extensions/guacamole-auth-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/MySQLConnectionRecord.java b/extensions/guacamole-auth-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/MySQLConnectionRecord.java
deleted file mode 100644
index c7903ad..0000000
--- a/extensions/guacamole-auth-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/MySQLConnectionRecord.java
+++ /dev/null
@@ -1,103 +0,0 @@
-
-package net.sourceforge.guacamole.net.auth.mysql;
-
-/* ***** BEGIN LICENSE BLOCK *****
- * Version: MPL 1.1/GPL 2.0/LGPL 2.1
- *
- * The contents of this file are subject to the Mozilla Public License Version
- * 1.1 (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.mozilla.org/MPL/
- *
- * Software distributed under the License is distributed on an "AS IS" basis,
- * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- * for the specific language governing rights and limitations under the
- * License.
- *
- * The Original Code is guacamole-auth-mysql.
- *
- * The Initial Developer of the Original Code is
- * James Muehlner.
- * Portions created by the Initial Developer are Copyright (C) 2010
- * the Initial Developer. All Rights Reserved.
- *
- * Contributor(s):
- *
- * Alternatively, the contents of this file may be used under the terms of
- * either the GNU General Public License Version 2 or later (the "GPL"), or
- * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
- * in which case the provisions of the GPL or the LGPL are applicable instead
- * of those above. If you wish to allow use of your version of this file only
- * under the terms of either the GPL or the LGPL, and not to allow others to
- * use your version of this file under the terms of the MPL, indicate your
- * decision by deleting the provisions above and replace them with the notice
- * and other provisions required by the GPL or the LGPL. If you do not delete
- * the provisions above, a recipient may use your version of this file under
- * the terms of any one of the MPL, the GPL or the LGPL.
- *
- * ***** END LICENSE BLOCK ***** */
-
-import java.util.Date;
-import org.glyptodon.guacamole.net.auth.ConnectionRecord;
-
-/**
- * A ConnectionRecord which is based on data stored in MySQL.
- *
- * @author James Muehlner
- */
-public class MySQLConnectionRecord implements ConnectionRecord {
-
-    /**
-     * The start date of the ConnectionRecord.
-     */
-    private Date startDate;
-
-    /**
-     * The end date of the ConnectionRecord.
-     */
-    private Date endDate;
-
-    /**
-     * The name of the user that is associated with this ConnectionRecord.
-     */
-    private String username;
-
-    /**
-     * Initialize this MySQLConnectionRecord with the start/end dates,
-     * and the name of the user it represents.
-     *
-     * @param startDate The start date of the connection history.
-     * @param endDate The end date of the connection history.
-     * @param username The name of the user that used the connection.
-     */
-    public MySQLConnectionRecord(Date startDate, Date endDate,
-            String username) {
-        if (startDate != null) this.startDate = new Date(startDate.getTime());
-        if (endDate != null) this.endDate = new Date(endDate.getTime());
-        this.username = username;
-    }
-
-    @Override
-    public Date getStartDate() {
-        if (startDate == null) return null;
-        return new Date(startDate.getTime());
-    }
-
-    @Override
-    public Date getEndDate() {
-        if (endDate == null) return null;
-        return new Date(endDate.getTime());
-    }
-
-    @Override
-    public String getUsername() {
-        return username;
-    }
-
-    @Override
-    public boolean isActive() {
-        // If the end date hasn't been stored yet, the connection is still open.
-        return endDate == null;
-    }
-
-}
diff --git a/extensions/guacamole-auth-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/MySQLConstants.java b/extensions/guacamole-auth-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/MySQLConstants.java
deleted file mode 100644
index 144936a..0000000
--- a/extensions/guacamole-auth-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/MySQLConstants.java
+++ /dev/null
@@ -1,279 +0,0 @@
-
-package net.sourceforge.guacamole.net.auth.mysql;
-
-import org.glyptodon.guacamole.net.auth.ConnectionGroup;
-import org.glyptodon.guacamole.net.auth.permission.ObjectPermission;
-import org.glyptodon.guacamole.net.auth.permission.SystemPermission;
-
-/* ***** BEGIN LICENSE BLOCK *****
- * Version: MPL 1.1/GPL 2.0/LGPL 2.1
- *
- * The contents of this file are subject to the Mozilla Public License Version
- * 1.1 (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.mozilla.org/MPL/
- *
- * Software distributed under the License is distributed on an "AS IS" basis,
- * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- * for the specific language governing rights and limitations under the
- * License.
- *
- * The Original Code is guacamole-auth-mysql.
- *
- * The Initial Developer of the Original Code is
- * James Muehlner.
- * Portions created by the Initial Developer are Copyright (C) 2010
- * the Initial Developer. All Rights Reserved.
- *
- * Contributor(s):
- *
- * Alternatively, the contents of this file may be used under the terms of
- * either the GNU General Public License Version 2 or later (the "GPL"), or
- * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
- * in which case the provisions of the GPL or the LGPL are applicable instead
- * of those above. If you wish to allow use of your version of this file only
- * under the terms of either the GPL or the LGPL, and not to allow others to
- * use your version of this file under the terms of the MPL, indicate your
- * decision by deleting the provisions above and replace them with the notice
- * and other provisions required by the GPL or the LGPL. If you do not delete
- * the provisions above, a recipient may use your version of this file under
- * the terms of any one of the MPL, the GPL or the LGPL.
- *
- * ***** END LICENSE BLOCK ***** */
-
-/**
- * A set of constants that are useful for the MySQL-based authentication provider.
- * @author James Muehlner
- */
-public final class MySQLConstants {
-
-    /**
-     * This class should not be instantiated.
-     */
-    private MySQLConstants() {}
-
-    /**
-     * The string stored in the database to represent READ access to a user.
-     */
-    public static final String USER_READ = "READ";
-
-    /**
-     * The string stored in the database to represent UPDATE access to a user.
-     */
-    public static final String USER_UPDATE = "UPDATE";
-
-    /**
-     * The string stored in the database to represent DELETE access to a user.
-     */
-    public static final String USER_DELETE = "DELETE";
-
-    /**
-     * The string stored in the database to represent ADMINISTER access to a
-     * user.
-     */
-    public static final String USER_ADMINISTER = "ADMINISTER";
-
-    /**
-     * The string stored in the database to represent READ access to a
-     * connection.
-     */
-    public static final String CONNECTION_READ = "READ";
-
-    /**
-     * The string stored in the database to represent UPDATE access to a
-     * connection.
-     */
-    public static final String CONNECTION_UPDATE = "UPDATE";
-
-    /**
-     * The string stored in the database to represent DELETE access to a
-     * connection.
-     */
-    public static final String CONNECTION_DELETE = "DELETE";
-
-    /**
-     * The string stored in the database to represent ADMINISTER access to a
-     * connection.
-     */
-    public static final String CONNECTION_ADMINISTER = "ADMINISTER";
-
-    /**
-     * The string stored in the database to represent READ access to a
-     * connection.
-     */
-    public static final String CONNECTION_GROUP_READ = "READ";
-
-    /**
-     * The string stored in the database to represent UPDATE access to a
-     * connection group.
-     */
-    public static final String CONNECTION_GROUP_UPDATE = "UPDATE";
-
-    /**
-     * The string stored in the database to represent DELETE access to a
-     * connection group.
-     */
-    public static final String CONNECTION_GROUP_DELETE = "DELETE";
-
-    /**
-     * The string stored in the database to represent ADMINISTER access to a
-     * connection group.
-     */
-    public static final String CONNECTION_GROUP_ADMINISTER = "ADMINISTER";
-
-    /**
-     * The string stored in the database to represent a BALANCING
-     * connection group.
-     */
-    public static final String CONNECTION_GROUP_BALANCING = "BALANCING";
-
-    /**
-     * The string stored in the database to represent an ORGANIZATIONAL
-     * connection group.
-     */
-    public static final String CONNECTION_GROUP_ORGANIZATIONAL = 
-            "ORGANIZATIONAL";
-    
-    /**
-     * The identifier used to mark the root connection group.
-     */
-    public static final String CONNECTION_GROUP_ROOT_IDENTIFIER = "ROOT";
-
-    /**
-     * The string stored in the database to represent permission to create
-     * users.
-     */
-    public static final String SYSTEM_USER_CREATE = "CREATE_USER";
-
-    /**
-     * The string stored in the database to represent permission to create
-     * connections.
-     */
-    public static final String SYSTEM_CONNECTION_CREATE = "CREATE_CONNECTION";
-
-    /**
-     * The string stored in the database to represent permission to create
-     * connection groups.
-     */
-    public static final String SYSTEM_CONNECTION_GROUP_CREATE = "CREATE_CONNECTION_GROUP";
-
-    /**
-     * The string stored in the database to represent permission to administer
-     * the system as a whole.
-     */
-    public static final String SYSTEM_ADMINISTER = "ADMINISTER";
-
-    /**
-     * Given the type of a permission affecting a user, returns the MySQL
-     * constant representing that permission type.
-     *
-     * @param type The type of permission to look up.
-     * @return The MySQL constant corresponding to the given permission type.
-     */
-    public static String getUserConstant(ObjectPermission.Type type) {
-
-        // Convert permission type to MySQL constant
-        switch (type) {
-            case READ:       return USER_READ;
-            case UPDATE:     return USER_UPDATE;
-            case ADMINISTER: return USER_ADMINISTER;
-            case DELETE:     return USER_DELETE;
-        }
-
-        // If we get here, permission support was not properly implemented
-        throw new UnsupportedOperationException(
-            "Unsupported permission type: " + type);
-
-    }
-
-    /**
-     * Given the type of a permission affecting a connection, returns the MySQL
-     * constant representing that permission type.
-     *
-     * @param type The type of permission to look up.
-     * @return The MySQL constant corresponding to the given permission type.
-     */
-    public static String getConnectionConstant(ObjectPermission.Type type) {
-
-        // Convert permission type to MySQL constant
-        switch (type) {
-            case READ:       return CONNECTION_READ;
-            case UPDATE:     return CONNECTION_UPDATE;
-            case ADMINISTER: return CONNECTION_ADMINISTER;
-            case DELETE:     return CONNECTION_DELETE;
-        }
-
-        // If we get here, permission support was not properly implemented
-        throw new UnsupportedOperationException(
-            "Unsupported permission type: " + type);
-
-    }
-
-    /**
-     * Given the type of a permission affecting a connection group, 
-     * returns the MySQL constant representing that permission type.
-     *
-     * @param type The type of permission to look up.
-     * @return The MySQL constant corresponding to the given permission type.
-     */
-    public static String getConnectionGroupConstant(ObjectPermission.Type type) {
-
-        // Convert permission type to MySQL constant
-        switch (type) {
-            case READ:       return CONNECTION_GROUP_READ;
-            case UPDATE:     return CONNECTION_GROUP_UPDATE;
-            case ADMINISTER: return CONNECTION_GROUP_ADMINISTER;
-            case DELETE:     return CONNECTION_GROUP_DELETE;
-        }
-
-        // If we get here, permission support was not properly implemented
-        throw new UnsupportedOperationException(
-            "Unsupported permission type: " + type);
-
-    }
-
-    /**
-     * Given the type of a connection group, returns the MySQL constant
-     * representing that type.
-     *
-     * @param type The connection group type to look up.
-     * @return The MySQL constant corresponding to the given type.
-     */
-    public static String getConnectionGroupTypeConstant(ConnectionGroup.Type type) {
-
-        // Convert permission type to MySQL constant
-        switch (type) {
-            case ORGANIZATIONAL: return CONNECTION_GROUP_ORGANIZATIONAL;
-            case BALANCING:      return CONNECTION_GROUP_BALANCING;
-        }
-
-        // If we get here, permission support was not properly implemented
-        throw new UnsupportedOperationException(
-            "Unsupported connection group type: " + type);
-
-    }
-
-    /**
-     * Given the type of a permission affecting the system, returns the MySQL
-     * constant representing that permission type.
-     *
-     * @param type The type of permission to look up.
-     * @return The MySQL constant corresponding to the given permission type.
-     */
-    public static String getSystemConstant(SystemPermission.Type type) {
-
-        // Convert permission type to MySQL constant
-        switch (type) {
-            case CREATE_USER:             return SYSTEM_USER_CREATE;
-            case CREATE_CONNECTION:       return SYSTEM_CONNECTION_CREATE;
-            case CREATE_CONNECTION_GROUP: return SYSTEM_CONNECTION_GROUP_CREATE;
-            case ADMINISTER:              return SYSTEM_ADMINISTER;
-        }
-
-        // If we get here, permission support was not properly implemented
-        throw new UnsupportedOperationException(
-            "Unsupported permission type: " + type);
-
-    }
-
-}
diff --git a/extensions/guacamole-auth-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/MySQLGuacamoleSocket.java b/extensions/guacamole-auth-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/MySQLGuacamoleSocket.java
deleted file mode 100644
index 340160f..0000000
--- a/extensions/guacamole-auth-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/MySQLGuacamoleSocket.java
+++ /dev/null
@@ -1,115 +0,0 @@
-
-package net.sourceforge.guacamole.net.auth.mysql;
-
-/* ***** BEGIN LICENSE BLOCK *****
- * Version: MPL 1.1/GPL 2.0/LGPL 2.1
- *
- * The contents of this file are subject to the Mozilla Public License Version
- * 1.1 (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.mozilla.org/MPL/
- *
- * Software distributed under the License is distributed on an "AS IS" basis,
- * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- * for the specific language governing rights and limitations under the
- * License.
- *
- * The Original Code is guacamole-auth-mysql.
- *
- * The Initial Developer of the Original Code is
- * James Muehlner.
- * Portions created by the Initial Developer are Copyright (C) 2010
- * the Initial Developer. All Rights Reserved.
- *
- * Contributor(s):
- *
- * Alternatively, the contents of this file may be used under the terms of
- * either the GNU General Public License Version 2 or later (the "GPL"), or
- * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
- * in which case the provisions of the GPL or the LGPL are applicable instead
- * of those above. If you wish to allow use of your version of this file only
- * under the terms of either the GPL or the LGPL, and not to allow others to
- * use your version of this file under the terms of the MPL, indicate your
- * decision by deleting the provisions above and replace them with the notice
- * and other provisions required by the GPL or the LGPL. If you do not delete
- * the provisions above, a recipient may use your version of this file under
- * the terms of any one of the MPL, the GPL or the LGPL.
- *
- * ***** END LICENSE BLOCK ***** */
-
-import com.google.inject.Inject;
-import org.glyptodon.guacamole.GuacamoleException;
-import org.glyptodon.guacamole.io.GuacamoleReader;
-import org.glyptodon.guacamole.io.GuacamoleWriter;
-import org.glyptodon.guacamole.net.GuacamoleSocket;
-
-/**
- * A MySQL specific wrapper around a ConfiguredGuacamoleSocket.
- * @author James Muehlner
- */
-public class MySQLGuacamoleSocket implements GuacamoleSocket {
-
-    /**
-     * Injected ActiveConnectionMap which will contain all active connections.
-     */
-    @Inject
-    private ActiveConnectionMap activeConnectionSet;
-
-    /**
-     * The wrapped socket.
-     */
-    private GuacamoleSocket socket;
-
-    /**
-     * The ID of the history record associated with this instance of the
-     * connection.
-     */
-    private int historyID;
-
-    /**
-     * The ID of the balancing connection group that is being connected to; 
-     * null if not used.
-     */
-    private Integer connectionGroupID;
-
-    /**
-     * Initialize this MySQLGuacamoleSocket with the provided GuacamoleSocket.
-     *
-     * @param socket The ConfiguredGuacamoleSocket to wrap.
-     * @param historyID The ID of the history record associated with this
-     *                  instance of the connection.
-     * @param connectionGroupID The ID of the balancing connection group that is
-     *                          being connected to; null if not used.
-     */
-    public void init(GuacamoleSocket socket, int connectionID, int userID, 
-            int historyID, Integer connectionGroupID) {
-        this.socket = socket;
-        this.historyID = historyID;
-        this.connectionGroupID = connectionGroupID;
-    }
-
-    @Override
-    public GuacamoleReader getReader() {
-        return socket.getReader();
-    }
-
-    @Override
-    public GuacamoleWriter getWriter() {
-        return socket.getWriter();
-    }
-
-    @Override
-    public void close() throws GuacamoleException {
-
-        // Close socket
-        socket.close();
-
-        // Mark this connection as inactive
-        activeConnectionSet.closeConnection(historyID, connectionGroupID);
-    }
-
-    @Override
-    public boolean isOpen() {
-        return socket.isOpen();
-    }
-}
diff --git a/extensions/guacamole-auth-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/MySQLUser.java b/extensions/guacamole-auth-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/MySQLUser.java
deleted file mode 100644
index 37f8f07..0000000
--- a/extensions/guacamole-auth-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/MySQLUser.java
+++ /dev/null
@@ -1,193 +0,0 @@
-/* ***** BEGIN LICENSE BLOCK *****
- * Version: MPL 1.1/GPL 2.0/LGPL 2.1
- *
- * The contents of this file are subject to the Mozilla Public License Version
- * 1.1 (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.mozilla.org/MPL/
- *
- * Software distributed under the License is distributed on an "AS IS" basis,
- * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- * for the specific language governing rights and limitations under the
- * License.
- *
- * The Original Code is guacamole-auth-mysql.
- *
- * The Initial Developer of the Original Code is
- * James Muehlner.
- * Portions created by the Initial Developer are Copyright (C) 2010
- * the Initial Developer. All Rights Reserved.
- *
- * Contributor(s):
- *
- * Alternatively, the contents of this file may be used under the terms of
- * either the GNU General Public License Version 2 or later (the "GPL"), or
- * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
- * in which case the provisions of the GPL or the LGPL are applicable instead
- * of those above. If you wish to allow use of your version of this file only
- * under the terms of either the GPL or the LGPL, and not to allow others to
- * use your version of this file under the terms of the MPL, indicate your
- * decision by deleting the provisions above and replace them with the notice
- * and other provisions required by the GPL or the LGPL. If you do not delete
- * the provisions above, a recipient may use your version of this file under
- * the terms of any one of the MPL, the GPL or the LGPL.
- *
- * ***** END LICENSE BLOCK ***** */
-package net.sourceforge.guacamole.net.auth.mysql;
-
-import java.util.Collections;
-import java.util.HashSet;
-import java.util.Set;
-import org.glyptodon.guacamole.GuacamoleException;
-import org.glyptodon.guacamole.net.auth.AbstractUser;
-import org.glyptodon.guacamole.net.auth.User;
-import org.glyptodon.guacamole.net.auth.permission.Permission;
-
-/**
- * A MySQL based implementation of the User object.
- * @author James Muehlner
- */
-public class MySQLUser extends AbstractUser {
-
-    /**
-     * The ID of this user in the database, if any.
-     */
-    private Integer userID;
-
-    /**
-     * The set of current permissions a user has.
-     */
-    private Set<Permission> permissions = new HashSet<Permission>();
-
-    /**
-     * Any newly added permissions that have yet to be committed.
-     */
-    private Set<Permission> newPermissions = new HashSet<Permission>();
-
-    /**
-     * Any newly deleted permissions that have yet to be deleted.
-     */
-    private Set<Permission> removedPermissions = new HashSet<Permission>();
-
-    /**
-     * Creates a new, empty MySQLUser.
-     */
-    public MySQLUser() {
-    }
-
-    /**
-     * Initializes a new MySQLUser having the given username.
-     *
-     * @param name The name to assign to this MySQLUser.
-     */
-    public void init(String name) {
-        init(null, name, null, Collections.EMPTY_SET);
-    }
-
-    /**
-     * Initializes a new MySQLUser, copying all data from the given user
-     * object.
-     *
-     * @param user The user object to copy.
-     * @throws GuacamoleException If an error occurs while reading the user
-     *                            data in the given object.
-     */
-    public void init(User user) throws GuacamoleException {
-        init(null, user.getUsername(), user.getPassword(), user.getPermissions());
-    }
-
-    /**
-     * Initializes a new MySQLUser initialized from the given data from the
-     * database.
-     *
-     * @param userID The ID of the user in the database, if any.
-     * @param username The username of this user.
-     * @param password The password to assign to this user.
-     * @param permissions The permissions to assign to this user, as
-     *                    retrieved from the database.
-     */
-    public void init(Integer userID, String username, String password,
-            Set<Permission> permissions) {
-        this.userID = userID;
-        setUsername(username);
-        setPassword(password);
-        this.permissions.addAll(permissions);
-    }
-
-    /**
-     * Get the current set of permissions this user has.
-     * @return the current set of permissions.
-     */
-    public Set<Permission> getCurrentPermissions() {
-        return permissions;
-    }
-
-    /**
-     * Get any new permissions that have yet to be inserted.
-     * @return the new set of permissions.
-     */
-    public Set<Permission> getNewPermissions() {
-        return newPermissions;
-    }
-
-    /**
-     * Get any permissions that have not yet been deleted.
-     * @return the permissions that need to be deleted.
-     */
-    public Set<Permission> getRemovedPermissions() {
-        return removedPermissions;
-    }
-
-    /**
-     * Reset the new and removed permission sets after they are
-     * no longer needed.
-     */
-    public void resetPermissions() {
-        newPermissions.clear();
-        removedPermissions.clear();
-    }
-
-    /**
-     * Returns the ID of this user in the database, if it exists.
-     *
-     * @return The ID of this user in the database, or null if this user
-     *         was not retrieved from the database.
-     */
-    public Integer getUserID() {
-        return userID;
-    }
-
-    /**
-     * Sets the ID of this user to the given value.
-     *
-     * @param userID The ID to assign to this user.
-     */
-    public void setUserID(Integer userID) {
-        this.userID = userID;
-    }
-
-    @Override
-    public Set<Permission> getPermissions() throws GuacamoleException {
-        return Collections.unmodifiableSet(permissions);
-    }
-
-    @Override
-    public boolean hasPermission(Permission permission) throws GuacamoleException {
-        return permissions.contains(permission);
-    }
-
-    @Override
-    public void addPermission(Permission permission) throws GuacamoleException {
-        permissions.add(permission);
-        newPermissions.add(permission);
-        removedPermissions.remove(permission);
-    }
-
-    @Override
-    public void removePermission(Permission permission) throws GuacamoleException {
-        permissions.remove(permission);
-        newPermissions.remove(permission);
-        removedPermissions.add(permission);
-    }
-
-}
diff --git a/extensions/guacamole-auth-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/MySQLUserContext.java b/extensions/guacamole-auth-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/MySQLUserContext.java
deleted file mode 100644
index 8323148..0000000
--- a/extensions/guacamole-auth-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/MySQLUserContext.java
+++ /dev/null
@@ -1,108 +0,0 @@
-
-package net.sourceforge.guacamole.net.auth.mysql;
-
-/* ***** BEGIN LICENSE BLOCK *****
- * Version: MPL 1.1/GPL 2.0/LGPL 2.1
- *
- * The contents of this file are subject to the Mozilla Public License Version
- * 1.1 (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.mozilla.org/MPL/
- *
- * Software distributed under the License is distributed on an "AS IS" basis,
- * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- * for the specific language governing rights and limitations under the
- * License.
- *
- * The Original Code is guacamole-auth-mysql.
- *
- * The Initial Developer of the Original Code is
- * James Muehlner.
- * Portions created by the Initial Developer are Copyright (C) 2010
- * the Initial Developer. All Rights Reserved.
- *
- * Contributor(s):
- *
- * Alternatively, the contents of this file may be used under the terms of
- * either the GNU General Public License Version 2 or later (the "GPL"), or
- * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
- * in which case the provisions of the GPL or the LGPL are applicable instead
- * of those above. If you wish to allow use of your version of this file only
- * under the terms of either the GPL or the LGPL, and not to allow others to
- * use your version of this file under the terms of the MPL, indicate your
- * decision by deleting the provisions above and replace them with the notice
- * and other provisions required by the GPL or the LGPL. If you do not delete
- * the provisions above, a recipient may use your version of this file under
- * the terms of any one of the MPL, the GPL or the LGPL.
- *
- * ***** END LICENSE BLOCK ***** */
-
-import com.google.inject.Inject;
-import org.glyptodon.guacamole.GuacamoleException;
-import org.glyptodon.guacamole.net.auth.ConnectionGroup;
-import org.glyptodon.guacamole.net.auth.Directory;
-import org.glyptodon.guacamole.net.auth.User;
-import org.glyptodon.guacamole.net.auth.UserContext;
-import net.sourceforge.guacamole.net.auth.mysql.service.UserService;
-
-/**
- * The MySQL representation of a UserContext.
- * @author James Muehlner
- */
-public class MySQLUserContext implements UserContext {
-
-    /**
-     * The ID of the user owning this context. The permissions of this user
-     * dictate the access given via the user and connection directories.
-     */
-    private int user_id;
-
-    /**
-     * User directory restricted by the permissions of the user associated
-     * with this context.
-     */
-    @Inject
-    private UserDirectory userDirectory;
-    
-    /**
-     * The root connection group.
-     */
-    @Inject
-    private MySQLConnectionGroup rootConnectionGroup;
-
-    /**
-     * Service for accessing users.
-     */
-    @Inject
-    private UserService userService;
-
-    /**
-     * Initializes the user and directories associated with this context.
-     *
-     * @param user_id The ID of the user owning this context.
-     */
-    public void init(int user_id) {
-        this.user_id = user_id;
-        userDirectory.init(user_id);
-        rootConnectionGroup.init(null, null, 
-                MySQLConstants.CONNECTION_GROUP_ROOT_IDENTIFIER, 
-                MySQLConstants.CONNECTION_GROUP_ROOT_IDENTIFIER, 
-                ConnectionGroup.Type.ORGANIZATIONAL, user_id);
-    }
-
-    @Override
-    public User self() {
-        return userService.retrieveUser(user_id);
-    }
-
-    @Override
-    public Directory<String, User> getUserDirectory() throws GuacamoleException {
-        return userDirectory;
-    }
-
-    @Override
-    public ConnectionGroup getRootConnectionGroup() throws GuacamoleException {
-        return rootConnectionGroup;
-    }
-
-}
diff --git a/extensions/guacamole-auth-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/UserDirectory.java b/extensions/guacamole-auth-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/UserDirectory.java
deleted file mode 100644
index a37742e..0000000
--- a/extensions/guacamole-auth-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/UserDirectory.java
+++ /dev/null
@@ -1,721 +0,0 @@
-
-package net.sourceforge.guacamole.net.auth.mysql;
-
-/* ***** BEGIN LICENSE BLOCK *****
- * Version: MPL 1.1/GPL 2.0/LGPL 2.1
- *
- * The contents of this file are subject to the Mozilla Public License Version
- * 1.1 (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.mozilla.org/MPL/
- *
- * Software distributed under the License is distributed on an "AS IS" basis,
- * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- * for the specific language governing rights and limitations under the
- * License.
- *
- * The Original Code is guacamole-auth-mysql.
- *
- * The Initial Developer of the Original Code is
- * James Muehlner.
- * Portions created by the Initial Developer are Copyright (C) 2010
- * the Initial Developer. All Rights Reserved.
- *
- * Contributor(s):
- *
- * Alternatively, the contents of this file may be used under the terms of
- * either the GNU General Public License Version 2 or later (the "GPL"), or
- * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
- * in which case the provisions of the GPL or the LGPL are applicable instead
- * of those above. If you wish to allow use of your version of this file only
- * under the terms of either the GPL or the LGPL, and not to allow others to
- * use your version of this file under the terms of the MPL, indicate your
- * decision by deleting the provisions above and replace them with the notice
- * and other provisions required by the GPL or the LGPL. If you do not delete
- * the provisions above, a recipient may use your version of this file under
- * the terms of any one of the MPL, the GPL or the LGPL.
- *
- * ***** END LICENSE BLOCK ***** */
-
-import com.google.common.base.Preconditions;
-import com.google.common.collect.Sets;
-import com.google.inject.Inject;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-import org.glyptodon.guacamole.GuacamoleClientException;
-import org.glyptodon.guacamole.GuacamoleException;
-import org.glyptodon.guacamole.GuacamoleSecurityException;
-import org.glyptodon.guacamole.net.auth.Directory;
-import org.glyptodon.guacamole.net.auth.User;
-import net.sourceforge.guacamole.net.auth.mysql.dao.ConnectionGroupPermissionMapper;
-import net.sourceforge.guacamole.net.auth.mysql.dao.ConnectionPermissionMapper;
-import net.sourceforge.guacamole.net.auth.mysql.dao.SystemPermissionMapper;
-import net.sourceforge.guacamole.net.auth.mysql.dao.UserPermissionMapper;
-import net.sourceforge.guacamole.net.auth.mysql.model.ConnectionGroupPermissionExample;
-import net.sourceforge.guacamole.net.auth.mysql.model.ConnectionGroupPermissionKey;
-import net.sourceforge.guacamole.net.auth.mysql.model.ConnectionPermissionExample;
-import net.sourceforge.guacamole.net.auth.mysql.model.ConnectionPermissionKey;
-import net.sourceforge.guacamole.net.auth.mysql.model.SystemPermissionExample;
-import net.sourceforge.guacamole.net.auth.mysql.model.SystemPermissionKey;
-import net.sourceforge.guacamole.net.auth.mysql.model.UserPermissionExample;
-import net.sourceforge.guacamole.net.auth.mysql.model.UserPermissionKey;
-import net.sourceforge.guacamole.net.auth.mysql.service.ConnectionGroupService;
-import net.sourceforge.guacamole.net.auth.mysql.service.ConnectionService;
-import net.sourceforge.guacamole.net.auth.mysql.service.PermissionCheckService;
-import net.sourceforge.guacamole.net.auth.mysql.service.UserService;
-import org.glyptodon.guacamole.net.auth.permission.ConnectionGroupPermission;
-import org.glyptodon.guacamole.net.auth.permission.ConnectionPermission;
-import org.glyptodon.guacamole.net.auth.permission.Permission;
-import org.glyptodon.guacamole.net.auth.permission.SystemPermission;
-import org.glyptodon.guacamole.net.auth.permission.UserPermission;
-import org.mybatis.guice.transactional.Transactional;
-
-/**
- * A MySQL based implementation of the User Directory.
- * @author James Muehlner
- */
-public class UserDirectory implements Directory<String, User> {
-
-    /**
-     * The ID of the user who this user directory belongs to.
-     * Access is based on his/her permission settings.
-     */
-    private int user_id;
-
-    /**
-     * Service for accessing users.
-     */
-    @Inject
-    private UserService userService;
-
-    /**
-     * Service for accessing connections.
-     */
-    @Inject
-    private ConnectionService connectionService;
-
-    /**
-     * Service for accessing connection groups.
-     */
-    @Inject
-    private ConnectionGroupService connectionGroupService;
-
-    /**
-     * DAO for accessing user permissions, which will be injected.
-     */
-    @Inject
-    private UserPermissionMapper userPermissionDAO;
-
-    /**
-     * DAO for accessing connection permissions, which will be injected.
-     */
-    @Inject
-    private ConnectionPermissionMapper connectionPermissionDAO;
-
-    /**
-     * DAO for accessing connection group permissions, which will be injected.
-     */
-    @Inject
-    private ConnectionGroupPermissionMapper connectionGroupPermissionDAO;
-
-    /**
-     * DAO for accessing system permissions, which will be injected.
-     */
-    @Inject
-    private SystemPermissionMapper systemPermissionDAO;
-
-    /**
-     * Service for checking various permissions, which will be injected.
-     */
-    @Inject
-    private PermissionCheckService permissionCheckService;
-
-    /**
-     * Set the user for this directory.
-     *
-     * @param user_id The ID of the user whose permissions define the visibility
-     *                of other users in this directory.
-     */
-    public void init(int user_id) {
-        this.user_id = user_id;
-    }
-
-    @Transactional
-    @Override
-    public org.glyptodon.guacamole.net.auth.User get(String identifier)
-            throws GuacamoleException {
-
-        // Get user
-        MySQLUser user = userService.retrieveUser(identifier);
-
-        // Verify access is granted
-        permissionCheckService.verifyUserAccess(this.user_id,
-                user.getUserID(),
-                MySQLConstants.USER_READ);
-
-        // Return user
-        return userService.retrieveUser(identifier);
-
-    }
-
-    @Transactional
-    @Override
-    public Set<String> getIdentifiers() throws GuacamoleException {
-        return permissionCheckService.retrieveUsernames(user_id,
-                MySQLConstants.USER_READ);
-    }
-
-    @Override
-    @Transactional
-    public void add(org.glyptodon.guacamole.net.auth.User object)
-            throws GuacamoleException {
-
-        String username = object.getUsername().trim();
-        if(username.isEmpty())
-            throw new GuacamoleClientException("The username cannot be blank.");
-
-        // Verify current user has permission to create users
-        permissionCheckService.verifySystemAccess(this.user_id,
-                MySQLConstants.SYSTEM_USER_CREATE);
-        Preconditions.checkNotNull(object);
-
-        // Verify that no user already exists with this username.
-        MySQLUser previousUser = userService.retrieveUser(username);
-        if(previousUser != null)
-            throw new GuacamoleClientException("That username is already in use.");
-
-        // Create new user
-        MySQLUser user = userService.createUser(username, object.getPassword());
-
-        // Create permissions of new user in database
-        createPermissions(user.getUserID(), object.getPermissions());
-
-        // Give the current user full access to the newly created user.
-        UserPermissionKey newUserPermission = new UserPermissionKey();
-        newUserPermission.setUser_id(this.user_id);
-        newUserPermission.setAffected_user_id(user.getUserID());
-
-        // READ permission on new user
-        newUserPermission.setPermission(MySQLConstants.USER_READ);
-        userPermissionDAO.insert(newUserPermission);
-
-        // UPDATE permission on new user
-        newUserPermission.setPermission(MySQLConstants.USER_UPDATE);
-        userPermissionDAO.insert(newUserPermission);
-
-        // DELETE permission on new user
-        newUserPermission.setPermission(MySQLConstants.USER_DELETE);
-        userPermissionDAO.insert(newUserPermission);
-
-        // ADMINISTER permission on new user
-        newUserPermission.setPermission(MySQLConstants.USER_ADMINISTER);
-        userPermissionDAO.insert(newUserPermission);
-
-    }
-
-    /**
-     * Add the given permissions to the given user.
-     *
-     * @param user_id The ID of the user whose permissions should be updated.
-     * @param permissions The permissions to add.
-     * @throws GuacamoleException If an error occurs while updating the
-     *                            permissions of the given user.
-     */
-    private void createPermissions(int user_id, Set<Permission> permissions) throws GuacamoleException {
-
-        // Partition given permissions by permission type
-        List<UserPermission> newUserPermissions = new ArrayList<UserPermission>();
-        List<ConnectionPermission> newConnectionPermissions = new ArrayList<ConnectionPermission>();
-        List<ConnectionGroupPermission> newConnectionGroupPermissions = new ArrayList<ConnectionGroupPermission>();
-        List<SystemPermission> newSystemPermissions = new ArrayList<SystemPermission>();
-
-        for (Permission permission : permissions) {
-
-            if (permission instanceof UserPermission)
-                newUserPermissions.add((UserPermission) permission);
-
-            else if (permission instanceof ConnectionPermission)
-                newConnectionPermissions.add((ConnectionPermission) permission);
-
-            else if (permission instanceof ConnectionGroupPermission)
-                newConnectionGroupPermissions.add((ConnectionGroupPermission) permission);
-
-            else if (permission instanceof SystemPermission)
-                newSystemPermissions.add((SystemPermission) permission);
-        }
-
-        // Create the new permissions
-        createUserPermissions(user_id, newUserPermissions);
-        createConnectionPermissions(user_id, newConnectionPermissions);
-        createConnectionGroupPermissions(user_id, newConnectionGroupPermissions);
-        createSystemPermissions(user_id, newSystemPermissions);
-
-    }
-
-
-
-    /**
-     * Remove the given permissions from the given user.
-     *
-     * @param user_id The ID of the user whose permissions should be updated.
-     * @param permissions The permissions to remove.
-     * @throws GuacamoleException If an error occurs while updating the
-     *                            permissions of the given user.
-     */
-    private void removePermissions(int user_id, Set<Permission> permissions)
-            throws GuacamoleException {
-
-        // Partition given permissions by permission type
-        List<UserPermission> removedUserPermissions = new ArrayList<UserPermission>();
-        List<ConnectionPermission> removedConnectionPermissions = new ArrayList<ConnectionPermission>();
-        List<ConnectionGroupPermission> removedConnectionGroupPermissions = new ArrayList<ConnectionGroupPermission>();
-        List<SystemPermission> removedSystemPermissions = new ArrayList<SystemPermission>();
-
-        for (Permission permission : permissions) {
-
-            if (permission instanceof UserPermission)
-                removedUserPermissions.add((UserPermission) permission);
-
-            else if (permission instanceof ConnectionPermission)
-                removedConnectionPermissions.add((ConnectionPermission) permission);
-
-            else if (permission instanceof ConnectionGroupPermission)
-                removedConnectionGroupPermissions.add((ConnectionGroupPermission) permission);
-
-            else if (permission instanceof SystemPermission)
-                removedSystemPermissions.add((SystemPermission) permission);
-        }
-
-        // Delete the removed permissions.
-        deleteUserPermissions(user_id, removedUserPermissions);
-        deleteConnectionPermissions(user_id, removedConnectionPermissions);
-        deleteConnectionGroupPermissions(user_id, removedConnectionGroupPermissions);
-        deleteSystemPermissions(user_id, removedSystemPermissions);
-
-    }
-
-    /**
-     * Create the given user permissions for the given user.
-     *
-     * @param user_id The ID of the user to change the permissions of.
-     * @param permissions The new permissions the given user should have when
-     *                    this operation completes.
-     * @throws GuacamoleException If permission to alter the access permissions
-     *                            of affected objects is denied.
-     */
-    private void createUserPermissions(int user_id,
-            Collection<UserPermission> permissions)
-            throws GuacamoleException {
-
-        // If no permissions given, stop now
-        if(permissions.isEmpty())
-            return;
-
-        // Get list of administerable user IDs
-        List<Integer> administerableUserIDs =
-            permissionCheckService.retrieveUserIDs(this.user_id,
-                MySQLConstants.USER_ADMINISTER);
-
-        // Get set of usernames corresponding to administerable users
-        Map<String, Integer> administerableUsers =
-                userService.translateUsernames(administerableUserIDs);
-
-        // Insert all given permissions
-        for (UserPermission permission : permissions) {
-
-            // Get original ID
-            Integer affected_id =
-                    administerableUsers.get(permission.getObjectIdentifier());
-
-            // Verify that the user actually has permission to administrate
-            // every one of these users
-            if (affected_id == null)
-                throw new GuacamoleSecurityException(
-                      "User #" + this.user_id
-                    + " does not have permission to administrate user "
-                    + permission.getObjectIdentifier());
-
-            // Create new permission
-            UserPermissionKey newPermission = new UserPermissionKey();
-            newPermission.setUser_id(user_id);
-            newPermission.setPermission(MySQLConstants.getUserConstant(permission.getType()));
-            newPermission.setAffected_user_id(affected_id);
-            userPermissionDAO.insert(newPermission);
-
-         }
-
-    }
-
-    /**
-     * Delete permissions having to do with users for a given user.
-     *
-     * @param user_id The ID of the user to change the permissions of.
-     * @param permissions The permissions the given user should no longer have
-     *                    when this operation completes.
-     * @throws GuacamoleException If permission to alter the access permissions
-     *                            of affected objects is denied.
-     */
-    private void deleteUserPermissions(int user_id,
-            Collection<UserPermission> permissions)
-            throws GuacamoleException {
-
-        // If no permissions given, stop now
-        if(permissions.isEmpty())
-            return;
-
-        // Get list of administerable user IDs
-        List<Integer> administerableUserIDs =
-            permissionCheckService.retrieveUserIDs(this.user_id,
-                MySQLConstants.USER_ADMINISTER);
-
-        // Get set of usernames corresponding to administerable users
-        Map<String, Integer> administerableUsers =
-                userService.translateUsernames(administerableUserIDs);
-
-        // Delete requested permissions
-        for (UserPermission permission : permissions) {
-
-            // Get original ID
-            Integer affected_id =
-                    administerableUsers.get(permission.getObjectIdentifier());
-
-            // Verify that the user actually has permission to administrate
-            // every one of these users
-            if (affected_id == null)
-                throw new GuacamoleSecurityException(
-                      "User #" + this.user_id
-                    + " does not have permission to administrate user "
-                    + permission.getObjectIdentifier());
-
-            // Delete requested permission
-            UserPermissionExample userPermissionExample = new UserPermissionExample();
-            userPermissionExample.createCriteria()
-                .andUser_idEqualTo(user_id)
-                .andPermissionEqualTo(MySQLConstants.getUserConstant(permission.getType()))
-                .andAffected_user_idEqualTo(affected_id);
-            userPermissionDAO.deleteByExample(userPermissionExample);
-
-        }
-
-    }
-
-    /**
-     * Create any new permissions having to do with connections for a given
-     * user.
-     *
-     * @param user_id The ID of the user to assign or remove permissions from.
-     * @param permissions The new permissions the user should have after this
-     *                    operation completes.
-     * @throws GuacamoleException If permission to alter the access permissions
-     *                            of affected objects is deniedD
-     */
-    private void createConnectionPermissions(int user_id,
-            Collection<ConnectionPermission> permissions)
-            throws GuacamoleException {
-
-        // If no permissions given, stop now
-        if(permissions.isEmpty())
-            return;
-
-        // Get list of administerable connection IDs
-        Set<Integer> administerableConnectionIDs = Sets.<Integer>newHashSet(
-            permissionCheckService.retrieveConnectionIDs(this.user_id,
-                MySQLConstants.CONNECTION_ADMINISTER));
-
-        // Insert all given permissions
-        for (ConnectionPermission permission : permissions) {
-
-            // Get original ID
-            Integer connection_id = Integer.valueOf(permission.getObjectIdentifier());
-
-            // Throw exception if permission to administer this connection
-            // is not granted
-            if (!administerableConnectionIDs.contains(connection_id))
-                throw new GuacamoleSecurityException(
-                      "User #" + this.user_id
-                    + " does not have permission to administrate connection "
-                    + permission.getObjectIdentifier());
-
-
-            // Create new permission
-            ConnectionPermissionKey newPermission = new ConnectionPermissionKey();
-            newPermission.setUser_id(user_id);
-            newPermission.setPermission(MySQLConstants.getConnectionConstant(permission.getType()));
-            newPermission.setConnection_id(connection_id);
-            connectionPermissionDAO.insert(newPermission);
-
-        }
-    }
-
-    /**
-     * Create any new permissions having to do with connection groups 
-     * for a given user.
-     *
-     * @param user_id The ID of the user to assign or remove permissions from.
-     * @param permissions The new permissions the user should have after this
-     *                    operation completes.
-     * @throws GuacamoleException If permission to alter the access permissions
-     *                            of affected objects is deniedD
-     */
-    private void createConnectionGroupPermissions(int user_id,
-            Collection<ConnectionGroupPermission> permissions)
-            throws GuacamoleException {
-
-        // If no permissions given, stop now
-        if(permissions.isEmpty())
-            return;
-
-        // Get list of administerable connection group IDs
-        Set<Integer> administerableConnectionGroupIDs = Sets.<Integer>newHashSet(
-            permissionCheckService.retrieveConnectionGroupIDs(this.user_id,
-                MySQLConstants.CONNECTION_GROUP_ADMINISTER));
-
-        // Insert all given permissions
-        for (ConnectionGroupPermission permission : permissions) {
-
-            // Get original ID
-            Integer connection_group_id = Integer.valueOf(permission.getObjectIdentifier());
-
-            // Throw exception if permission to administer this connection group
-            // is not granted
-            if (!administerableConnectionGroupIDs.contains(connection_group_id))
-                throw new GuacamoleSecurityException(
-                      "User #" + this.user_id
-                    + " does not have permission to administrate connection group"
-                    + permission.getObjectIdentifier());
-
-
-            // Create new permission
-            ConnectionGroupPermissionKey newPermission = new ConnectionGroupPermissionKey();
-            newPermission.setUser_id(user_id);
-            newPermission.setPermission(MySQLConstants.getConnectionGroupConstant(permission.getType()));
-            newPermission.setConnection_group_id(connection_group_id);
-            connectionGroupPermissionDAO.insert(newPermission);
-
-        }
-    }
-
-    /**
-     * Delete permissions having to do with connections for a given user.
-     *
-     * @param user_id The ID of the user to change the permissions of.
-     * @param permissions The permissions the given user should no longer have
-     *                    when this operation completes.
-     * @throws GuacamoleException If permission to alter the access permissions
-     *                            of affected objects is denied.
-     */
-    private void deleteConnectionPermissions(int user_id,
-            Collection<ConnectionPermission> permissions)
-            throws GuacamoleException {
-
-        // If no permissions given, stop now
-        if(permissions.isEmpty())
-            return;
-
-        // Get list of administerable connection IDs
-        Set<Integer> administerableConnectionIDs = Sets.<Integer>newHashSet(
-            permissionCheckService.retrieveConnectionIDs(this.user_id,
-                MySQLConstants.CONNECTION_ADMINISTER));
-
-        // Delete requested permissions
-        for (ConnectionPermission permission : permissions) {
-
-            // Get original ID
-            Integer connection_id = Integer.valueOf(permission.getObjectIdentifier());
-
-            // Verify that the user actually has permission to administrate
-            // every one of these connections
-            if (!administerableConnectionIDs.contains(connection_id))
-                throw new GuacamoleSecurityException(
-                      "User #" + this.user_id
-                    + " does not have permission to administrate connection "
-                    + permission.getObjectIdentifier());
-
-            ConnectionPermissionExample connectionPermissionExample = new ConnectionPermissionExample();
-            connectionPermissionExample.createCriteria()
-                .andUser_idEqualTo(user_id)
-                .andPermissionEqualTo(MySQLConstants.getConnectionConstant(permission.getType()))
-                .andConnection_idEqualTo(connection_id);
-            connectionPermissionDAO.deleteByExample(connectionPermissionExample);
-
-        }
-
-    }
-
-    /**
-     * Delete permissions having to do with connection groups for a given user.
-     *
-     * @param user_id The ID of the user to change the permissions of.
-     * @param permissions The permissions the given user should no longer have
-     *                    when this operation completes.
-     * @throws GuacamoleException If permission to alter the access permissions
-     *                            of affected objects is denied.
-     */
-    private void deleteConnectionGroupPermissions(int user_id,
-            Collection<ConnectionGroupPermission> permissions)
-            throws GuacamoleException {
-
-        // If no permissions given, stop now
-        if(permissions.isEmpty())
-            return;
-
-        // Get list of administerable connection group IDs
-        Set<Integer> administerableConnectionGroupIDs = Sets.<Integer>newHashSet(
-            permissionCheckService.retrieveConnectionGroupIDs(this.user_id,
-                MySQLConstants.CONNECTION_GROUP_ADMINISTER));
-
-        // Delete requested permissions
-        for (ConnectionGroupPermission permission : permissions) {
-
-            // Get original ID
-            Integer connection_group_id = Integer.valueOf(permission.getObjectIdentifier());
-
-            // Verify that the user actually has permission to administrate
-            // every one of these connection groups
-            if (!administerableConnectionGroupIDs.contains(connection_group_id))
-                throw new GuacamoleSecurityException(
-                      "User #" + this.user_id
-                    + " does not have permission to administrate connection group"
-                    + permission.getObjectIdentifier());
-
-            ConnectionGroupPermissionExample connectionGroupPermissionExample = new ConnectionGroupPermissionExample();
-            connectionGroupPermissionExample.createCriteria()
-                .andUser_idEqualTo(user_id)
-                .andPermissionEqualTo(MySQLConstants.getConnectionGroupConstant(permission.getType()))
-                .andConnection_group_idEqualTo(connection_group_id);
-            connectionGroupPermissionDAO.deleteByExample(connectionGroupPermissionExample);
-
-        }
-
-    }
-
-    /**
-     * Create any new system permissions for a given user. All permissions in
-     * the given list will be inserted.
-     *
-     * @param user_id The ID of the user whose permissions should be updated.
-     * @param permissions The new system permissions that the given user should
-     *                    have when this operation completes.
-     * @throws GuacamoleException If permission to administer system permissions
-     *                            is denied.
-     */
-    private void createSystemPermissions(int user_id,
-            Collection<SystemPermission> permissions) throws GuacamoleException {
-
-        // If no permissions given, stop now
-        if(permissions.isEmpty())
-            return;
-
-        // Only a system administrator can add system permissions.
-        permissionCheckService.verifySystemAccess(
-                this.user_id, SystemPermission.Type.ADMINISTER.name());
-
-        // Insert all requested permissions
-        for (SystemPermission permission : permissions) {
-
-            // Insert permission
-            SystemPermissionKey newSystemPermission = new SystemPermissionKey();
-            newSystemPermission.setUser_id(user_id);
-            newSystemPermission.setPermission(MySQLConstants.getSystemConstant(permission.getType()));
-            systemPermissionDAO.insert(newSystemPermission);
-
-        }
-
-    }
-
-    /**
-     * Delete system permissions for a given user. All permissions in
-     * the given list will be removed from the user.
-     *
-     * @param user_id The ID of the user whose permissions should be updated.
-     * @param permissions The permissions the given user should no longer have
-     *                    when this operation completes.
-     * @throws GuacamoleException If the permissions specified could not be
-     *                            removed due to system restrictions.
-     */
-    private void deleteSystemPermissions(int user_id,
-            Collection<SystemPermission> permissions)
-            throws GuacamoleException {
-
-        // If no permissions given, stop now
-        if (permissions.isEmpty())
-            return;
-
-        // Prevent self-de-adminifying
-        if (user_id == this.user_id)
-            throw new GuacamoleClientException("Removing your own administrative permissions is not allowed.");
-
-        // Build list of requested system permissions
-        List<String> systemPermissionTypes = new ArrayList<String>();
-        for (SystemPermission permission : permissions)
-            systemPermissionTypes.add(MySQLConstants.getSystemConstant(permission.getType()));
-
-        // Delete the requested system permissions for this user
-        SystemPermissionExample systemPermissionExample = new SystemPermissionExample();
-        systemPermissionExample.createCriteria().andUser_idEqualTo(user_id)
-                .andPermissionIn(systemPermissionTypes);
-        systemPermissionDAO.deleteByExample(systemPermissionExample);
-
-    }
-
-    @Override
-    @Transactional
-    public void update(org.glyptodon.guacamole.net.auth.User object)
-            throws GuacamoleException {
-
-        // If user not actually from this auth provider, we can't handle updated
-        // permissions.
-        if (!(object instanceof MySQLUser))
-            throw new GuacamoleException("User not from database.");
-
-        MySQLUser mySQLUser = (MySQLUser) object;
-
-        // Validate permission to update this user is granted
-        permissionCheckService.verifyUserAccess(this.user_id,
-                mySQLUser.getUserID(),
-                MySQLConstants.USER_UPDATE);
-
-        // Update the user in the database
-        userService.updateUser(mySQLUser);
-
-        // Update permissions in database
-        createPermissions(mySQLUser.getUserID(), mySQLUser.getNewPermissions());
-        removePermissions(mySQLUser.getUserID(), mySQLUser.getRemovedPermissions());
-
-        // The appropriate permissions have been inserted and deleted, so
-        // reset the new and removed permission sets.
-        mySQLUser.resetPermissions();
-
-    }
-
-    @Override
-    @Transactional
-    public void remove(String identifier) throws GuacamoleException {
-
-        // Get user pending deletion
-        MySQLUser user = userService.retrieveUser(identifier);
-
-        // Prevent self-deletion
-        if (user.getUserID() == this.user_id)
-            throw new GuacamoleClientException("Deleting your own user is not allowed.");
-
-        // Validate current user has permission to remove the specified user
-        permissionCheckService.verifyUserAccess(this.user_id,
-                user.getUserID(),
-                MySQLConstants.USER_DELETE);
-
-        // Delete specified user
-        userService.deleteUser(user.getUserID());
-
-    }
-
-    @Override
-    public void move(String identifier, Directory<String, User> groupIdentifier) 
-            throws GuacamoleException {
-        throw new GuacamoleSecurityException("Permission denied.");
-    }
-
-}
diff --git a/extensions/guacamole-auth-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/package-info.java b/extensions/guacamole-auth-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/package-info.java
deleted file mode 100644
index 81f802b..0000000
--- a/extensions/guacamole-auth-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/package-info.java
+++ /dev/null
@@ -1,7 +0,0 @@
-
-/**
- * Base classes which support the MySQL authentication provider, including
- * the authentication provider itself.
- */
-package net.sourceforge.guacamole.net.auth.mysql;
-
diff --git a/extensions/guacamole-auth-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/properties/MySQLGuacamoleProperties.java b/extensions/guacamole-auth-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/properties/MySQLGuacamoleProperties.java
deleted file mode 100644
index 6f457a1..0000000
--- a/extensions/guacamole-auth-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/properties/MySQLGuacamoleProperties.java
+++ /dev/null
@@ -1,124 +0,0 @@
-/* ***** BEGIN LICENSE BLOCK *****
- * Version: MPL 1.1/GPL 2.0/LGPL 2.1
- *
- * The contents of this file are subject to the Mozilla Public License Version
- * 1.1 (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.mozilla.org/MPL/
- *
- * Software distributed under the License is distributed on an "AS IS" basis,
- * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- * for the specific language governing rights and limitations under the
- * License.
- *
- * The Original Code is guacamole-auth-mysql.
- *
- * The Initial Developer of the Original Code is
- * James Muehlner.
- * Portions created by the Initial Developer are Copyright (C) 2010
- * the Initial Developer. All Rights Reserved.
- *
- * Contributor(s):
- *
- * Alternatively, the contents of this file may be used under the terms of
- * either the GNU General Public License Version 2 or later (the "GPL"), or
- * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
- * in which case the provisions of the GPL or the LGPL are applicable instead
- * of those above. If you wish to allow use of your version of this file only
- * under the terms of either the GPL or the LGPL, and not to allow others to
- * use your version of this file under the terms of the MPL, indicate your
- * decision by deleting the provisions above and replace them with the notice
- * and other provisions required by the GPL or the LGPL. If you do not delete
- * the provisions above, a recipient may use your version of this file under
- * the terms of any one of the MPL, the GPL or the LGPL.
- *
- * ***** END LICENSE BLOCK ***** */
-package net.sourceforge.guacamole.net.auth.mysql.properties;
-
-import org.glyptodon.guacamole.properties.BooleanGuacamoleProperty;
-import org.glyptodon.guacamole.properties.IntegerGuacamoleProperty;
-import org.glyptodon.guacamole.properties.StringGuacamoleProperty;
-
-/**
- * Properties used by the MySQL Authentication plugin.
- * @author James Muehlner
- */
-public class MySQLGuacamoleProperties {
-
-    /**
-     * This class should not be instantiated.
-     */
-    private MySQLGuacamoleProperties() {}
-
-    /**
-     * The URL of the MySQL server hosting the guacamole authentication tables.
-     */
-    public static final StringGuacamoleProperty MYSQL_HOSTNAME = new StringGuacamoleProperty() {
-
-        @Override
-        public String getName() { return "mysql-hostname"; }
-
-    };
-
-    /**
-     * The port of the MySQL server hosting the guacamole authentication tables.
-     */
-    public static final IntegerGuacamoleProperty MYSQL_PORT = new IntegerGuacamoleProperty() {
-
-        @Override
-        public String getName() { return "mysql-port"; }
-
-    };
-
-    /**
-     * The name of the MySQL database containing the guacamole authentication tables.
-     */
-    public static final StringGuacamoleProperty MYSQL_DATABASE = new StringGuacamoleProperty() {
-
-        @Override
-        public String getName() { return "mysql-database"; }
-
-    };
-
-    /**
-     * The username used to authenticate to the MySQL database containing the guacamole authentication tables.
-     */
-    public static final StringGuacamoleProperty MYSQL_USERNAME = new StringGuacamoleProperty() {
-
-        @Override
-        public String getName() { return "mysql-username"; }
-
-    };
-
-    /**
-     * The password used to authenticate to the MySQL database containing the guacamole authentication tables.
-     */
-    public static final StringGuacamoleProperty MYSQL_PASSWORD = new StringGuacamoleProperty() {
-
-        @Override
-        public String getName() { return "mysql-password"; }
-
-    };
-
-    /**
-     * Whether or not multiple users accessing the same connection at the same time should be disallowed.
-     */
-    public static final BooleanGuacamoleProperty MYSQL_DISALLOW_SIMULTANEOUS_CONNECTIONS = new BooleanGuacamoleProperty() {
-
-        @Override
-        public String getName() { return "mysql-disallow-simultaneous-connections"; }
-
-    };
-
-    /**
-     * Whether or not the same user accessing the same connection or connection group at the same time should be disallowed.
-     */
-    public static final BooleanGuacamoleProperty MYSQL_DISALLOW_DUPLICATE_CONNECTIONS = new BooleanGuacamoleProperty() {
-
-        @Override
-        public String getName() { return "mysql-disallow-duplicate-connections"; }
-
-    };
-    
-    
-}
diff --git a/extensions/guacamole-auth-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/properties/package-info.java b/extensions/guacamole-auth-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/properties/package-info.java
deleted file mode 100644
index d327a33..0000000
--- a/extensions/guacamole-auth-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/properties/package-info.java
+++ /dev/null
@@ -1,7 +0,0 @@
-
-/**
- * Properties which control the configuration of the MySQL authentication
- * provider.
- */
-package net.sourceforge.guacamole.net.auth.mysql.properties;
-
diff --git a/extensions/guacamole-auth-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/service/ConnectionGroupService.java b/extensions/guacamole-auth-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/service/ConnectionGroupService.java
deleted file mode 100644
index f4954cb..0000000
--- a/extensions/guacamole-auth-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/service/ConnectionGroupService.java
+++ /dev/null
@@ -1,411 +0,0 @@
-
-package net.sourceforge.guacamole.net.auth.mysql.service;
-
-/* ***** BEGIN LICENSE BLOCK *****
- * Version: MPL 1.1/GPL 2.0/LGPL 2.1
- *
- * The contents of this file are subject to the Mozilla Public License Version
- * 1.1 (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.mozilla.org/MPL/
- *
- * Software distributed under the License is distributed on an "AS IS" basis,
- * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- * for the specific language governing rights and limitations under the
- * License.
- *
- * The Original Code is guacamole-auth-mysql.
- *
- * The Initial Developer of the Original Code is
- * James Muehlner.
- * Portions created by the Initial Developer are Copyright (C) 2010
- * the Initial Developer. All Rights Reserved.
- *
- * Contributor(s):
- *
- * Alternatively, the contents of this file may be used under the terms of
- * either the GNU General Public License Version 2 or later (the "GPL"), or
- * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
- * in which case the provisions of the GPL or the LGPL are applicable instead
- * of those above. If you wish to allow use of your version of this file only
- * under the terms of either the GPL or the LGPL, and not to allow others to
- * use your version of this file under the terms of the MPL, indicate your
- * decision by deleting the provisions above and replace them with the notice
- * and other provisions required by the GPL or the LGPL. If you do not delete
- * the provisions above, a recipient may use your version of this file under
- * the terms of any one of the MPL, the GPL or the LGPL.
- *
- * ***** END LICENSE BLOCK ***** */
-
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.util.ArrayList;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Set;
-import org.glyptodon.guacamole.GuacamoleClientException;
-import org.glyptodon.guacamole.GuacamoleException;
-import org.glyptodon.guacamole.net.GuacamoleSocket;
-import net.sourceforge.guacamole.net.auth.mysql.ActiveConnectionMap;
-import net.sourceforge.guacamole.net.auth.mysql.MySQLConnection;
-import net.sourceforge.guacamole.net.auth.mysql.MySQLConnectionGroup;
-import net.sourceforge.guacamole.net.auth.mysql.MySQLConstants;
-import net.sourceforge.guacamole.net.auth.mysql.dao.ConnectionGroupMapper;
-import net.sourceforge.guacamole.net.auth.mysql.model.ConnectionGroup;
-import net.sourceforge.guacamole.net.auth.mysql.model.ConnectionGroupExample;
-import net.sourceforge.guacamole.net.auth.mysql.model.ConnectionGroupExample.Criteria;
-import net.sourceforge.guacamole.net.auth.mysql.properties.MySQLGuacamoleProperties;
-import org.glyptodon.guacamole.properties.GuacamoleProperties;
-import org.glyptodon.guacamole.protocol.GuacamoleClientInformation;
-
-/**
- * Service which provides convenience methods for creating, retrieving, and
- * manipulating connection groups.
- *
- * @author James Muehlner
- */
-public class ConnectionGroupService {
-    
-    /**
-     * Service for managing connections.
-     */
-    @Inject
-    private ConnectionService connectionService;
-    
-    /**
-     * DAO for accessing connection groups.
-     */
-    @Inject
-    private ConnectionGroupMapper connectionGroupDAO;
-
-    /**
-     * Provider which creates MySQLConnectionGroups.
-     */
-    @Inject
-    private Provider<MySQLConnectionGroup> mysqlConnectionGroupProvider;
-    
-    /**
-     * The map of all active connections.
-     */
-    @Inject
-    private ActiveConnectionMap activeConnectionMap;
-    
-
-    /**
-     * Retrieves the connection group having the given 
-     * name from the database.
-     *
-     * @param name The name of the connection to return.
-     * @param parentID The ID of the parent connection group.
-     * @param userID The ID of the user who queried this connection group.
-     * @return The connection having the given name, or null if no such
-     *         connection group could be found.
-     */
-    public MySQLConnectionGroup retrieveConnectionGroup(String name, Integer parentID,
-            int userID) {
-
-        // Create criteria
-        ConnectionGroupExample example = new ConnectionGroupExample();
-        Criteria criteria = example.createCriteria().andConnection_group_nameEqualTo(name);
-        if(parentID != null)
-            criteria.andParent_idEqualTo(parentID);
-        else
-            criteria.andParent_idIsNull();
-        
-        // Query connection group by name and parentID
-        List<ConnectionGroup> connectionGroups =
-                connectionGroupDAO.selectByExample(example);
-
-        // If no connection group found, return null
-        if(connectionGroups.isEmpty())
-            return null;
-
-        // Otherwise, return found connection
-        return toMySQLConnectionGroup(connectionGroups.get(0), userID);
-
-    }
-
-    /**
-     * Retrieves the connection group having the given unique identifier 
-     * from the database.
-     *
-     * @param uniqueIdentifier The unique identifier of the connection group to retrieve.
-     * @param userID The ID of the user who queried this connection group.
-     * @return The connection group having the given unique identifier, 
-     *         or null if no such connection group was found.
-     */
-    public MySQLConnectionGroup retrieveConnectionGroup(String uniqueIdentifier, 
-            int userID) throws GuacamoleException {
-
-        // The unique identifier for a MySQLConnectionGroup is the database ID
-        Integer connectionGroupID = null;
-        
-        // Try to parse the connectionID if it's not the root group
-        if(!MySQLConstants.CONNECTION_GROUP_ROOT_IDENTIFIER.equals(uniqueIdentifier)) {
-            try {
-                connectionGroupID = Integer.parseInt(uniqueIdentifier);
-            } catch(NumberFormatException e) {
-                throw new GuacamoleException("Invalid connection group ID.");
-            }
-        }
-        
-        return retrieveConnectionGroup(connectionGroupID, userID);
-    }
-    
-    /**
-     * Retrieves the connection group having the given ID from the database.
-     *
-     * @param id The ID of the connection group to retrieve.
-     * @param userID The ID of the user who queried this connection.
-     * @return The connection group having the given ID, or null if no such
-     *         connection was found.
-     */
-    public MySQLConnectionGroup retrieveConnectionGroup(Integer id, int userID) {
-
-        // This is the root connection group, so just create it here
-        if(id == null) {
-            MySQLConnectionGroup connectionGroup = mysqlConnectionGroupProvider.get();
-            connectionGroup.init(null, null, 
-                    MySQLConstants.CONNECTION_GROUP_ROOT_IDENTIFIER, 
-                    MySQLConstants.CONNECTION_GROUP_ROOT_IDENTIFIER, 
-                    org.glyptodon.guacamole.net.auth.ConnectionGroup.Type.ORGANIZATIONAL, 
-                    userID);
-            
-            return connectionGroup;
-        }
-        
-        // Query connection by ID
-        ConnectionGroup connectionGroup = connectionGroupDAO.selectByPrimaryKey(id);
-
-        // If no connection found, return null
-        if(connectionGroup == null)
-            return null;
-
-        // Otherwise, return found connection
-        return toMySQLConnectionGroup(connectionGroup, userID);
-    }
-
-
-    /**
-     * Connect to the connection within the given group with the lowest number
-     * of currently active users.
-     *
-     * @param connection The group to load balance across.
-     * @param info The information to use when performing the connection
-     *             handshake.
-     * @param userID The ID of the user who is connecting to the socket.
-     * @return The connected socket.
-     * @throws GuacamoleException If an error occurs while connecting the
-     *                            socket.
-     */
-    public GuacamoleSocket connect(MySQLConnectionGroup group, 
-            GuacamoleClientInformation info, int userID) throws GuacamoleException {
-        
-        // Get all connections in the group.
-        List<Integer> connectionIDs = connectionService.getAllConnectionIDs
-                (group.getConnectionGroupID());
-        
-        // Get the least used connection.
-        Integer leastUsedConnectionID = 
-                activeConnectionMap.getLeastUsedConnection(connectionIDs);
-        
-        if(leastUsedConnectionID == null)
-            throw new GuacamoleException("No connections found in group.");
-        
-        if(GuacamoleProperties.getProperty(
-                MySQLGuacamoleProperties.MYSQL_DISALLOW_SIMULTANEOUS_CONNECTIONS, false)
-                && activeConnectionMap.isActive(leastUsedConnectionID))
-            throw new GuacamoleClientException
-                    ("Cannot connect. All connections are in use.");
-        
-        if(GuacamoleProperties.getProperty(
-                MySQLGuacamoleProperties.MYSQL_DISALLOW_DUPLICATE_CONNECTIONS, true)
-                && activeConnectionMap.isConnectionGroupUserActive(group.getConnectionGroupID(), userID))
-            throw new GuacamoleClientException
-                    ("Cannot connect. Connection group already in use by this user.");
-        
-        // Get the connection 
-        MySQLConnection connection = connectionService
-                .retrieveConnection(leastUsedConnectionID, userID);
-        
-        // Connect to the connection
-        return connectionService.connect(connection, info, userID, group.getConnectionGroupID());
-    }
-    
-    /**
-     * Returns a list of the IDs of all connection groups with a given parent ID.
-     * @param parentID The ID of the parent for all the queried connection groups.
-     * @return a list of the IDs of all connection groups with a given parent ID.
-     */
-    public List<Integer> getAllConnectionGroupIDs(Integer parentID) {
-        
-        // Create criteria
-        ConnectionGroupExample example = new ConnectionGroupExample();
-        Criteria criteria = example.createCriteria();
-        
-        if(parentID != null)
-            criteria.andParent_idEqualTo(parentID);
-        else
-            criteria.andParent_idIsNull();
-        
-        // Query the connections
-        List<ConnectionGroup> connectionGroups = connectionGroupDAO.selectByExample(example);
-        
-        // List of IDs of connections with the given parent
-        List<Integer> connectionGroupIDs = new ArrayList<Integer>();
-        
-        for(ConnectionGroup connectionGroup : connectionGroups) {
-            connectionGroupIDs.add(connectionGroup.getConnection_group_id());
-        }
-        
-        return connectionGroupIDs;
-    }
-
-    /**
-     * Get the identifiers of all the connection groups defined in the system 
-     * with a certain parentID.
-     *
-     * @return A Set of identifiers of all the connection groups defined 
-     * in the system with the given parentID.
-     */
-    public Set<String> getAllConnectionGroupIdentifiers(Integer parentID) {
-
-        // Set of all present connection identifiers
-        Set<String> identifiers = new HashSet<String>();
-        
-        // Set up Criteria
-        ConnectionGroupExample example = new ConnectionGroupExample();
-        Criteria criteria = example.createCriteria();
-        if(parentID != null)
-            criteria.andParent_idEqualTo(parentID);
-        else
-            criteria.andParent_idIsNull();
-
-        // Query connection identifiers
-        List<ConnectionGroup> connectionGroups =
-                connectionGroupDAO.selectByExample(example);
-        for (ConnectionGroup connectionGroup : connectionGroups)
-            identifiers.add(String.valueOf(connectionGroup.getConnection_group_id()));
-
-        return identifiers;
-
-    }
-
-    /**
-     * Convert the given database-retrieved Connection into a MySQLConnection.
-     * The parameters of the given connection will be read and added to the
-     * MySQLConnection in the process.
-     *
-     * @param connection The connection to convert.
-     * @param userID The user who queried this connection.
-     * @return A new MySQLConnection containing all data associated with the
-     *         specified connection.
-     */
-    private MySQLConnectionGroup toMySQLConnectionGroup(ConnectionGroup connectionGroup, int userID) {
-
-        // Create new MySQLConnection from retrieved data
-        MySQLConnectionGroup mySQLConnectionGroup = mysqlConnectionGroupProvider.get();
-        
-        String mySqlType = connectionGroup.getType();
-        org.glyptodon.guacamole.net.auth.ConnectionGroup.Type authType;
-        
-        if(mySqlType.equals(MySQLConstants.CONNECTION_GROUP_ORGANIZATIONAL))
-            authType = org.glyptodon.guacamole.net.auth.ConnectionGroup.Type.ORGANIZATIONAL;
-        else
-            authType = org.glyptodon.guacamole.net.auth.ConnectionGroup.Type.BALANCING;
-        
-        mySQLConnectionGroup.init(
-            connectionGroup.getConnection_group_id(),
-            connectionGroup.getParent_id(),
-            connectionGroup.getConnection_group_name(),
-            Integer.toString(connectionGroup.getConnection_group_id()),
-            authType,
-            userID
-        );
-
-        return mySQLConnectionGroup;
-
-    }
-
-    /**
-     * Get the connection group IDs of all the connection groups defined in the system.
-     *
-     * @return A list of connection group IDs of all the connection groups defined in the system.
-     */
-    public List<Integer> getAllConnectionGroupIDs() {
-
-        // Set of all present connection group IDs
-        List<Integer> connectionGroupIDs = new ArrayList<Integer>();
-
-        // Query all connection IDs
-        List<ConnectionGroup> connections =
-                connectionGroupDAO.selectByExample(new ConnectionGroupExample());
-        for (ConnectionGroup connection : connections)
-            connectionGroupIDs.add(connection.getConnection_group_id());
-
-        return connectionGroupIDs;
-
-    }
-
-    /**
-     * Creates a new connection group having the given name and protocol.
-     *
-     * @param name The name to assign to the new connection group.
-     * @param userID The ID of the user who created this connection group.
-     * @param Type The type of the new connection group.
-     * @return A new MySQLConnectionGroup containing the data of the newly created
-     *         connection group.
-     */
-    public MySQLConnectionGroup createConnectionGroup(String name, int userID, 
-            Integer parentID, String type) {
-
-        // Initialize database connection
-        ConnectionGroup connectionGroup = new ConnectionGroup();
-        connectionGroup.setConnection_group_name(name);
-        connectionGroup.setParent_id(parentID);
-        connectionGroup.setType(type);
-
-        // Create connection
-        connectionGroupDAO.insert(connectionGroup);
-        return toMySQLConnectionGroup(connectionGroup, userID);
-
-    }
-
-    /**
-     * Updates the connection group in the database corresponding to the given
-     * MySQLConnectionGroup.
-     *
-     * @param mySQLConnectionGroup The MySQLConnectionGroup to update (save) 
-     *                             to the database. 
-     *                             This connection must already exist.
-     */
-    public void updateConnectionGroup(MySQLConnectionGroup mySQLConnectionGroup) {
-
-        // Populate connection
-        ConnectionGroup connectionGroup = new ConnectionGroup();
-        connectionGroup.setConnection_group_id(mySQLConnectionGroup.getConnectionGroupID());
-        connectionGroup.setParent_id(mySQLConnectionGroup.getParentID());
-        connectionGroup.setConnection_group_name(mySQLConnectionGroup.getName());
-        
-        switch(mySQLConnectionGroup.getType()) {
-            case BALANCING :
-                connectionGroup.setType(MySQLConstants.CONNECTION_GROUP_BALANCING);
-                break;
-            case ORGANIZATIONAL:
-                connectionGroup.setType(MySQLConstants.CONNECTION_GROUP_ORGANIZATIONAL);
-                break;
-        }
-
-        // Update the connection group in the database
-        connectionGroupDAO.updateByPrimaryKey(connectionGroup);
-
-    }
-
-    /**
-     * Deletes the connection group having the given ID from the database.
-     * @param id The ID of the connection group to delete.
-     */
-    public void deleteConnectionGroup(int id) {
-        connectionGroupDAO.deleteByPrimaryKey(id);
-    }
-}
diff --git a/extensions/guacamole-auth-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/service/ConnectionService.java b/extensions/guacamole-auth-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/service/ConnectionService.java
deleted file mode 100644
index e1c0b5c..0000000
--- a/extensions/guacamole-auth-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/service/ConnectionService.java
+++ /dev/null
@@ -1,490 +0,0 @@
-
-package net.sourceforge.guacamole.net.auth.mysql.service;
-
-/* ***** BEGIN LICENSE BLOCK *****
- * Version: MPL 1.1/GPL 2.0/LGPL 2.1
- *
- * The contents of this file are subject to the Mozilla Public License Version
- * 1.1 (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.mozilla.org/MPL/
- *
- * Software distributed under the License is distributed on an "AS IS" basis,
- * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- * for the specific language governing rights and limitations under the
- * License.
- *
- * The Original Code is guacamole-auth-mysql.
- *
- * The Initial Developer of the Original Code is
- * James Muehlner.
- * Portions created by the Initial Developer are Copyright (C) 2010
- * the Initial Developer. All Rights Reserved.
- *
- * Contributor(s):
- *
- * Alternatively, the contents of this file may be used under the terms of
- * either the GNU General Public License Version 2 or later (the "GPL"), or
- * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
- * in which case the provisions of the GPL or the LGPL are applicable instead
- * of those above. If you wish to allow use of your version of this file only
- * under the terms of either the GPL or the LGPL, and not to allow others to
- * use your version of this file under the terms of the MPL, indicate your
- * decision by deleting the provisions above and replace them with the notice
- * and other provisions required by the GPL or the LGPL. If you do not delete
- * the provisions above, a recipient may use your version of this file under
- * the terms of any one of the MPL, the GPL or the LGPL.
- *
- * ***** END LICENSE BLOCK ***** */
-
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.util.ArrayList;
-import java.util.Date;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-import org.glyptodon.guacamole.GuacamoleClientException;
-import org.glyptodon.guacamole.GuacamoleException;
-import org.glyptodon.guacamole.net.GuacamoleSocket;
-import org.glyptodon.guacamole.net.InetGuacamoleSocket;
-import org.glyptodon.guacamole.net.SSLGuacamoleSocket;
-import net.sourceforge.guacamole.net.auth.mysql.ActiveConnectionMap;
-import net.sourceforge.guacamole.net.auth.mysql.MySQLConnection;
-import net.sourceforge.guacamole.net.auth.mysql.MySQLConnectionRecord;
-import net.sourceforge.guacamole.net.auth.mysql.MySQLGuacamoleSocket;
-import net.sourceforge.guacamole.net.auth.mysql.dao.ConnectionHistoryMapper;
-import net.sourceforge.guacamole.net.auth.mysql.dao.ConnectionMapper;
-import net.sourceforge.guacamole.net.auth.mysql.dao.ConnectionParameterMapper;
-import net.sourceforge.guacamole.net.auth.mysql.model.Connection;
-import net.sourceforge.guacamole.net.auth.mysql.model.ConnectionExample;
-import net.sourceforge.guacamole.net.auth.mysql.model.ConnectionExample.Criteria;
-import net.sourceforge.guacamole.net.auth.mysql.model.ConnectionHistory;
-import net.sourceforge.guacamole.net.auth.mysql.model.ConnectionHistoryExample;
-import net.sourceforge.guacamole.net.auth.mysql.model.ConnectionParameter;
-import net.sourceforge.guacamole.net.auth.mysql.model.ConnectionParameterExample;
-import net.sourceforge.guacamole.net.auth.mysql.properties.MySQLGuacamoleProperties;
-import org.glyptodon.guacamole.properties.GuacamoleProperties;
-import org.glyptodon.guacamole.protocol.ConfiguredGuacamoleSocket;
-import org.glyptodon.guacamole.protocol.GuacamoleClientInformation;
-import org.glyptodon.guacamole.protocol.GuacamoleConfiguration;
-import org.apache.ibatis.session.RowBounds;
-
-/**
- * Service which provides convenience methods for creating, retrieving, and
- * manipulating connections.
- *
- * @author Michael Jumper, James Muehlner
- */
-public class ConnectionService {
-
-    /**
-     * DAO for accessing connections.
-     */
-    @Inject
-    private ConnectionMapper connectionDAO;
-
-    /**
-     * DAO for accessing connection parameters.
-     */
-    @Inject
-    private ConnectionParameterMapper connectionParameterDAO;
-
-    /**
-     * DAO for accessing connection history.
-     */
-    @Inject
-    private ConnectionHistoryMapper connectionHistoryDAO;
-
-    /**
-     * Provider which creates MySQLConnections.
-     */
-    @Inject
-    private Provider<MySQLConnection> mySQLConnectionProvider;
-
-    /**
-     * Provider which creates MySQLGuacamoleSockets.
-     */
-    @Inject
-    private Provider<MySQLGuacamoleSocket> mySQLGuacamoleSocketProvider;
-
-    /**
-     * Map of all currently active connections.
-     */
-    @Inject
-    private ActiveConnectionMap activeConnectionMap;
-
-    /**
-     * Service managing users.
-     */
-    @Inject
-    private UserService userService;
-
-    /**
-     * Retrieves the connection having the given name from the database.
-     *
-     * @param name The name of the connection to return.
-     * @param parentID The ID of the parent connection group.
-     * @param userID The ID of the user who queried this connection.
-     * @return The connection having the given name, or null if no such
-     *         connection could be found.
-     */
-    public MySQLConnection retrieveConnection(String name, Integer parentID,
-            int userID) {
-
-        // Create criteria
-        ConnectionExample example = new ConnectionExample();
-        Criteria criteria = example.createCriteria().andConnection_nameEqualTo(name);
-        if(parentID != null)
-            criteria.andParent_idEqualTo(parentID);
-        else
-            criteria.andParent_idIsNull();
-        
-        // Query connection by name and parentID
-        List<Connection> connections =
-                connectionDAO.selectByExample(example);
-
-        // If no connection found, return null
-        if(connections.isEmpty())
-            return null;
-
-        // Otherwise, return found connection
-        return toMySQLConnection(connections.get(0), userID);
-
-    }
-
-    /**
-     * Retrieves the connection having the given unique identifier 
-     * from the database.
-     *
-     * @param uniqueIdentifier The unique identifier of the connection to retrieve.
-     * @param userID The ID of the user who queried this connection.
-     * @return The connection having the given unique identifier, 
-     *         or null if no such connection was found.
-     */
-    public MySQLConnection retrieveConnection(String uniqueIdentifier, int userID) {
-
-        // The unique identifier for a MySQLConnection is the database ID
-        int connectionID;
-        try {
-            connectionID = Integer.parseInt(uniqueIdentifier);
-        } catch(NumberFormatException e) {
-            // Invalid number means it can't be a DB record; not found
-            return null;
-        }
-        
-        return retrieveConnection(connectionID, userID);
-    }
-
-    /**
-     * Retrieves the connection having the given ID from the database.
-     *
-     * @param id The ID of the connection to retrieve.
-     * @param userID The ID of the user who queried this connection.
-     * @return The connection having the given ID, or null if no such
-     *         connection was found.
-     */
-    public MySQLConnection retrieveConnection(int id, int userID) {
-
-        // Query connection by ID
-        Connection connection = connectionDAO.selectByPrimaryKey(id);
-
-        // If no connection found, return null
-        if(connection == null)
-            return null;
-
-        // Otherwise, return found connection
-        return toMySQLConnection(connection, userID);
-    }
-    
-    /**
-     * Returns a list of the IDs of all connections with a given parent ID.
-     * @param parentID The ID of the parent for all the queried connections.
-     * @return a list of the IDs of all connections with a given parent ID.
-     */
-    public List<Integer> getAllConnectionIDs(Integer parentID) {
-        
-        // Create criteria
-        ConnectionExample example = new ConnectionExample();
-        Criteria criteria = example.createCriteria();
-        
-        if(parentID != null)
-            criteria.andParent_idEqualTo(parentID);
-        else
-            criteria.andParent_idIsNull();
-        
-        // Query the connections
-        List<Connection> connections = connectionDAO.selectByExample(example);
-        
-        // List of IDs of connections with the given parent
-        List<Integer> connectionIDs = new ArrayList<Integer>();
-        
-        for(Connection connection : connections) {
-            connectionIDs.add(connection.getConnection_id());
-        }
-        
-        return connectionIDs;
-    }
-
-    /**
-     * Convert the given database-retrieved Connection into a MySQLConnection.
-     * The parameters of the given connection will be read and added to the
-     * MySQLConnection in the process.
-     *
-     * @param connection The connection to convert.
-     * @param userID The user who queried this connection.
-     * @return A new MySQLConnection containing all data associated with the
-     *         specified connection.
-     */
-    private MySQLConnection toMySQLConnection(Connection connection, int userID) {
-
-        // Build configuration
-        GuacamoleConfiguration config = new GuacamoleConfiguration();
-
-        // Query parameters for configuration
-        ConnectionParameterExample connectionParameterExample = new ConnectionParameterExample();
-        connectionParameterExample.createCriteria().andConnection_idEqualTo(connection.getConnection_id());
-        List<ConnectionParameter> connectionParameters =
-                connectionParameterDAO.selectByExample(connectionParameterExample);
-
-        // Set protocol
-        config.setProtocol(connection.getProtocol());
-
-        // Set all values for all parameters
-        for (ConnectionParameter parameter : connectionParameters)
-            config.setParameter(parameter.getParameter_name(),
-                    parameter.getParameter_value());
-
-        // Create new MySQLConnection from retrieved data
-        MySQLConnection mySQLConnection = mySQLConnectionProvider.get();
-        mySQLConnection.init(
-            connection.getConnection_id(),
-            connection.getParent_id(),
-            connection.getConnection_name(),
-            Integer.toString(connection.getConnection_id()),
-            config,
-            retrieveHistory(connection.getConnection_id()),
-            userID
-        );
-
-        return mySQLConnection;
-
-    }
-
-    /**
-     * Retrieves the history of the connection having the given ID.
-     *
-     * @param connectionID The ID of the connection to retrieve the history of.
-     * @return A list of MySQLConnectionRecord documenting the history of this
-     *         connection.
-     */
-    public List<MySQLConnectionRecord> retrieveHistory(int connectionID) {
-
-        // Retrieve history records relating to given connection ID
-        ConnectionHistoryExample example = new ConnectionHistoryExample();
-        example.createCriteria().andConnection_idEqualTo(connectionID);
-
-        // We want to return the newest records first
-        example.setOrderByClause("start_date DESC");
-
-        // Set the maximum number of history records returned to 100
-        RowBounds rowBounds = new RowBounds(0, 100);
-
-        // Retrieve all connection history entries
-        List<ConnectionHistory> connectionHistories =
-                connectionHistoryDAO.selectByExampleWithRowbounds(example, rowBounds);
-
-        // Convert history entries to connection records
-        List<MySQLConnectionRecord> connectionRecords = new ArrayList<MySQLConnectionRecord>();
-        Set<Integer> userIDSet = new HashSet<Integer>();
-        for(ConnectionHistory history : connectionHistories) {
-            userIDSet.add(history.getUser_id());
-        }
-
-        // Get all the usernames for the users who are in the history
-        Map<Integer, String> usernameMap = userService.retrieveUsernames(userIDSet);
-
-        // Create the new ConnectionRecords
-        for(ConnectionHistory history : connectionHistories) {
-            Date startDate = history.getStart_date();
-            Date endDate = history.getEnd_date();
-            String username = usernameMap.get(history.getUser_id());
-            MySQLConnectionRecord connectionRecord = new MySQLConnectionRecord(startDate, endDate, username);
-            connectionRecords.add(connectionRecord);
-        }
-
-        return connectionRecords;
-    }
-    
-    
-
-    /**
-     * Create a MySQLGuacamoleSocket using the provided connection.
-     *
-     * @param connection The connection to use when connecting the socket.
-     * @param info The information to use when performing the connection
-     *             handshake.
-     * @param userID The ID of the user who is connecting to the socket.
-     * @param connectionGroupID The ID of the balancing connection group that is
-     *                          being connected to; null if not used.
-     * @return The connected socket.
-     * @throws GuacamoleException If an error occurs while connecting the
-     *                            socket.
-     */
-    public MySQLGuacamoleSocket connect(MySQLConnection connection,
-            GuacamoleClientInformation info, int userID, Integer connectionGroupID)
-        throws GuacamoleException {
-
-        // If the given connection is active, and multiple simultaneous
-        // connections are not allowed, disallow connection
-        if(GuacamoleProperties.getProperty(
-                MySQLGuacamoleProperties.MYSQL_DISALLOW_SIMULTANEOUS_CONNECTIONS, false)
-                && activeConnectionMap.isActive(connection.getConnectionID()))
-            throw new GuacamoleClientException("Cannot connect. This connection is in use.");
-        
-        if(GuacamoleProperties.getProperty(
-                MySQLGuacamoleProperties.MYSQL_DISALLOW_DUPLICATE_CONNECTIONS, true)
-                && activeConnectionMap.isConnectionUserActive(connection.getConnectionID(), userID))
-            throw new GuacamoleClientException
-                    ("Cannot connect. Connection already in use by this user.");
-
-        // Get guacd connection information
-        String host = GuacamoleProperties.getRequiredProperty(GuacamoleProperties.GUACD_HOSTNAME);
-        int port = GuacamoleProperties.getRequiredProperty(GuacamoleProperties.GUACD_PORT);
-
-        // Get socket
-        GuacamoleSocket socket;
-        if (GuacamoleProperties.getProperty(GuacamoleProperties.GUACD_SSL, false))
-            socket = new ConfiguredGuacamoleSocket(
-                new SSLGuacamoleSocket(host, port),
-                connection.getConfiguration(), info
-            );
-        else
-            socket = new ConfiguredGuacamoleSocket(
-                new InetGuacamoleSocket(host, port),
-                connection.getConfiguration(), info
-            );
-
-        // Mark this connection as active
-        int historyID = activeConnectionMap.openConnection(connection.getConnectionID(), 
-                userID, connectionGroupID);
-
-        // Return new MySQLGuacamoleSocket
-        MySQLGuacamoleSocket mySQLGuacamoleSocket = mySQLGuacamoleSocketProvider.get();
-        mySQLGuacamoleSocket.init(socket, connection.getConnectionID(), userID, 
-                historyID, connectionGroupID);
-        
-        return mySQLGuacamoleSocket;
-
-    }
-
-    /**
-     * Creates a new connection having the given name and protocol.
-     *
-     * @param name The name to assign to the new connection.
-     * @param protocol The protocol to assign to the new connection.
-     * @param userID The ID of the user who created this connection.
-     * @param parentID The ID of the parent connection group.
-     * @return A new MySQLConnection containing the data of the newly created
-     *         connection.
-     */
-    public MySQLConnection createConnection(String name, String protocol,
-            int userID, Integer parentID) {
-
-        // Initialize database connection
-        Connection connection = new Connection();
-        connection.setConnection_name(name);
-        connection.setProtocol(protocol);
-        connection.setParent_id(parentID);
-
-        // Create connection
-        connectionDAO.insert(connection);
-        return toMySQLConnection(connection, userID);
-
-    }
-
-    /**
-     * Deletes the connection having the given ID from the database.
-     * @param id The ID of the connection to delete.
-     */
-    public void deleteConnection(int id) {
-        connectionDAO.deleteByPrimaryKey(id);
-    }
-
-    /**
-     * Updates the connection in the database corresponding to the given
-     * MySQLConnection.
-     *
-     * @param mySQLConnection The MySQLConnection to update (save) to the
-     *                        database. This connection must already exist.
-     */
-    public void updateConnection(MySQLConnection mySQLConnection) {
-
-        // Populate connection
-        Connection connection = new Connection();
-        connection.setConnection_id(mySQLConnection.getConnectionID());
-        connection.setParent_id(mySQLConnection.getParentID());
-        connection.setConnection_name(mySQLConnection.getName());
-        connection.setProtocol(mySQLConnection.getConfiguration().getProtocol());
-
-        // Update the connection in the database
-        connectionDAO.updateByPrimaryKey(connection);
-
-    }
-
-    /**
-     * Get the identifiers of all the connections defined in the system 
-     * with a certain parentID.
-     *
-     * @return A Set of identifiers of all the connections defined in the system
-     * with the given parentID.
-     */
-    public Set<String> getAllConnectionIdentifiers(Integer parentID) {
-
-        // Set of all present connection identifiers
-        Set<String> identifiers = new HashSet<String>();
-        
-        // Set up Criteria
-        ConnectionExample example = new ConnectionExample();
-        Criteria criteria = example.createCriteria();
-        if(parentID != null)
-            criteria.andParent_idEqualTo(parentID);
-        else
-            criteria.andParent_idIsNull();
-
-        // Query connection identifiers
-        List<Connection> connections =
-                connectionDAO.selectByExample(example);
-        for (Connection connection : connections)
-            identifiers.add(String.valueOf(connection.getConnection_id()));
-
-        return identifiers;
-
-    }
-
-    /**
-     * Get the connection IDs of all the connections defined in the system 
-     * with a certain parent connection group.
-     *
-     * @return A list of connection IDs of all the connections defined in the system.
-     */
-    public List<Integer> getAllConnectionIDs() {
-
-        // Set of all present connection IDs
-        List<Integer> connectionIDs = new ArrayList<Integer>();
-
-        // Create the criteria
-        ConnectionExample example = new ConnectionExample();
-        
-        // Query the connections
-        List<Connection> connections =
-                connectionDAO.selectByExample(example);
-        for (Connection connection : connections)
-            connectionIDs.add(connection.getConnection_id());
-
-        return connectionIDs;
-
-    }
-
-}
diff --git a/extensions/guacamole-auth-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/service/PasswordEncryptionService.java b/extensions/guacamole-auth-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/service/PasswordEncryptionService.java
deleted file mode 100644
index 0989a1b..0000000
--- a/extensions/guacamole-auth-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/service/PasswordEncryptionService.java
+++ /dev/null
@@ -1,69 +0,0 @@
-
-package net.sourceforge.guacamole.net.auth.mysql.service;
-
-/* ***** BEGIN LICENSE BLOCK *****
- * Version: MPL 1.1/GPL 2.0/LGPL 2.1
- *
- * The contents of this file are subject to the Mozilla Public License Version
- * 1.1 (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.mozilla.org/MPL/
- *
- * Software distributed under the License is distributed on an "AS IS" basis,
- * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- * for the specific language governing rights and limitations under the
- * License.
- *
- * The Original Code is guacamole-auth-mysql.
- *
- * The Initial Developer of the Original Code is
- * James Muehlner.
- * Portions created by the Initial Developer are Copyright (C) 2010
- * the Initial Developer. All Rights Reserved.
- *
- * Contributor(s):
- *
- * Alternatively, the contents of this file may be used under the terms of
- * either the GNU General Public License Version 2 or later (the "GPL"), or
- * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
- * in which case the provisions of the GPL or the LGPL are applicable instead
- * of those above. If you wish to allow use of your version of this file only
- * under the terms of either the GPL or the LGPL, and not to allow others to
- * use your version of this file under the terms of the MPL, indicate your
- * decision by deleting the provisions above and replace them with the notice
- * and other provisions required by the GPL or the LGPL. If you do not delete
- * the provisions above, a recipient may use your version of this file under
- * the terms of any one of the MPL, the GPL or the LGPL.
- *
- * ***** END LICENSE BLOCK ***** */
-
-/**
- * A service to perform password encryption and checking.
- * @author James Muehlner
- */
-public interface PasswordEncryptionService {
-
-    /**
-     * Checks whether the provided, unhashed password matches the given
-     * hash/salt pair.
-     *
-     * @param password The unhashed password to validate.
-     * @param hashedPassword The hashed password to compare the given password
-     *                       against.
-     * @param salt The salt used when the hashed password given was created.
-     * @return true if the provided credentials match the values given, false
-     *         otherwise.
-     */
-    public boolean checkPassword(String password, byte[] hashedPassword,
-            byte[] salt);
-
-    /**
-     * Creates a password hash based on the provided username, password, and
-     * salt.
-     *
-     * @param password The password to hash.
-     * @param salt The salt to use when hashing the password.
-     * @return The generated password hash.
-     */
-    public byte[] createPasswordHash(String password, byte[] salt);
-}
diff --git a/extensions/guacamole-auth-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/service/PermissionCheckService.java b/extensions/guacamole-auth-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/service/PermissionCheckService.java
deleted file mode 100644
index ddf74ab..0000000
--- a/extensions/guacamole-auth-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/service/PermissionCheckService.java
+++ /dev/null
@@ -1,848 +0,0 @@
-/* ***** BEGIN LICENSE BLOCK *****
- * Version: MPL 1.1/GPL 2.0/LGPL 2.1
- *
- * The contents of this file are subject to the Mozilla Public License Version
- * 1.1 (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.mozilla.org/MPL/
- *
- * Software distributed under the License is distributed on an "AS IS" basis,
- * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- * for the specific language governing rights and limitations under the
- * License.
- *
- * The Original Code is guacamole-auth-mysql.
- *
- * The Initial Developer of the Original Code is
- * James Muehlner.
- * Portions created by the Initial Developer are Copyright (C) 2010
- * the Initial Developer. All Rights Reserved.
- *
- * Contributor(s):
- *
- * Alternatively, the contents of this file may be used under the terms of
- * either the GNU General Public License Version 2 or later (the "GPL"), or
- * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
- * in which case the provisions of the GPL or the LGPL are applicable instead
- * of those above. If you wish to allow use of your version of this file only
- * under the terms of either the GPL or the LGPL, and not to allow others to
- * use your version of this file under the terms of the MPL, indicate your
- * decision by deleting the provisions above and replace them with the notice
- * and other provisions required by the GPL or the LGPL. If you do not delete
- * the provisions above, a recipient may use your version of this file under
- * the terms of any one of the MPL, the GPL or the LGPL.
- *
- * ***** END LICENSE BLOCK ***** */
-package net.sourceforge.guacamole.net.auth.mysql.service;
-
-import com.google.inject.Inject;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-import org.glyptodon.guacamole.GuacamoleSecurityException;
-import net.sourceforge.guacamole.net.auth.mysql.MySQLConnectionGroup;
-import net.sourceforge.guacamole.net.auth.mysql.MySQLConstants;
-import net.sourceforge.guacamole.net.auth.mysql.dao.ConnectionGroupPermissionMapper;
-import net.sourceforge.guacamole.net.auth.mysql.dao.ConnectionPermissionMapper;
-import net.sourceforge.guacamole.net.auth.mysql.dao.SystemPermissionMapper;
-import net.sourceforge.guacamole.net.auth.mysql.dao.UserPermissionMapper;
-import net.sourceforge.guacamole.net.auth.mysql.model.ConnectionGroupPermissionExample;
-import net.sourceforge.guacamole.net.auth.mysql.model.ConnectionGroupPermissionKey;
-import net.sourceforge.guacamole.net.auth.mysql.model.ConnectionPermissionExample;
-import net.sourceforge.guacamole.net.auth.mysql.model.ConnectionPermissionExample.Criteria;
-import net.sourceforge.guacamole.net.auth.mysql.model.ConnectionPermissionKey;
-import net.sourceforge.guacamole.net.auth.mysql.model.SystemPermissionExample;
-import net.sourceforge.guacamole.net.auth.mysql.model.SystemPermissionKey;
-import net.sourceforge.guacamole.net.auth.mysql.model.UserPermissionExample;
-import net.sourceforge.guacamole.net.auth.mysql.model.UserPermissionKey;
-import org.glyptodon.guacamole.net.auth.permission.ConnectionGroupPermission;
-import org.glyptodon.guacamole.net.auth.permission.ConnectionPermission;
-import org.glyptodon.guacamole.net.auth.permission.Permission;
-import org.glyptodon.guacamole.net.auth.permission.SystemPermission;
-import org.glyptodon.guacamole.net.auth.permission.UserPermission;
-
-/**
- * A service to retrieve information about what objects a user has permission to.
- * @author James Muehlner
- */
-public class PermissionCheckService {
-
-    /**
-     * Service for accessing users.
-     */
-    @Inject
-    private UserService userService;
-
-    /**
-     * Service for accessing connections.
-     */
-    @Inject
-    private ConnectionService connectionService;
-
-    /**
-     * Service for accessing connection groups.
-     */
-    @Inject
-    private ConnectionGroupService connectionGroupService;
-
-    /**
-     * DAO for accessing permissions related to users.
-     */
-    @Inject
-    private UserPermissionMapper userPermissionDAO;
-
-    /**
-     * DAO for accessing permissions related to connections.
-     */
-    @Inject
-    private ConnectionPermissionMapper connectionPermissionDAO;
-    
-    /**
-     * DAO for accessing permissions related to connection groups.
-     */
-    @Inject
-    private ConnectionGroupPermissionMapper connectionGroupPermissionDAO;
-
-    /**
-     * DAO for accessing permissions related to the system as a whole.
-     */
-    @Inject
-    private SystemPermissionMapper systemPermissionDAO;
-
-    /**
-     * Verifies that the user has the specified access to the given other
-     * user. If permission is denied, a GuacamoleSecurityException is thrown.
-     *
-     * @param userID The ID of the user to check.
-     * @param affectedUserID The user that would be affected by the operation
-     *                       if permission is granted.
-     * @param permissionType The type of permission to check for.
-     * @throws GuacamoleSecurityException If the specified permission is not
-     *                                    granted.
-     */
-    public void verifyUserAccess(int userID, int affectedUserID,
-            String permissionType) throws GuacamoleSecurityException {
-
-        // If permission does not exist, throw exception
-        if(!checkUserAccess(userID, affectedUserID, permissionType))
-            throw new GuacamoleSecurityException("Permission denied.");
-
-    }
-
-    /**
-     * Verifies that the user has the specified access to the given connection.
-     * If permission is denied, a GuacamoleSecurityException is thrown.
-     *
-     * @param userID The ID of the user to check.
-     * @param affectedConnectionID The connection that would be affected by the
-     *                             operation if permission is granted.
-     * @param permissionType The type of permission to check for.
-     * @throws GuacamoleSecurityException If the specified permission is not
-     *                                    granted.
-     */
-    public void verifyConnectionAccess(int userID, int affectedConnectionID, String permissionType) throws GuacamoleSecurityException {
-
-        // If permission does not exist, throw exception
-        if(!checkConnectionAccess(userID, affectedConnectionID, permissionType))
-            throw new GuacamoleSecurityException("Permission denied.");
-
-    }
-
-    /**
-     * Verifies that the user has the specified access to the given connection group.
-     * If permission is denied, a GuacamoleSecurityException is thrown.
-     *
-     * @param userID The ID of the user to check.
-     * @param affectedConnectionGroupID The connection group that would be affected by the
-     *                                  operation if permission is granted.
-     * @param permissionType The type of permission to check for.
-     * @throws GuacamoleSecurityException If the specified permission is not
-     *                                    granted.
-     */
-    public void verifyConnectionGroupAccess(int userID, Integer affectedConnectionGroupID, String permissionType) throws GuacamoleSecurityException {
-
-        // If permission does not exist, throw exception
-        if(!checkConnectionGroupAccess(userID, affectedConnectionGroupID, permissionType))
-            throw new GuacamoleSecurityException("Permission denied.");
-
-    }
-    
-    /**
-     * Verifies that the user has the specified access to the system. If
-     * permission is denied, a GuacamoleSecurityException is thrown.
-     *
-     * @param userID The ID of the user to check.
-     * @param systemPermissionType The type of permission to check for.
-     * @throws GuacamoleSecurityException If the specified permission is not
-     *                                    granted.
-     */
-    public void verifySystemAccess(int userID, String systemPermissionType)
-            throws GuacamoleSecurityException {
-
-        // If permission does not exist, throw exception
-        if(!checkSystemAccess(userID, systemPermissionType))
-            throw new GuacamoleSecurityException("Permission denied.");
-
-    }
-
-    /**
-     * Checks whether a user has the specified type of access to the affected
-     * user.
-     *
-     * @param userID The ID of the user to check.
-     * @param affectedUserID The user that would be affected by the operation
-     *                       if permission is granted.
-     * @param permissionType The type of permission to check for.
-     * @return true if the specified permission is granted, false otherwise.
-     */
-    public boolean checkUserAccess(int userID, Integer affectedUserID, String permissionType) {
-
-        // A system administrator has full access to everything.
-        if(checkSystemAdministratorAccess(userID))
-            return true;
-
-        // Check existence of requested permission
-        UserPermissionExample example = new UserPermissionExample();
-        example.createCriteria().andUser_idEqualTo(userID).andAffected_user_idEqualTo(affectedUserID).andPermissionEqualTo(permissionType);
-        return userPermissionDAO.countByExample(example) > 0;
-
-    }
-
-    /**
-     * Checks whether a user has the specified type of access to the affected
-     * connection.
-     *
-     * @param userID The ID of the user to check.
-     * @param affectedConnectionID The connection that would be affected by the
-     *                             operation if permission is granted.
-     * @param permissionType The type of permission to check for.
-     * @return true if the specified permission is granted, false otherwise.
-     */
-    public boolean checkConnectionAccess(int userID, Integer affectedConnectionID, String permissionType) {
-
-        // A system administrator has full access to everything.
-        if(checkSystemAdministratorAccess(userID))
-            return true;
-
-        // Check existence of requested permission
-        ConnectionPermissionExample example = new ConnectionPermissionExample();
-        example.createCriteria().andUser_idEqualTo(userID).andConnection_idEqualTo(affectedConnectionID).andPermissionEqualTo(permissionType);
-        return connectionPermissionDAO.countByExample(example) > 0;
-
-    }
-
-    /**
-     * Checks whether a user has the specified type of access to the affected
-     * connection group.
-     *
-     * @param userID The ID of the user to check.
-     * @param affectedConnectionGroupID The connection group that would be affected by the
-     *                                  operation if permission is granted.
-     * @param permissionType The type of permission to check for.
-     * @return true if the specified permission is granted, false otherwise.
-     */
-    public boolean checkConnectionGroupAccess(int userID, Integer affectedConnectionGroupID, String permissionType) {
-
-        // All users have implicit permission to read and update the root connection group
-        if(affectedConnectionGroupID == null && 
-                MySQLConstants.CONNECTION_GROUP_READ.equals(permissionType) ||
-                MySQLConstants.CONNECTION_GROUP_UPDATE.equals(permissionType))
-            return true;
-        
-        // A system administrator has full access to everything.
-        if(checkSystemAdministratorAccess(userID))
-            return true;
-
-        // Check existence of requested permission
-        ConnectionGroupPermissionExample example = new ConnectionGroupPermissionExample();
-        example.createCriteria().andUser_idEqualTo(userID).andConnection_group_idEqualTo(affectedConnectionGroupID).andPermissionEqualTo(permissionType);
-        return connectionGroupPermissionDAO.countByExample(example) > 0;
-
-    }
-
-    /**
-     * Checks whether a user has the specified type of access to the system.
-     *
-     * @param userID The ID of the user to check.
-     * @param systemPermissionType The type of permission to check for.
-     * @return true if the specified permission is granted, false otherwise.
-     */
-    private boolean checkSystemAccess(int userID, String systemPermissionType) {
-
-        // A system administrator has full access to everything.
-        if(checkSystemAdministratorAccess(userID))
-            return true;
-
-        // Check existence of requested permission
-        SystemPermissionExample example = new SystemPermissionExample();
-        example.createCriteria().andUser_idEqualTo(userID).andPermissionEqualTo(systemPermissionType);
-        return systemPermissionDAO.countByExample(example) > 0;
-
-    }
-
-
-    /**
-     * Checks whether a user has system administrator access to the system.
-     *
-     * @param userID The ID of the user to check.
-     * @return true if the system administrator access exists, false otherwise.
-     */
-    private boolean checkSystemAdministratorAccess(int userID) {
-
-        // Check existence of system administrator permission
-        SystemPermissionExample example = new SystemPermissionExample();
-        example.createCriteria().andUser_idEqualTo(userID).
-                andPermissionEqualTo(MySQLConstants.SYSTEM_ADMINISTER);
-        return systemPermissionDAO.countByExample(example) > 0;
-    }
-    
-    /**
-     * Verifies that the specified group can be used for organization 
-     * by the given user.
-     * 
-     * @param connectionGroupID The ID of the affected ConnectionGroup.
-     * @param userID The ID of the user to check.
-     * @throws GuacamoleSecurityException If the connection group 
-     *                                    cannot be used for organization.
-     */
-    public void verifyConnectionGroupUsageAccess(Integer connectionGroupID, 
-            int userID, String type) throws GuacamoleSecurityException {
-
-        // If permission does not exist, throw exception
-        if(!checkConnectionGroupUsageAccess(connectionGroupID, userID, type))
-            throw new GuacamoleSecurityException("Permission denied.");
-
-    }
-    
-    /**
-     * Check whether a user can use connectionGroup for the given usage.
-     * @param connectionGroupID the ID of the affected connection group.
-     * @param userID The ID of the user to check.
-     * @param usage The desired usage.
-     * @return true if the user can use the connection group for the given usage.
-     */
-    private boolean checkConnectionGroupUsageAccess(
-            Integer connectionGroupID, int userID, String usage) {
-        
-        // The root level connection group can only be used for organization
-        if(connectionGroupID == null)
-            return MySQLConstants.CONNECTION_GROUP_ORGANIZATIONAL.equals(usage);
-
-        // A system administrator has full access to everything.
-        if(checkSystemAdministratorAccess(userID))
-            return true;
-        
-        // A connection group administrator can use the group either way.
-        if(checkConnectionGroupAccess(userID, connectionGroupID,
-                MySQLConstants.CONNECTION_GROUP_ADMINISTER))
-            return true;
-        
-        // Query the connection group
-        MySQLConnectionGroup connectionGroup = connectionGroupService.
-                retrieveConnectionGroup(connectionGroupID, userID);
-        
-        // If the connection group is not found, it cannot be used.
-        if(connectionGroup == null)
-            return false;
-
-        // Verify that the desired usage matches the type.
-        return MySQLConstants.getConnectionGroupTypeConstant(
-                connectionGroup.getType()).equals(usage);
-        
-    }
-
-
-    /**
-     * Find the list of the IDs of all users a user has permission to.
-     * The access type is defined by permissionType.
-     *
-     * @param userID The ID of the user to check.
-     * @param permissionType The type of permission to check for.
-     * @return A list of all user IDs this user has the specified access to.
-     */
-    public List<Integer> retrieveUserIDs(int userID, String permissionType) {
-
-        // A system administrator has access to all users.
-        if(checkSystemAdministratorAccess(userID))
-            return userService.getAllUserIDs();
-
-        // Query all user permissions for the given user and permission type
-        UserPermissionExample example = new UserPermissionExample();
-        example.createCriteria().andUser_idEqualTo(userID).andPermissionEqualTo(permissionType);
-        example.setDistinct(true);
-        List<UserPermissionKey> userPermissions =
-                userPermissionDAO.selectByExample(example);
-
-        // Convert result into list of IDs
-        List<Integer> userIDs = new ArrayList<Integer>(userPermissions.size());
-        for(UserPermissionKey permission : userPermissions)
-            userIDs.add(permission.getAffected_user_id());
-
-        return userIDs;
-
-    }
-
-    /**
-     * Find the list of the IDs of all connections a user has permission to.
-     * The access type is defined by permissionType.
-     *
-     * @param userID The ID of the user to check.
-     * @param permissionType The type of permission to check for.
-     * @return A list of all connection IDs this user has the specified access
-     *         to.
-     */
-    public List<Integer> retrieveConnectionIDs(int userID,
-            String permissionType) {
-
-        return retrieveConnectionIDs(userID, null, permissionType, false);
-
-    }
-
-    /**
-     * Find the list of the IDs of all connections a user has permission to.
-     * The access type is defined by permissionType.
-     *
-     * @param userID The ID of the user to check.
-     * @param parentID the parent connection group.
-     * @param permissionType The type of permission to check for.
-     * @return A list of all connection IDs this user has the specified access
-     *         to.
-     */
-    public List<Integer> retrieveConnectionIDs(int userID, Integer parentID,
-            String permissionType) {
-
-        return retrieveConnectionIDs(userID, parentID, permissionType, true);
-
-    }
-
-    /**
-     * Find the list of the IDs of all connections a user has permission to.
-     * The access type is defined by permissionType.
-     *
-     * @param userID The ID of the user to check.
-     * @param parentID the parent connection group.
-     * @param permissionType The type of permission to check for.
-     * @param checkParentID Whether the parentID should be checked or not.
-     * @return A list of all connection IDs this user has the specified access
-     *         to.
-     */
-    private List<Integer> retrieveConnectionIDs(int userID, Integer parentID,
-            String permissionType, boolean checkParentID) {
-
-        // A system administrator has access to all connections.
-        if(checkSystemAdministratorAccess(userID)) {
-            if(checkParentID)
-                return connectionService.getAllConnectionIDs(parentID);
-            else
-                return connectionService.getAllConnectionIDs();
-        }
-
-        // Query all connection permissions for the given user and permission type
-        ConnectionPermissionExample example = new ConnectionPermissionExample();
-        Criteria criteria = example.createCriteria().andUser_idEqualTo(userID)
-                .andPermissionEqualTo(permissionType);
-        
-        // Ensure that the connections are all under the parent ID, if needed
-        if(checkParentID) {
-            // Get the IDs of all connections in the connection group
-            List<Integer> allConnectionIDs = connectionService.getAllConnectionIDs(parentID);
-            
-            if(allConnectionIDs.isEmpty())
-                return Collections.EMPTY_LIST;
-            
-            criteria.andConnection_idIn(allConnectionIDs);
-        }
-                                              
-        example.setDistinct(true);
-        List<ConnectionPermissionKey> connectionPermissions =
-                connectionPermissionDAO.selectByExample(example);
-
-        // Convert result into list of IDs
-        List<Integer> connectionIDs = new ArrayList<Integer>(connectionPermissions.size());
-        for(ConnectionPermissionKey permission : connectionPermissions)
-            connectionIDs.add(permission.getConnection_id());
-
-        return connectionIDs;
-
-    }
-
-    /**
-     * Find the list of the IDs of all connection groups a user has permission to.
-     * The access type is defined by permissionType.
-     *
-     * @param userID The ID of the user to check.
-     * @param permissionType The type of permission to check for.
-     * @return A list of all connection group IDs this user has the specified access
-     *         to.
-     */
-    public List<Integer> retrieveConnectionGroupIDs(int userID,
-            String permissionType) {
-
-        return retrieveConnectionGroupIDs(userID, null, permissionType, false);
-
-    }
-
-    /**
-     * Find the list of the IDs of all connection groups a user has permission to.
-     * The access type is defined by permissionType.
-     *
-     * @param userID The ID of the user to check.
-     * @param parentID the parent connection group.
-     * @param permissionType The type of permission to check for.
-     * @return A list of all connection group IDs this user has the specified access
-     *         to.
-     */
-    public List<Integer> retrieveConnectionGroupIDs(int userID, Integer parentID,
-            String permissionType) {
-
-        return retrieveConnectionGroupIDs(userID, parentID, permissionType, true);
-
-    }
-
-    /**
-     * Find the list of the IDs of all connection groups a user has permission to.
-     * The access type is defined by permissionType.
-     *
-     * @param userID The ID of the user to check.
-     * @param parentID the parent connection group.
-     * @param permissionType The type of permission to check for.
-     * @param checkParentID Whether the parentID should be checked or not.
-     * @return A list of all connection group IDs this user has the specified access
-     *         to.
-     */
-    private List<Integer> retrieveConnectionGroupIDs(int userID, Integer parentID,
-            String permissionType, boolean checkParentID) {
-
-        // A system administrator has access to all connectionGroups .
-        if(checkSystemAdministratorAccess(userID)) {
-            if(checkParentID)
-                return connectionGroupService.getAllConnectionGroupIDs(parentID);
-            else
-                return connectionGroupService.getAllConnectionGroupIDs();
-        }
-
-        // Query all connection permissions for the given user and permission type
-        ConnectionGroupPermissionExample example = new ConnectionGroupPermissionExample();
-        ConnectionGroupPermissionExample.Criteria criteria = 
-                example.createCriteria().andUser_idEqualTo(userID)
-                .andPermissionEqualTo(permissionType);
-        
-        // Ensure that the connection groups are all under the parent ID, if needed
-        if(checkParentID) {
-            // Get the IDs of all connection groups in the connection group
-            List<Integer> allConnectionGroupIDs = connectionGroupService
-                    .getAllConnectionGroupIDs(parentID);
-            
-            if(allConnectionGroupIDs.isEmpty())
-                return Collections.EMPTY_LIST;
-            
-            criteria.andConnection_group_idIn(allConnectionGroupIDs);
-        }
-                                              
-        example.setDistinct(true);
-        List<ConnectionGroupPermissionKey> connectionGroupPermissions =
-                connectionGroupPermissionDAO.selectByExample(example);
-
-        // Convert result into list of IDs
-        List<Integer> connectionGroupIDs = new ArrayList<Integer>(connectionGroupPermissions.size());
-        for(ConnectionGroupPermissionKey permission : connectionGroupPermissions)
-            connectionGroupIDs.add(permission.getConnection_group_id());
-        
-        // All users have implicit access to read and update the root group
-        if(MySQLConstants.CONNECTION_GROUP_READ.equals(permissionType)
-                && MySQLConstants.CONNECTION_GROUP_UPDATE.equals(permissionType)
-                && !checkParentID)
-            connectionGroupIDs.add(null);
-
-        return connectionGroupIDs;
-
-    }
-
-    /**
-     * Retrieve all existing usernames that the given user has permission to
-     * perform the given operation upon.
-     *
-     * @param userID The user whose permissions should be checked.
-     * @param permissionType The permission to check.
-     * @return A set of all usernames for which the given user has the given
-     *         permission.
-     */
-    public Set<String> retrieveUsernames(int userID, String permissionType) {
-
-        // A system administrator has access to all users.
-        if(checkSystemAdministratorAccess(userID))
-            return userService.getAllUsernames();
-
-        // List of all user IDs for which this user has read access
-        List<Integer> userIDs =
-                retrieveUserIDs(userID, MySQLConstants.USER_READ);
-
-        // Query all associated users
-        return userService.translateUsernames(userIDs).keySet();
-
-    }
-
-    /**
-     * Retrieve all existing connection identifiers that the given user has 
-     * permission to perform the given operation upon.
-     *
-     * @param userID The user whose permissions should be checked.
-     * @param permissionType The permission to check.
-     * @param parentID The parent connection group.
-     * @return A set of all connection identifiers for which the given user 
-     *         has the given permission.
-     */
-    public Set<String> retrieveConnectionIdentifiers(int userID, Integer parentID,
-            String permissionType) {
-
-        // A system administrator has access to all connections.
-        if(checkSystemAdministratorAccess(userID))
-            return connectionService.getAllConnectionIdentifiers(parentID);
-
-        // List of all connection IDs for which this user has access
-        List<Integer> connectionIDs =
-                retrieveConnectionIDs(userID, parentID, permissionType);
-        
-        // Unique Identifiers for MySQLConnections are the database IDs
-        Set<String> connectionIdentifiers = new HashSet<String>();
-        
-        for(Integer connectionID : connectionIDs)
-            connectionIdentifiers.add(Integer.toString(connectionID));
-
-        return connectionIdentifiers;
-    }
-
-    /**
-     * Retrieve all existing connection group identifiers that the given user 
-     * has permission to perform the given operation upon.
-     *
-     * @param userID The user whose permissions should be checked.
-     * @param permissionType The permission to check.
-     * @param parentID The parent connection group.
-     * @return A set of all connection group identifiers for which the given 
-     *         user has the given permission.
-     */
-    public Set<String> retrieveConnectionGroupIdentifiers(int userID, Integer parentID,
-            String permissionType) {
-
-        // A system administrator has access to all connections.
-        if(checkSystemAdministratorAccess(userID))
-            return connectionGroupService.getAllConnectionGroupIdentifiers(parentID);
-
-        // List of all connection group IDs for which this user has access
-        List<Integer> connectionGroupIDs =
-                retrieveConnectionGroupIDs(userID, parentID, permissionType);
-        
-        // Unique Identifiers for MySQLConnectionGroups are the database IDs
-        Set<String> connectionGroupIdentifiers = new HashSet<String>();
-        
-        for(Integer connectionGroupID : connectionGroupIDs)
-            connectionGroupIdentifiers.add(Integer.toString(connectionGroupID));
-
-        return connectionGroupIdentifiers;
-    }
-
-    /**
-     * Retrieves all user permissions granted to the user having the given ID.
-     *
-     * @param userID The ID of the user to retrieve permissions of.
-     * @return A set of all user permissions granted to the user having the
-     *         given ID.
-     */
-    public Set<UserPermission> retrieveUserPermissions(int userID) {
-
-        // Set of all permissions
-        Set<UserPermission> permissions = new HashSet<UserPermission>();
-
-        // Query all user permissions
-        UserPermissionExample userPermissionExample = new UserPermissionExample();
-        userPermissionExample.createCriteria().andUser_idEqualTo(userID);
-        List<UserPermissionKey> userPermissions =
-                userPermissionDAO.selectByExample(userPermissionExample);
-
-        // Get list of affected user IDs
-        List<Integer> affectedUserIDs = new ArrayList<Integer>();
-        for(UserPermissionKey userPermission : userPermissions)
-            affectedUserIDs.add(userPermission.getAffected_user_id());
-
-        // Get corresponding usernames
-        Map<Integer, String> affectedUsers =
-                userService.retrieveUsernames(affectedUserIDs);
-
-        // Add user permissions
-        for(UserPermissionKey userPermission : userPermissions) {
-
-            // Construct permission from data
-            UserPermission permission = new UserPermission(
-                UserPermission.Type.valueOf(userPermission.getPermission()),
-                affectedUsers.get(userPermission.getAffected_user_id())
-            );
-
-            // Add to set
-            permissions.add(permission);
-
-        }
-
-        return permissions;
-
-    }
-
-    /**
-     * Retrieves all connection permissions granted to the user having the
-     * given ID.
-     *
-     * @param userID The ID of the user to retrieve permissions of.
-     * @return A set of all connection permissions granted to the user having
-     *         the given ID.
-     */
-    public Set<ConnectionPermission> retrieveConnectionPermissions(int userID) {
-
-        // Set of all permissions
-        Set<ConnectionPermission> permissions = new HashSet<ConnectionPermission>();
-
-        // Query all connection permissions
-        ConnectionPermissionExample connectionPermissionExample = new ConnectionPermissionExample();
-        connectionPermissionExample.createCriteria().andUser_idEqualTo(userID);
-        List<ConnectionPermissionKey> connectionPermissions =
-                connectionPermissionDAO.selectByExample(connectionPermissionExample);
-
-        // Add connection permissions
-        for(ConnectionPermissionKey connectionPermission : connectionPermissions) {
-
-            // Construct permission from data
-            ConnectionPermission permission = new ConnectionPermission(
-                ConnectionPermission.Type.valueOf(connectionPermission.getPermission()),
-                String.valueOf(connectionPermission.getConnection_id())
-            );
-
-            // Add to set
-            permissions.add(permission);
-
-        }
-
-        return permissions;
-
-    }
-
-    /**
-     * Retrieves all connection group permissions granted to the user having the
-     * given ID.
-     *
-     * @param userID The ID of the user to retrieve permissions of.
-     * @return A set of all connection group permissions granted to the user having
-     *         the given ID.
-     */
-    public Set<ConnectionGroupPermission> retrieveConnectionGroupPermissions(int userID) {
-
-        // Set of all permissions
-        Set<ConnectionGroupPermission> permissions = new HashSet<ConnectionGroupPermission>();
-
-        // Query all connection permissions
-        ConnectionGroupPermissionExample connectionGroupPermissionExample = new ConnectionGroupPermissionExample();
-        connectionGroupPermissionExample.createCriteria().andUser_idEqualTo(userID);
-        List<ConnectionGroupPermissionKey> connectionGroupPermissions =
-                connectionGroupPermissionDAO.selectByExample(connectionGroupPermissionExample);
-
-        // Add connection permissions
-        for(ConnectionGroupPermissionKey connectionGroupPermission : connectionGroupPermissions) {
-
-            // Construct permission from data
-            ConnectionGroupPermission permission = new ConnectionGroupPermission(
-                ConnectionGroupPermission.Type.valueOf(connectionGroupPermission.getPermission()),
-                String.valueOf(connectionGroupPermission.getConnection_group_id())
-            );
-
-            // Add to set
-            permissions.add(permission);
-
-        }
-        
-        // All users have implict access to read the root connection group
-        permissions.add(new ConnectionGroupPermission(
-            ConnectionGroupPermission.Type.READ,
-            MySQLConstants.CONNECTION_GROUP_ROOT_IDENTIFIER
-        ));
-        
-        // All users have implict access to update the root connection group
-        permissions.add(new ConnectionGroupPermission(
-            ConnectionGroupPermission.Type.UPDATE,
-            MySQLConstants.CONNECTION_GROUP_ROOT_IDENTIFIER
-        ));
-
-        return permissions;
-
-    }
-
-    /**
-     * Retrieves all system permissions granted to the user having the
-     * given ID.
-     *
-     * @param userID The ID of the user to retrieve permissions of.
-     * @return A set of all system permissions granted to the user having the
-     *         given ID.
-     */
-    public Set<SystemPermission> retrieveSystemPermissions(int userID) {
-
-        // Set of all permissions
-        Set<SystemPermission> permissions = new HashSet<SystemPermission>();
-
-        // And finally, system permissions
-        SystemPermissionExample systemPermissionExample = new SystemPermissionExample();
-        systemPermissionExample.createCriteria().andUser_idEqualTo(userID);
-        List<SystemPermissionKey> systemPermissions =
-                systemPermissionDAO.selectByExample(systemPermissionExample);
-        for(SystemPermissionKey systemPermission : systemPermissions) {
-
-            // User creation permission
-            if(systemPermission.getPermission().equals(MySQLConstants.SYSTEM_USER_CREATE))
-                permissions.add(new SystemPermission(SystemPermission.Type.CREATE_USER));
-
-            // System creation permission
-            else if(systemPermission.getPermission().equals(MySQLConstants.SYSTEM_CONNECTION_CREATE))
-                permissions.add(new SystemPermission(SystemPermission.Type.CREATE_CONNECTION));
-
-            // System creation permission
-            else if(systemPermission.getPermission().equals(MySQLConstants.SYSTEM_CONNECTION_GROUP_CREATE))
-                permissions.add(new SystemPermission(SystemPermission.Type.CREATE_CONNECTION_GROUP));
-
-            // System administration permission
-            else if(systemPermission.getPermission().equals(MySQLConstants.SYSTEM_ADMINISTER))
-                permissions.add(new SystemPermission(SystemPermission.Type.ADMINISTER));
-
-        }
-
-        return permissions;
-
-    }
-
-    /**
-     * Retrieves all permissions granted to the user having the given ID.
-     *
-     * @param userID The ID of the user to retrieve permissions of.
-     * @return A set of all permissions granted to the user having the given
-     *         ID.
-     */
-    public Set<Permission> retrieveAllPermissions(int userID) {
-
-        // Set which will contain all permissions
-        Set<Permission> allPermissions = new HashSet<Permission>();
-
-        // Add user permissions
-        allPermissions.addAll(retrieveUserPermissions(userID));
-
-        // Add connection permissions
-        allPermissions.addAll(retrieveConnectionPermissions(userID));
-        
-        // add connection group permissions
-        allPermissions.addAll(retrieveConnectionGroupPermissions(userID));
-
-        // Add system permissions
-        allPermissions.addAll(retrieveSystemPermissions(userID));
-
-        return allPermissions;
-    }
-
-}
diff --git a/extensions/guacamole-auth-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/service/SHA256PasswordEncryptionService.java b/extensions/guacamole-auth-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/service/SHA256PasswordEncryptionService.java
deleted file mode 100644
index 04fea1a..0000000
--- a/extensions/guacamole-auth-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/service/SHA256PasswordEncryptionService.java
+++ /dev/null
@@ -1,90 +0,0 @@
-
-package net.sourceforge.guacamole.net.auth.mysql.service;
-
-/* ***** BEGIN LICENSE BLOCK *****
- * Version: MPL 1.1/GPL 2.0/LGPL 2.1
- *
- * The contents of this file are subject to the Mozilla Public License Version
- * 1.1 (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.mozilla.org/MPL/
- *
- * Software distributed under the License is distributed on an "AS IS" basis,
- * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- * for the specific language governing rights and limitations under the
- * License.
- *
- * The Original Code is guacamole-auth-mysql.
- *
- * The Initial Developer of the Original Code is
- * James Muehlner.
- * Portions created by the Initial Developer are Copyright (C) 2010
- * the Initial Developer. All Rights Reserved.
- *
- * Contributor(s):
- *
- * Alternatively, the contents of this file may be used under the terms of
- * either the GNU General Public License Version 2 or later (the "GPL"), or
- * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
- * in which case the provisions of the GPL or the LGPL are applicable instead
- * of those above. If you wish to allow use of your version of this file only
- * under the terms of either the GPL or the LGPL, and not to allow others to
- * use your version of this file under the terms of the MPL, indicate your
- * decision by deleting the provisions above and replace them with the notice
- * and other provisions required by the GPL or the LGPL. If you do not delete
- * the provisions above, a recipient may use your version of this file under
- * the terms of any one of the MPL, the GPL or the LGPL.
- *
- * ***** END LICENSE BLOCK ***** */
-
-import java.io.UnsupportedEncodingException;
-import java.security.MessageDigest;
-import java.security.NoSuchAlgorithmException;
-import java.util.Arrays;
-import javax.xml.bind.DatatypeConverter;
-
-/**
- * Provides a SHA-256 based implementation of the password encryption functionality.
- * @author James Muehlner
- */
-public class SHA256PasswordEncryptionService implements PasswordEncryptionService {
-
-    @Override
-    public boolean checkPassword(String password, byte[] hashedPassword,
-        byte[] salt) {
-
-        // Compare bytes of password in credentials against hashed password
-        byte[] passwordBytes = createPasswordHash(password, salt);
-        return Arrays.equals(passwordBytes, hashedPassword);
-
-    }
-
-    @Override
-    public byte[] createPasswordHash(String password, byte[] salt) {
-
-        try {
-
-            // Build salted password
-            StringBuilder builder = new StringBuilder();
-            builder.append(password);
-            builder.append(DatatypeConverter.printHexBinary(salt));
-
-            // Hash UTF-8 bytes of salted password
-            MessageDigest md = MessageDigest.getInstance("SHA-256");
-            md.update(builder.toString().getBytes("UTF-8"));
-            return md.digest();
-
-        }
-
-        // Should not happen
-        catch (UnsupportedEncodingException ex) {
-            throw new RuntimeException(ex);
-        }
-
-        // Should not happen
-        catch (NoSuchAlgorithmException ex) {
-            throw new RuntimeException(ex);
-        }
-
-    }
-}
diff --git a/extensions/guacamole-auth-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/service/SaltService.java b/extensions/guacamole-auth-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/service/SaltService.java
deleted file mode 100644
index 0d194c9..0000000
--- a/extensions/guacamole-auth-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/service/SaltService.java
+++ /dev/null
@@ -1,48 +0,0 @@
-/* ***** BEGIN LICENSE BLOCK *****
- * Version: MPL 1.1/GPL 2.0/LGPL 2.1
- *
- * The contents of this file are subject to the Mozilla Public License Version
- * 1.1 (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.mozilla.org/MPL/
- *
- * Software distributed under the License is distributed on an "AS IS" basis,
- * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- * for the specific language governing rights and limitations under the
- * License.
- *
- * The Original Code is guacamole-auth-mysql.
- *
- * The Initial Developer of the Original Code is
- * James Muehlner.
- * Portions created by the Initial Developer are Copyright (C) 2010
- * the Initial Developer. All Rights Reserved.
- *
- * Contributor(s):
- *
- * Alternatively, the contents of this file may be used under the terms of
- * either the GNU General Public License Version 2 or later (the "GPL"), or
- * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
- * in which case the provisions of the GPL or the LGPL are applicable instead
- * of those above. If you wish to allow use of your version of this file only
- * under the terms of either the GPL or the LGPL, and not to allow others to
- * use your version of this file under the terms of the MPL, indicate your
- * decision by deleting the provisions above and replace them with the notice
- * and other provisions required by the GPL or the LGPL. If you do not delete
- * the provisions above, a recipient may use your version of this file under
- * the terms of any one of the MPL, the GPL or the LGPL.
- *
- * ***** END LICENSE BLOCK ***** */
-package net.sourceforge.guacamole.net.auth.mysql.service;
-
-/**
- * A service to generate password salts.
- * @author James Muehlner
- */
-public interface SaltService {
-    /**
-     * Generates a new String that can be used as a password salt.
-     * @return a new salt for password encryption.
-     */
-    public byte[] generateSalt();
-}
diff --git a/extensions/guacamole-auth-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/service/SecureRandomSaltService.java b/extensions/guacamole-auth-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/service/SecureRandomSaltService.java
deleted file mode 100644
index 35caba2..0000000
--- a/extensions/guacamole-auth-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/service/SecureRandomSaltService.java
+++ /dev/null
@@ -1,60 +0,0 @@
-
-package net.sourceforge.guacamole.net.auth.mysql.service;
-
-/* ***** BEGIN LICENSE BLOCK *****
- * Version: MPL 1.1/GPL 2.0/LGPL 2.1
- *
- * The contents of this file are subject to the Mozilla Public License Version
- * 1.1 (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.mozilla.org/MPL/
- *
- * Software distributed under the License is distributed on an "AS IS" basis,
- * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- * for the specific language governing rights and limitations under the
- * License.
- *
- * The Original Code is guacamole-auth-mysql.
- *
- * The Initial Developer of the Original Code is
- * James Muehlner.
- * Portions created by the Initial Developer are Copyright (C) 2010
- * the Initial Developer. All Rights Reserved.
- *
- * Contributor(s):
- *
- * Alternatively, the contents of this file may be used under the terms of
- * either the GNU General Public License Version 2 or later (the "GPL"), or
- * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
- * in which case the provisions of the GPL or the LGPL are applicable instead
- * of those above. If you wish to allow use of your version of this file only
- * under the terms of either the GPL or the LGPL, and not to allow others to
- * use your version of this file under the terms of the MPL, indicate your
- * decision by deleting the provisions above and replace them with the notice
- * and other provisions required by the GPL or the LGPL. If you do not delete
- * the provisions above, a recipient may use your version of this file under
- * the terms of any one of the MPL, the GPL or the LGPL.
- *
- * ***** END LICENSE BLOCK ***** */
-
-import java.security.SecureRandom;
-
-/**
- * Generates password salts via SecureRandom.
- * @author James Muehlner
- */
-public class SecureRandomSaltService implements SaltService {
-
-    /**
-     * Instance of SecureRandom for generating the salt.
-     */
-    private SecureRandom secureRandom = new SecureRandom();
-
-    @Override
-    public byte[] generateSalt() {
-        byte[] salt = new byte[32];
-        secureRandom.nextBytes(salt);
-        return salt;
-    }
-
-}
diff --git a/extensions/guacamole-auth-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/service/UserService.java b/extensions/guacamole-auth-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/service/UserService.java
deleted file mode 100644
index b988983..0000000
--- a/extensions/guacamole-auth-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/service/UserService.java
+++ /dev/null
@@ -1,381 +0,0 @@
-
-package net.sourceforge.guacamole.net.auth.mysql.service;
-
-/* ***** BEGIN LICENSE BLOCK *****
- * Version: MPL 1.1/GPL 2.0/LGPL 2.1
- *
- * The contents of this file are subject to the Mozilla Public License Version
- * 1.1 (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.mozilla.org/MPL/
- *
- * Software distributed under the License is distributed on an "AS IS" basis,
- * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- * for the specific language governing rights and limitations under the
- * License.
- *
- * The Original Code is guacamole-auth-mysql.
- *
- * The Initial Developer of the Original Code is
- * James Muehlner.
- * Portions created by the Initial Developer are Copyright (C) 2010
- * the Initial Developer. All Rights Reserved.
- *
- * Contributor(s):
- *
- * Alternatively, the contents of this file may be used under the terms of
- * either the GNU General Public License Version 2 or later (the "GPL"), or
- * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
- * in which case the provisions of the GPL or the LGPL are applicable instead
- * of those above. If you wish to allow use of your version of this file only
- * under the terms of either the GPL or the LGPL, and not to allow others to
- * use your version of this file under the terms of the MPL, indicate your
- * decision by deleting the provisions above and replace them with the notice
- * and other provisions required by the GPL or the LGPL. If you do not delete
- * the provisions above, a recipient may use your version of this file under
- * the terms of any one of the MPL, the GPL or the LGPL.
- *
- * ***** END LICENSE BLOCK ***** */
-
-import com.google.common.collect.Lists;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-import org.glyptodon.guacamole.GuacamoleException;
-import org.glyptodon.guacamole.net.auth.Credentials;
-import net.sourceforge.guacamole.net.auth.mysql.MySQLUser;
-import net.sourceforge.guacamole.net.auth.mysql.dao.UserMapper;
-import net.sourceforge.guacamole.net.auth.mysql.model.User;
-import net.sourceforge.guacamole.net.auth.mysql.model.UserExample;
-import net.sourceforge.guacamole.net.auth.mysql.model.UserWithBLOBs;
-
-/**
- * Service which provides convenience methods for creating, retrieving, and
- * manipulating users.
- *
- * @author Michael Jumper, James Muehlner
- */
-public class UserService {
-
-    /**
-     * DAO for accessing users.
-     */
-    @Inject
-    private UserMapper userDAO;
-
-    /**
-     * Provider for creating users.
-     */
-    @Inject
-    private Provider<MySQLUser> mySQLUserProvider;
-
-    /**
-     * Service for checking permissions.
-     */
-    @Inject
-    private PermissionCheckService permissionCheckService;
-
-    /**
-     * Service for encrypting passwords.
-     */
-    @Inject
-    private PasswordEncryptionService passwordService;
-
-    /**
-     * Service for generating random salts.
-     */
-    @Inject
-    private SaltService saltService;
-
-    /**
-     * Create a new MySQLUser based on the provided User.
-     *
-     * @param user The User to use when populating the data of the given
-     *             MySQLUser.
-     * @return A new MySQLUser object, populated with the data of the given
-     *         user.
-     *
-     * @throws GuacamoleException If an error occurs while reading the data
-     *                            of the provided User.
-     */
-    public MySQLUser toMySQLUser(org.glyptodon.guacamole.net.auth.User user) throws GuacamoleException {
-        MySQLUser mySQLUser = mySQLUserProvider.get();
-        mySQLUser.init(user);
-        return mySQLUser;
-    }
-
-    /**
-     * Create a new MySQLUser based on the provided database record.
-     *
-     * @param user The database record describing the user.
-     * @return A new MySQLUser object, populated with the data of the given
-     *         database record.
-     */
-    private MySQLUser toMySQLUser(UserWithBLOBs user) {
-
-        // Retrieve user from provider
-        MySQLUser mySQLUser = mySQLUserProvider.get();
-
-        // Init with data from given database user
-        mySQLUser.init(
-            user.getUser_id(),
-            user.getUsername(),
-            null,
-            permissionCheckService.retrieveAllPermissions(user.getUser_id())
-        );
-
-        // Return new user
-        return mySQLUser;
-
-    }
-
-    /**
-     * Retrieves the user having the given ID from the database.
-     *
-     * @param id The ID of the user to retrieve.
-     * @return The existing MySQLUser object if found, null otherwise.
-     */
-    public MySQLUser retrieveUser(int id) {
-
-        // Query user by ID
-        UserWithBLOBs user = userDAO.selectByPrimaryKey(id);
-
-        // If no user found, return null
-        if(user == null)
-            return null;
-
-        // Otherwise, return found user
-        return toMySQLUser(user);
-
-    }
-
-    /**
-     * Retrieves the user having the given username from the database.
-     *
-     * @param name The username of the user to retrieve.
-     * @return The existing MySQLUser object if found, null otherwise.
-     */
-    public MySQLUser retrieveUser(String name) {
-
-        // Query user by ID
-        UserExample example = new UserExample();
-        example.createCriteria().andUsernameEqualTo(name);
-        List<UserWithBLOBs> users = userDAO.selectByExampleWithBLOBs(example);
-
-        // If no user found, return null
-        if(users.isEmpty())
-            return null;
-
-        // Otherwise, return found user
-        return toMySQLUser(users.get(0));
-
-    }
-
-    /**
-     * Retrieves the user corresponding to the given credentials from the
-     * database.
-     *
-     * @param credentials The credentials to use when locating the user.
-     * @return The existing MySQLUser object if the credentials given are
-     *         valid, null otherwise.
-     */
-    public MySQLUser retrieveUser(Credentials credentials) {
-
-        // No null users in database
-        if (credentials.getUsername() == null)
-            return null;
-
-        // Query user
-        UserExample userExample = new UserExample();
-        userExample.createCriteria().andUsernameEqualTo(credentials.getUsername());
-        List<UserWithBLOBs> users = userDAO.selectByExampleWithBLOBs(userExample);
-
-        // Check that a user was found
-        if (users.isEmpty())
-            return null;
-
-        // Assert only one user found
-        assert users.size() == 1 : "Multiple users with same username.";
-
-        // Get first (and only) user
-        UserWithBLOBs user = users.get(0);
-
-        // Check password, if invalid return null
-        if (!passwordService.checkPassword(credentials.getPassword(),
-                user.getPassword_hash(), user.getPassword_salt()))
-            return null;
-
-        // Return found user
-        return toMySQLUser(user);
-
-    }
-
-    /**
-     * Retrieves a translation map of usernames to their corresponding IDs.
-     *
-     * @param ids The IDs of the users to retrieve the usernames of.
-     * @return A map containing the names of all users and their corresponding
-     *         IDs.
-     */
-    public Map<String, Integer> translateUsernames(List<Integer> ids) {
-
-        // If no IDs given, just return empty map
-        if (ids.isEmpty())
-            return Collections.EMPTY_MAP;
-
-        // Map of all names onto their corresponding IDs
-        Map<String, Integer> names = new HashMap<String, Integer>();
-
-        // Get all users having the given IDs
-        UserExample example = new UserExample();
-        example.createCriteria().andUser_idIn(ids);
-        List<User> users =
-                userDAO.selectByExample(example);
-
-        // Produce set of names
-        for (User user : users)
-            names.put(user.getUsername(), user.getUser_id());
-
-        return names;
-
-    }
-
-    /**
-     * Retrieves a map of all usernames for the given IDs.
-     *
-     * @param ids The IDs of the users to retrieve the usernames of.
-     * @return A map containing the names of all users and their corresponding
-     *         IDs.
-     */
-    public Map<Integer, String> retrieveUsernames(Collection<Integer> ids) {
-
-        // If no IDs given, just return empty map
-        if (ids.isEmpty())
-            return Collections.EMPTY_MAP;
-
-        // Map of all names onto their corresponding IDs
-        Map<Integer, String> names = new HashMap<Integer, String>();
-
-        // Get all users having the given IDs
-        UserExample example = new UserExample();
-        example.createCriteria().andUser_idIn(Lists.newArrayList(ids));
-        List<User> users =
-                userDAO.selectByExample(example);
-
-        // Produce set of names
-        for (User user : users)
-            names.put(user.getUser_id(), user.getUsername());
-
-        return names;
-
-    }
-
-    /**
-     * Creates a new user having the given username and password.
-     *
-     * @param username The username to assign to the new user.
-     * @param password The password to assign to the new user.
-     * @return A new MySQLUser containing the data of the newly created
-     *         user.
-     */
-    public MySQLUser createUser(String username, String password) {
-
-        // Initialize database user
-        UserWithBLOBs user = new UserWithBLOBs();
-        user.setUsername(username);
-
-        // Set password if specified
-        if (password != null) {
-            byte[] salt = saltService.generateSalt();
-            user.setPassword_salt(salt);
-            user.setPassword_hash(
-                passwordService.createPasswordHash(password, salt));
-        }
-
-        // Create user
-        userDAO.insert(user);
-        return toMySQLUser(user);
-
-    }
-
-    /**
-     * Deletes the user having the given ID from the database.
-     * @param user_id The ID of the user to delete.
-     */
-    public void deleteUser(int user_id) {
-        userDAO.deleteByPrimaryKey(user_id);
-    }
-
-    /**
-     * Updates the user in the database corresponding to the given MySQLUser.
-     *
-     * @param mySQLUser The MySQLUser to update (save) to the database. This
-     *                  user must already exist.
-     */
-    public void updateUser(MySQLUser mySQLUser) {
-
-        UserWithBLOBs user = new UserWithBLOBs();
-        user.setUser_id(mySQLUser.getUserID());
-        user.setUsername(mySQLUser.getUsername());
-
-        // Set password if specified
-        if (mySQLUser.getPassword() != null) {
-            byte[] salt = saltService.generateSalt();
-            user.setPassword_salt(salt);
-            user.setPassword_hash(
-                passwordService.createPasswordHash(mySQLUser.getPassword(), salt));
-        }
-
-        // Update the user in the database
-        userDAO.updateByPrimaryKeySelective(user);
-
-    }
-
-    /**
-     * Get the usernames of all the users defined in the system.
-     *
-     * @return A Set of usernames of all the users defined in the system.
-     */
-    public Set<String> getAllUsernames() {
-
-        // Set of all present usernames
-        Set<String> usernames = new HashSet<String>();
-
-        // Query all usernames
-        List<User> users =
-                userDAO.selectByExample(new UserExample());
-        for (User user : users)
-            usernames.add(user.getUsername());
-
-        return usernames;
-
-    }
-
-    /**
-     * Get the user IDs of all the users defined in the system.
-     *
-     * @return A list of user IDs of all the users defined in the system.
-     */
-    public List<Integer> getAllUserIDs() {
-
-        // Set of all present user IDs
-        List<Integer> userIDs = new ArrayList<Integer>();
-
-        // Query all user IDs
-        List<User> users =
-                userDAO.selectByExample(new UserExample());
-        for (User user : users)
-            userIDs.add(user.getUser_id());
-
-        return userIDs;
-
-    }
-
-}
diff --git a/extensions/guacamole-auth-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/service/package-info.java b/extensions/guacamole-auth-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/service/package-info.java
deleted file mode 100644
index 4cc071f..0000000
--- a/extensions/guacamole-auth-mysql/src/main/java/net/sourceforge/guacamole/net/auth/mysql/service/package-info.java
+++ /dev/null
@@ -1,7 +0,0 @@
-
-/**
- * Service classes which help fill the needs of the MySQL authentication
- * provider.
- */
-package net.sourceforge.guacamole.net.auth.mysql.service;
-
diff --git a/extensions/guacamole-auth-mysql/src/main/resources/generatorConfig.xml b/extensions/guacamole-auth-mysql/src/main/resources/generatorConfig.xml
deleted file mode 100644
index a232603..0000000
--- a/extensions/guacamole-auth-mysql/src/main/resources/generatorConfig.xml
+++ /dev/null
@@ -1,114 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<!DOCTYPE generatorConfiguration
-    PUBLIC "-//mybatis.org//DTD MyBatis Generator Configuration 1.0//EN"
-    "http://mybatis.org/dtd/mybatis-generator-config_1_0.dtd">
-
-<generatorConfiguration>
-    <context id="guacamoleTables" targetRuntime="MyBatis3">
-
-        <!-- Allow selectByExample with RowBounds -->
-        <plugin type="org.mybatis.generator.plugins.RowBoundsPlugin"/>
-
-        <!-- MySQL JDBC driver class. -->
-        <jdbcConnection driverClass="com.mysql.jdbc.Driver"
-            connectionURL="jdbc:mysql://127.0.0.1:3306"
-            userId="${guacamole.database.user}"
-            password="${guacamole.database.password}"/>
-
-        <javaModelGenerator
-            targetPackage="net.sourceforge.guacamole.net.auth.mysql.model"
-            targetProject="MAVEN"/>
-
-        <sqlMapGenerator
-            targetPackage="net.sourceforge.guacamole.net.auth.mysql.dao"
-            targetProject="MAVEN"/>
-
-        <javaClientGenerator type="XMLMAPPER"
-            targetPackage="net.sourceforge.guacamole.net.auth.mysql.dao"
-            targetProject="MAVEN"/>
-
-        <!-- TABLES -->
-
-        <table tableName="guacamole_connection"
-            catalog="${guacamole.database.catalog}"
-            schema="${guacamole.database.schema}"
-            domainObjectName="Connection" >
-            <property name="useActualColumnNames" value="true"/>
-            <property name="ignoreQualifiersAtRuntime" value="true"/>
-            <generatedKey column="connection_id" identity="true"
-                sqlStatement="SELECT LAST_INSERT_ID()"/>
-        </table>
-
-        <table tableName="guacamole_connection_group"
-            catalog="${guacamole.database.catalog}"
-            schema="${guacamole.database.schema}"
-            domainObjectName="ConnectionGroup" >
-            <property name="useActualColumnNames" value="true"/>
-            <property name="ignoreQualifiersAtRuntime" value="true"/>
-            <generatedKey column="connection_group_id" identity="true"
-                sqlStatement="SELECT LAST_INSERT_ID()"/>
-        </table>
-
-        <table tableName="guacamole_connection_parameter"
-            catalog="${guacamole.database.catalog}"
-            schema="${guacamole.database.schema}"
-            domainObjectName="ConnectionParameter" >
-            <property name="useActualColumnNames" value="true"/>
-            <property name="ignoreQualifiersAtRuntime" value="true"/>
-        </table>
-
-        <table tableName="guacamole_connection_permission"
-            catalog="${guacamole.database.catalog}"
-            schema="${guacamole.database.schema}"
-            domainObjectName="ConnectionPermission" >
-            <property name="useActualColumnNames" value="true"/>
-            <property name="ignoreQualifiersAtRuntime" value="true"/>
-        </table>
-
-        <table tableName="guacamole_connection_group_permission"
-            catalog="${guacamole.database.catalog}"
-            schema="${guacamole.database.schema}"
-            domainObjectName="ConnectionGroupPermission" >
-            <property name="useActualColumnNames" value="true"/>
-            <property name="ignoreQualifiersAtRuntime" value="true"/>
-        </table>
-
-        <table tableName="guacamole_system_permission"
-            catalog="${guacamole.database.catalog}"
-            schema="${guacamole.database.schema}"
-            domainObjectName="SystemPermission" >
-            <property name="useActualColumnNames" value="true"/>
-            <property name="ignoreQualifiersAtRuntime" value="true"/>
-        </table>
-
-        <table tableName="guacamole_user"
-            catalog="${guacamole.database.catalog}"
-            schema="${guacamole.database.schema}"
-            domainObjectName="User" >
-            <property name="useActualColumnNames" value="true"/>
-            <property name="ignoreQualifiersAtRuntime" value="true"/>
-            <generatedKey column="user_id" identity="true"
-                sqlStatement="SELECT LAST_INSERT_ID()"/>
-        </table>
-
-        <table tableName="guacamole_user_permission"
-            catalog="${guacamole.database.catalog}"
-            schema="${guacamole.database.schema}"
-            domainObjectName="UserPermission" >
-            <property name="useActualColumnNames" value="true"/>
-            <property name="ignoreQualifiersAtRuntime" value="true"/>
-        </table>
-
-        <table tableName="guacamole_connection_history"
-            catalog="${guacamole.database.catalog}"
-            schema="${guacamole.database.schema}"
-            domainObjectName="ConnectionHistory" >
-            <property name="useActualColumnNames" value="true"/>
-            <property name="ignoreQualifiersAtRuntime" value="true"/>
-            <generatedKey column="history_id" identity="true"
-                sqlStatement="SELECT LAST_INSERT_ID()"/>
-        </table>
-
-    </context>
-</generatorConfiguration>
-
diff --git a/extensions/guacamole-auth-noauth/LICENSE b/extensions/guacamole-auth-noauth/LICENSE
new file mode 100644
index 0000000..540cdcf
--- /dev/null
+++ b/extensions/guacamole-auth-noauth/LICENSE
@@ -0,0 +1,19 @@
+Copyright (C) 2013 Glyptodon LLC
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/extensions/guacamole-auth-noauth/pom.xml b/extensions/guacamole-auth-noauth/pom.xml
index 7667a9e..17a1046 100644
--- a/extensions/guacamole-auth-noauth/pom.xml
+++ b/extensions/guacamole-auth-noauth/pom.xml
@@ -5,7 +5,7 @@
     <groupId>org.glyptodon.guacamole</groupId>
     <artifactId>guacamole-auth-noauth</artifactId>
     <packaging>jar</packaging>
-    <version>0.8.0</version>
+    <version>0.9.9</version>
     <name>guacamole-auth-noauth</name>
     <url>http://guacamole.sourceforge.net/</url>
 
@@ -20,16 +20,42 @@
             <plugin>
                 <groupId>org.apache.maven.plugins</groupId>
                 <artifactId>maven-compiler-plugin</artifactId>
+                <version>3.3</version>
                 <configuration>
                     <source>1.6</source>
                     <target>1.6</target>
+                    <compilerArgs>
+                        <arg>-Xlint:all</arg>
+                        <arg>-Werror</arg>
+                    </compilerArgs>
+                    <fork>true</fork>
                 </configuration>
             </plugin>
 
+            <!-- Copy dependencies prior to packaging -->
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-dependency-plugin</artifactId>
+                <version>2.10</version>
+                <executions>
+                    <execution>
+                        <id>unpack-dependencies</id>
+                        <phase>prepare-package</phase>
+                        <goals>
+                            <goal>unpack-dependencies</goal>
+                        </goals>
+                        <configuration>
+                            <includeScope>runtime</includeScope>
+                            <outputDirectory>${project.build.directory}/classes</outputDirectory>
+                        </configuration>
+                    </execution>
+                </executions>
+            </plugin>
+
             <!-- Assembly plugin - for easy distribution -->
             <plugin>
                 <artifactId>maven-assembly-plugin</artifactId>
-                <version>2.2-beta-5</version>
+                <version>2.5.3</version>
                 <configuration>
                     <finalName>${project.artifactId}-${project.version}</finalName>
                     <appendAssemblyId>false</appendAssemblyId>
@@ -57,14 +83,16 @@
         <dependency>
             <groupId>org.glyptodon.guacamole</groupId>
             <artifactId>guacamole-common</artifactId>
-            <version>0.8.0</version>
+            <version>0.9.9</version>
+            <scope>provided</scope>
         </dependency>
 
         <!-- Guacamole Extension API -->
         <dependency>
             <groupId>org.glyptodon.guacamole</groupId>
             <artifactId>guacamole-ext</artifactId>
-            <version>0.8.1</version>
+            <version>0.9.9</version>
+            <scope>provided</scope>
         </dependency>
 
     </dependencies>
diff --git a/extensions/guacamole-auth-noauth/src/main/assembly/dist.xml b/extensions/guacamole-auth-noauth/src/main/assembly/dist.xml
index bb42806..834d5be 100644
--- a/extensions/guacamole-auth-noauth/src/main/assembly/dist.xml
+++ b/extensions/guacamole-auth-noauth/src/main/assembly/dist.xml
@@ -11,38 +11,23 @@
         <format>tar.gz</format>
     </formats>
 
-    <!-- Include docs and schema -->
+    <!-- Include docs and extension .jar -->
     <fileSets>
 
-            <!-- Include docs -->
-            <fileSet>
-                <outputDirectory>/</outputDirectory>
-                <directory>doc</directory>
-            </fileSet>
+        <!-- Include docs -->
+        <fileSet>
+            <directory>doc</directory>
+        </fileSet>
 
-    </fileSets>
-
-    <!-- Include self and all dependencies except guacamole-common 
-         and guacamole-ext -->
-    <dependencySets>
-        <dependencySet>
-
-            <outputDirectory>/lib</outputDirectory>
-            <scope>runtime</scope>
-            <unpack>false</unpack>
-            <useProjectArtifact>true</useProjectArtifact>
-            <useTransitiveFiltering>true</useTransitiveFiltering>
-
-            <excludes>
+        <!-- Include extension .jar -->
+        <fileSet>
+            <directory>target</directory>
+            <outputDirectory></outputDirectory>
+            <includes>
+                <include>*.jar</include>
+            </includes>
+        </fileSet>
 
-                <!-- Do not include guacamole-common -->
-                <exclude>org.glyptodon.guacamole:guacamole-common</exclude>
-
-                <!-- Do not include guacamole-ext -->
-                <exclude>org.glyptodon.guacamole:guacamole-ext</exclude>
-
-            </excludes>
-        </dependencySet>
-    </dependencySets>
+    </fileSets>
 
 </assembly>
diff --git a/extensions/guacamole-auth-noauth/src/main/java/net/sourceforge/guacamole/net/auth/noauth/NoAuthConfigContentHandler.java b/extensions/guacamole-auth-noauth/src/main/java/net/sourceforge/guacamole/net/auth/noauth/NoAuthConfigContentHandler.java
index b1f4b92..40c27f9 100644
--- a/extensions/guacamole-auth-noauth/src/main/java/net/sourceforge/guacamole/net/auth/noauth/NoAuthConfigContentHandler.java
+++ b/extensions/guacamole-auth-noauth/src/main/java/net/sourceforge/guacamole/net/auth/noauth/NoAuthConfigContentHandler.java
@@ -1,41 +1,26 @@
-
-package net.sourceforge.guacamole.net.auth.noauth;
-
-/* ***** BEGIN LICENSE BLOCK *****
- * Version: MPL 1.1/GPL 2.0/LGPL 2.1
- *
- * The contents of this file are subject to the Mozilla Public License Version
- * 1.1 (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.mozilla.org/MPL/
- *
- * Software distributed under the License is distributed on an "AS IS" basis,
- * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- * for the specific language governing rights and limitations under the
- * License.
+/*
+ * Copyright (C) 2013 Glyptodon LLC
  *
- * The Original Code is guacamole-auth-noauth.
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
  *
- * The Initial Developer of the Original Code is
- * Laurent Meunier
- * Portions created by the Initial Developer are Copyright (C) 2010
- * the Initial Developer. All Rights Reserved.
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
  *
- * Contributor(s):
- *
- * Alternatively, the contents of this file may be used under the terms of
- * either the GNU General Public License Version 2 or later (the "GPL"), or
- * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
- * in which case the provisions of the GPL or the LGPL are applicable instead
- * of those above. If you wish to allow use of your version of this file only
- * under the terms of either the GPL or the LGPL, and not to allow others to
- * use your version of this file under the terms of the MPL, indicate your
- * decision by deleting the provisions above and replace them with the notice
- * and other provisions required by the GPL or the LGPL. If you do not delete
- * the provisions above, a recipient may use your version of this file under
- * the terms of any one of the MPL, the GPL or the LGPL.
- *
- * ***** END LICENSE BLOCK ***** */
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package net.sourceforge.guacamole.net.auth.noauth;
 
 import java.util.Collections;
 import java.util.HashMap;
diff --git a/extensions/guacamole-auth-noauth/src/main/java/net/sourceforge/guacamole/net/auth/noauth/NoAuthenticationProvider.java b/extensions/guacamole-auth-noauth/src/main/java/net/sourceforge/guacamole/net/auth/noauth/NoAuthenticationProvider.java
index 7d5ded9..fe38e13 100644
--- a/extensions/guacamole-auth-noauth/src/main/java/net/sourceforge/guacamole/net/auth/noauth/NoAuthenticationProvider.java
+++ b/extensions/guacamole-auth-noauth/src/main/java/net/sourceforge/guacamole/net/auth/noauth/NoAuthenticationProvider.java
@@ -1,41 +1,26 @@
-
-package net.sourceforge.guacamole.net.auth.noauth;
-
-/* ***** BEGIN LICENSE BLOCK *****
- * Version: MPL 1.1/GPL 2.0/LGPL 2.1
- *
- * The contents of this file are subject to the Mozilla Public License Version
- * 1.1 (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.mozilla.org/MPL/
- *
- * Software distributed under the License is distributed on an "AS IS" basis,
- * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- * for the specific language governing rights and limitations under the
- * License.
- *
- * The Original Code is guacamole-auth-noauth.
- *
- * The Initial Developer of the Original Code is
- * Laurent Meunier
- * Portions created by the Initial Developer are Copyright (C) 2010
- * the Initial Developer. All Rights Reserved.
+/*
+ * Copyright (C) 2013 Glyptodon LLC
  *
- * Contributor(s):
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
  *
- * Alternatively, the contents of this file may be used under the terms of
- * either the GNU General Public License Version 2 or later (the "GPL"), or
- * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
- * in which case the provisions of the GPL or the LGPL are applicable instead
- * of those above. If you wish to allow use of your version of this file only
- * under the terms of either the GPL or the LGPL, and not to allow others to
- * use your version of this file under the terms of the MPL, indicate your
- * decision by deleting the provisions above and replace them with the notice
- * and other provisions required by the GPL or the LGPL. If you do not delete
- * the provisions above, a recipient may use your version of this file under
- * the terms of any one of the MPL, the GPL or the LGPL.
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
  *
- * ***** END LICENSE BLOCK ***** */
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package net.sourceforge.guacamole.net.auth.noauth;
 
 import java.util.Map;
 import java.io.BufferedReader;
@@ -45,10 +30,11 @@ import java.io.IOException;
 import java.io.Reader;
 import org.glyptodon.guacamole.GuacamoleException;
 import org.glyptodon.guacamole.GuacamoleServerException;
+import org.glyptodon.guacamole.environment.Environment;
+import org.glyptodon.guacamole.environment.LocalEnvironment;
 import org.glyptodon.guacamole.net.auth.simple.SimpleAuthenticationProvider;
 import org.glyptodon.guacamole.net.auth.Credentials;
 import org.glyptodon.guacamole.properties.FileGuacamoleProperty;
-import org.glyptodon.guacamole.properties.GuacamoleProperties;
 import org.glyptodon.guacamole.protocol.GuacamoleConfiguration;
 import org.slf4j.LoggerFactory;
 import org.slf4j.Logger;
@@ -57,7 +43,6 @@ import org.xml.sax.SAXException;
 import org.xml.sax.XMLReader;
 import org.xml.sax.helpers.XMLReaderFactory;
 
-
 /**
  * Disable authentication in Guacamole. All users accessing Guacamole are
  * automatically authenticated as "Anonymous" user and are able to use all
@@ -69,7 +54,6 @@ import org.xml.sax.helpers.XMLReaderFactory;
  *
  * Example `guacamole.properties`:
  *
- *  auth-provider: net.sourceforge.guacamole.net.auth.noauth.NoAuthenticationProvider
  *  noauth-config: /etc/guacamole/noauth-config.xml
  *
  *
@@ -103,7 +87,12 @@ public class NoAuthenticationProvider extends SimpleAuthenticationProvider {
     private long configTime;
 
     /**
-     * The filename of the XML file to read the user mapping from.
+     * Guacamole server environment.
+     */
+    private final Environment environment;
+    
+    /**
+     * The XML file to read the configuration from.
      */
     public static final FileGuacamoleProperty NOAUTH_CONFIG = new FileGuacamoleProperty() {
 
@@ -115,6 +104,30 @@ public class NoAuthenticationProvider extends SimpleAuthenticationProvider {
     };
 
     /**
+     * The default filename to use for the configuration, if not defined within
+     * guacamole.properties.
+     */
+    public static final String DEFAULT_NOAUTH_CONFIG = "noauth-config.xml";
+
+    /**
+     * Creates a new NoAuthenticationProvider that does not perform any
+     * authentication at all. All attempts to access the Guacamole system are
+     * presumed to be authorized.
+     *
+     * @throws GuacamoleException
+     *     If a required property is missing, or an error occurs while parsing
+     *     a property.
+     */
+    public NoAuthenticationProvider() throws GuacamoleException {
+        environment = new LocalEnvironment();
+    }
+
+    @Override
+    public String getIdentifier() {
+        return "noauth";
+    }
+
+    /**
      * Retrieves the configuration file, as defined within guacamole.properties.
      *
      * @return The configuration file, as defined within guacamole.properties.
@@ -122,14 +135,21 @@ public class NoAuthenticationProvider extends SimpleAuthenticationProvider {
      *                            property.
      */
     private File getConfigurationFile() throws GuacamoleException {
-        return GuacamoleProperties.getRequiredProperty(NOAUTH_CONFIG);
+
+        // Get config file, defaulting to GUACAMOLE_HOME/noauth-config.xml
+        File configFile = environment.getProperty(NOAUTH_CONFIG);
+        if (configFile == null)
+            configFile = new File(environment.getGuacamoleHome(), DEFAULT_NOAUTH_CONFIG);
+
+        return configFile;
+
     }
 
     public synchronized void init() throws GuacamoleException {
 
         // Get configuration file
         File configFile = getConfigurationFile();
-        logger.info("Reading configuration file: {}", configFile);
+        logger.debug("Reading configuration file: \"{}\"", configFile);
 
         // Parse document
         try {
@@ -151,10 +171,10 @@ public class NoAuthenticationProvider extends SimpleAuthenticationProvider {
 
         }
         catch (IOException e) {
-            throw new GuacamoleServerException("Error reading configuration file: " + e.getMessage(), e);
+            throw new GuacamoleServerException("Error reading configuration file.", e);
         }
         catch (SAXException e) {
-            throw new GuacamoleServerException("Error parsing XML file: " + e.getMessage(), e);
+            throw new GuacamoleServerException("Error parsing XML file.", e);
         }
 
     }
@@ -169,7 +189,7 @@ public class NoAuthenticationProvider extends SimpleAuthenticationProvider {
             // If modified recently, gain exclusive access and recheck
             synchronized (this) {
                 if (configFile.exists() && configTime < configFile.lastModified()) {
-                    logger.info("Config file {} has been modified.", configFile);
+                    logger.debug("Configuration file \"{}\" has been modified.", configFile);
                     init(); // If still not up to date, re-init
                 }
             }
diff --git a/extensions/guacamole-auth-noauth/src/main/resources/guac-manifest.json b/extensions/guacamole-auth-noauth/src/main/resources/guac-manifest.json
new file mode 100644
index 0000000..c7d2bfa
--- /dev/null
+++ b/extensions/guacamole-auth-noauth/src/main/resources/guac-manifest.json
@@ -0,0 +1,16 @@
+{
+
+    "guacamoleVersion" : "0.9.9",
+
+    "name"      : "Disabled Authentication",
+    "namespace" : "guac-noauth",
+
+    "authProviders" : [
+        "net.sourceforge.guacamole.net.auth.noauth.NoAuthenticationProvider"
+    ],
+
+    "translations" : [
+        "translations/en.json"
+    ]
+
+}
diff --git a/extensions/guacamole-auth-noauth/src/main/resources/translations/en.json b/extensions/guacamole-auth-noauth/src/main/resources/translations/en.json
new file mode 100644
index 0000000..f755bd7
--- /dev/null
+++ b/extensions/guacamole-auth-noauth/src/main/resources/translations/en.json
@@ -0,0 +1,7 @@
+{
+
+    "DATA_SOURCE_NOAUTH" : {
+        "NAME" : "NoAuth"
+    }
+
+}
diff --git a/guacamole-common-js/LICENSE b/guacamole-common-js/LICENSE
index 7714141..540cdcf 100644
--- a/guacamole-common-js/LICENSE
+++ b/guacamole-common-js/LICENSE
@@ -1,470 +1,19 @@
-                          MOZILLA PUBLIC LICENSE
-                                Version 1.1
-
-                              ---------------
-
-1. Definitions.
-
-     1.0.1. "Commercial Use" means distribution or otherwise making the
-     Covered Code available to a third party.
-
-     1.1. "Contributor" means each entity that creates or contributes to
-     the creation of Modifications.
-
-     1.2. "Contributor Version" means the combination of the Original
-     Code, prior Modifications used by a Contributor, and the Modifications
-     made by that particular Contributor.
-
-     1.3. "Covered Code" means the Original Code or Modifications or the
-     combination of the Original Code and Modifications, in each case
-     including portions thereof.
-
-     1.4. "Electronic Distribution Mechanism" means a mechanism generally
-     accepted in the software development community for the electronic
-     transfer of data.
-
-     1.5. "Executable" means Covered Code in any form other than Source
-     Code.
-
-     1.6. "Initial Developer" means the individual or entity identified
-     as the Initial Developer in the Source Code notice required by Exhibit
-     A.
-
-     1.7. "Larger Work" means a work which combines Covered Code or
-     portions thereof with code not governed by the terms of this License.
-
-     1.8. "License" means this document.
-
-     1.8.1. "Licensable" means having the right to grant, to the maximum
-     extent possible, whether at the time of the initial grant or
-     subsequently acquired, any and all of the rights conveyed herein.
-
-     1.9. "Modifications" means any addition to or deletion from the
-     substance or structure of either the Original Code or any previous
-     Modifications. When Covered Code is released as a series of files, a
-     Modification is:
-          A. Any addition to or deletion from the contents of a file
-          containing Original Code or previous Modifications.
-
-          B. Any new file that contains any part of the Original Code or
-          previous Modifications.
-
-     1.10. "Original Code" means Source Code of computer software code
-     which is described in the Source Code notice required by Exhibit A as
-     Original Code, and which, at the time of its release under this
-     License is not already Covered Code governed by this License.
-
-     1.10.1. "Patent Claims" means any patent claim(s), now owned or
-     hereafter acquired, including without limitation,  method, process,
-     and apparatus claims, in any patent Licensable by grantor.
-
-     1.11. "Source Code" means the preferred form of the Covered Code for
-     making modifications to it, including all modules it contains, plus
-     any associated interface definition files, scripts used to control
-     compilation and installation of an Executable, or source code
-     differential comparisons against either the Original Code or another
-     well known, available Covered Code of the Contributor's choice. The
-     Source Code can be in a compressed or archival form, provided the
-     appropriate decompression or de-archiving software is widely available
-     for no charge.
-
-     1.12. "You" (or "Your")  means an individual or a legal entity
-     exercising rights under, and complying with all of the terms of, this
-     License or a future version of this License issued under Section 6.1.
-     For legal entities, "You" includes any entity which controls, is
-     controlled by, or is under common control with You. For purposes of
-     this definition, "control" means (a) the power, direct or indirect,
-     to cause the direction or management of such entity, whether by
-     contract or otherwise, or (b) ownership of more than fifty percent
-     (50%) of the outstanding shares or beneficial ownership of such
-     entity.
-
-2. Source Code License.
-
-     2.1. The Initial Developer Grant.
-     The Initial Developer hereby grants You a world-wide, royalty-free,
-     non-exclusive license, subject to third party intellectual property
-     claims:
-          (a)  under intellectual property rights (other than patent or
-          trademark) Licensable by Initial Developer to use, reproduce,
-          modify, display, perform, sublicense and distribute the Original
-          Code (or portions thereof) with or without Modifications, and/or
-          as part of a Larger Work; and
-
-          (b) under Patents Claims infringed by the making, using or
-          selling of Original Code, to make, have made, use, practice,
-          sell, and offer for sale, and/or otherwise dispose of the
-          Original Code (or portions thereof).
-
-          (c) the licenses granted in this Section 2.1(a) and (b) are
-          effective on the date Initial Developer first distributes
-          Original Code under the terms of this License.
-
-          (d) Notwithstanding Section 2.1(b) above, no patent license is
-          granted: 1) for code that You delete from the Original Code; 2)
-          separate from the Original Code;  or 3) for infringements caused
-          by: i) the modification of the Original Code or ii) the
-          combination of the Original Code with other software or devices.
-
-     2.2. Contributor Grant.
-     Subject to third party intellectual property claims, each Contributor
-     hereby grants You a world-wide, royalty-free, non-exclusive license
-
-          (a)  under intellectual property rights (other than patent or
-          trademark) Licensable by Contributor, to use, reproduce, modify,
-          display, perform, sublicense and distribute the Modifications
-          created by such Contributor (or portions thereof) either on an
-          unmodified basis, with other Modifications, as Covered Code
-          and/or as part of a Larger Work; and
-
-          (b) under Patent Claims infringed by the making, using, or
-          selling of  Modifications made by that Contributor either alone
-          and/or in combination with its Contributor Version (or portions
-          of such combination), to make, use, sell, offer for sale, have
-          made, and/or otherwise dispose of: 1) Modifications made by that
-          Contributor (or portions thereof); and 2) the combination of
-          Modifications made by that Contributor with its Contributor
-          Version (or portions of such combination).
-
-          (c) the licenses granted in Sections 2.2(a) and 2.2(b) are
-          effective on the date Contributor first makes Commercial Use of
-          the Covered Code.
-
-          (d)    Notwithstanding Section 2.2(b) above, no patent license is
-          granted: 1) for any code that Contributor has deleted from the
-          Contributor Version; 2)  separate from the Contributor Version;
-          3)  for infringements caused by: i) third party modifications of
-          Contributor Version or ii)  the combination of Modifications made
-          by that Contributor with other software  (except as part of the
-          Contributor Version) or other devices; or 4) under Patent Claims
-          infringed by Covered Code in the absence of Modifications made by
-          that Contributor.
-
-3. Distribution Obligations.
-
-     3.1. Application of License.
-     The Modifications which You create or to which You contribute are
-     governed by the terms of this License, including without limitation
-     Section 2.2. The Source Code version of Covered Code may be
-     distributed only under the terms of this License or a future version
-     of this License released under Section 6.1, and You must include a
-     copy of this License with every copy of the Source Code You
-     distribute. You may not offer or impose any terms on any Source Code
-     version that alters or restricts the applicable version of this
-     License or the recipients' rights hereunder. However, You may include
-     an additional document offering the additional rights described in
-     Section 3.5.
-
-     3.2. Availability of Source Code.
-     Any Modification which You create or to which You contribute must be
-     made available in Source Code form under the terms of this License
-     either on the same media as an Executable version or via an accepted
-     Electronic Distribution Mechanism to anyone to whom you made an
-     Executable version available; and if made available via Electronic
-     Distribution Mechanism, must remain available for at least twelve (12)
-     months after the date it initially became available, or at least six
-     (6) months after a subsequent version of that particular Modification
-     has been made available to such recipients. You are responsible for
-     ensuring that the Source Code version remains available even if the
-     Electronic Distribution Mechanism is maintained by a third party.
-
-     3.3. Description of Modifications.
-     You must cause all Covered Code to which You contribute to contain a
-     file documenting the changes You made to create that Covered Code and
-     the date of any change. You must include a prominent statement that
-     the Modification is derived, directly or indirectly, from Original
-     Code provided by the Initial Developer and including the name of the
-     Initial Developer in (a) the Source Code, and (b) in any notice in an
-     Executable version or related documentation in which You describe the
-     origin or ownership of the Covered Code.
-
-     3.4. Intellectual Property Matters
-          (a) Third Party Claims.
-          If Contributor has knowledge that a license under a third party's
-          intellectual property rights is required to exercise the rights
-          granted by such Contributor under Sections 2.1 or 2.2,
-          Contributor must include a text file with the Source Code
-          distribution titled "LEGAL" which describes the claim and the
-          party making the claim in sufficient detail that a recipient will
-          know whom to contact. If Contributor obtains such knowledge after
-          the Modification is made available as described in Section 3.2,
-          Contributor shall promptly modify the LEGAL file in all copies
-          Contributor makes available thereafter and shall take other steps
-          (such as notifying appropriate mailing lists or newsgroups)
-          reasonably calculated to inform those who received the Covered
-          Code that new knowledge has been obtained.
-
-          (b) Contributor APIs.
-          If Contributor's Modifications include an application programming
-          interface and Contributor has knowledge of patent licenses which
-          are reasonably necessary to implement that API, Contributor must
-          also include this information in the LEGAL file.
-
-               (c)    Representations.
-          Contributor represents that, except as disclosed pursuant to
-          Section 3.4(a) above, Contributor believes that Contributor's
-          Modifications are Contributor's original creation(s) and/or
-          Contributor has sufficient rights to grant the rights conveyed by
-          this License.
-
-     3.5. Required Notices.
-     You must duplicate the notice in Exhibit A in each file of the Source
-     Code.  If it is not possible to put such notice in a particular Source
-     Code file due to its structure, then You must include such notice in a
-     location (such as a relevant directory) where a user would be likely
-     to look for such a notice.  If You created one or more Modification(s)
-     You may add your name as a Contributor to the notice described in
-     Exhibit A.  You must also duplicate this License in any documentation
-     for the Source Code where You describe recipients' rights or ownership
-     rights relating to Covered Code.  You may choose to offer, and to
-     charge a fee for, warranty, support, indemnity or liability
-     obligations to one or more recipients of Covered Code. However, You
-     may do so only on Your own behalf, and not on behalf of the Initial
-     Developer or any Contributor. You must make it absolutely clear than
-     any such warranty, support, indemnity or liability obligation is
-     offered by You alone, and You hereby agree to indemnify the Initial
-     Developer and every Contributor for any liability incurred by the
-     Initial Developer or such Contributor as a result of warranty,
-     support, indemnity or liability terms You offer.
-
-     3.6. Distribution of Executable Versions.
-     You may distribute Covered Code in Executable form only if the
-     requirements of Section 3.1-3.5 have been met for that Covered Code,
-     and if You include a notice stating that the Source Code version of
-     the Covered Code is available under the terms of this License,
-     including a description of how and where You have fulfilled the
-     obligations of Section 3.2. The notice must be conspicuously included
-     in any notice in an Executable version, related documentation or
-     collateral in which You describe recipients' rights relating to the
-     Covered Code. You may distribute the Executable version of Covered
-     Code or ownership rights under a license of Your choice, which may
-     contain terms different from this License, provided that You are in
-     compliance with the terms of this License and that the license for the
-     Executable version does not attempt to limit or alter the recipient's
-     rights in the Source Code version from the rights set forth in this
-     License. If You distribute the Executable version under a different
-     license You must make it absolutely clear that any terms which differ
-     from this License are offered by You alone, not by the Initial
-     Developer or any Contributor. You hereby agree to indemnify the
-     Initial Developer and every Contributor for any liability incurred by
-     the Initial Developer or such Contributor as a result of any such
-     terms You offer.
-
-     3.7. Larger Works.
-     You may create a Larger Work by combining Covered Code with other code
-     not governed by the terms of this License and distribute the Larger
-     Work as a single product. In such a case, You must make sure the
-     requirements of this License are fulfilled for the Covered Code.
-
-4. Inability to Comply Due to Statute or Regulation.
-
-     If it is impossible for You to comply with any of the terms of this
-     License with respect to some or all of the Covered Code due to
-     statute, judicial order, or regulation then You must: (a) comply with
-     the terms of this License to the maximum extent possible; and (b)
-     describe the limitations and the code they affect. Such description
-     must be included in the LEGAL file described in Section 3.4 and must
-     be included with all distributions of the Source Code. Except to the
-     extent prohibited by statute or regulation, such description must be
-     sufficiently detailed for a recipient of ordinary skill to be able to
-     understand it.
-
-5. Application of this License.
-
-     This License applies to code to which the Initial Developer has
-     attached the notice in Exhibit A and to related Covered Code.
-
-6. Versions of the License.
-
-     6.1. New Versions.
-     Netscape Communications Corporation ("Netscape") may publish revised
-     and/or new versions of the License from time to time. Each version
-     will be given a distinguishing version number.
-
-     6.2. Effect of New Versions.
-     Once Covered Code has been published under a particular version of the
-     License, You may always continue to use it under the terms of that
-     version. You may also choose to use such Covered Code under the terms
-     of any subsequent version of the License published by Netscape. No one
-     other than Netscape has the right to modify the terms applicable to
-     Covered Code created under this License.
-
-     6.3. Derivative Works.
-     If You create or use a modified version of this License (which you may
-     only do in order to apply it to code which is not already Covered Code
-     governed by this License), You must (a) rename Your license so that
-     the phrases "Mozilla", "MOZILLAPL", "MOZPL", "Netscape",
-     "MPL", "NPL" or any confusingly similar phrase do not appear in your
-     license (except to note that your license differs from this License)
-     and (b) otherwise make it clear that Your version of the license
-     contains terms which differ from the Mozilla Public License and
-     Netscape Public License. (Filling in the name of the Initial
-     Developer, Original Code or Contributor in the notice described in
-     Exhibit A shall not of themselves be deemed to be modifications of
-     this License.)
-
-7. DISCLAIMER OF WARRANTY.
-
-     COVERED CODE IS PROVIDED UNDER THIS LICENSE ON AN "AS IS" BASIS,
-     WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING,
-     WITHOUT LIMITATION, WARRANTIES THAT THE COVERED CODE IS FREE OF
-     DEFECTS, MERCHANTABLE, FIT FOR A PARTICULAR PURPOSE OR NON-INFRINGING.
-     THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE COVERED CODE
-     IS WITH YOU. SHOULD ANY COVERED CODE PROVE DEFECTIVE IN ANY RESPECT,
-     YOU (NOT THE INITIAL DEVELOPER OR ANY OTHER CONTRIBUTOR) ASSUME THE
-     COST OF ANY NECESSARY SERVICING, REPAIR OR CORRECTION. THIS DISCLAIMER
-     OF WARRANTY CONSTITUTES AN ESSENTIAL PART OF THIS LICENSE. NO USE OF
-     ANY COVERED CODE IS AUTHORIZED HEREUNDER EXCEPT UNDER THIS DISCLAIMER.
-
-8. TERMINATION.
-
-     8.1.  This License and the rights granted hereunder will terminate
-     automatically if You fail to comply with terms herein and fail to cure
-     such breach within 30 days of becoming aware of the breach. All
-     sublicenses to the Covered Code which are properly granted shall
-     survive any termination of this License. Provisions which, by their
-     nature, must remain in effect beyond the termination of this License
-     shall survive.
-
-     8.2.  If You initiate litigation by asserting a patent infringement
-     claim (excluding declatory judgment actions) against Initial Developer
-     or a Contributor (the Initial Developer or Contributor against whom
-     You file such action is referred to as "Participant")  alleging that:
-
-     (a)  such Participant's Contributor Version directly or indirectly
-     infringes any patent, then any and all rights granted by such
-     Participant to You under Sections 2.1 and/or 2.2 of this License
-     shall, upon 60 days notice from Participant terminate prospectively,
-     unless if within 60 days after receipt of notice You either: (i)
-     agree in writing to pay Participant a mutually agreeable reasonable
-     royalty for Your past and future use of Modifications made by such
-     Participant, or (ii) withdraw Your litigation claim with respect to
-     the Contributor Version against such Participant.  If within 60 days
-     of notice, a reasonable royalty and payment arrangement are not
-     mutually agreed upon in writing by the parties or the litigation claim
-     is not withdrawn, the rights granted by Participant to You under
-     Sections 2.1 and/or 2.2 automatically terminate at the expiration of
-     the 60 day notice period specified above.
-
-     (b)  any software, hardware, or device, other than such Participant's
-     Contributor Version, directly or indirectly infringes any patent, then
-     any rights granted to You by such Participant under Sections 2.1(b)
-     and 2.2(b) are revoked effective as of the date You first made, used,
-     sold, distributed, or had made, Modifications made by that
-     Participant.
-
-     8.3.  If You assert a patent infringement claim against Participant
-     alleging that such Participant's Contributor Version directly or
-     indirectly infringes any patent where such claim is resolved (such as
-     by license or settlement) prior to the initiation of patent
-     infringement litigation, then the reasonable value of the licenses
-     granted by such Participant under Sections 2.1 or 2.2 shall be taken
-     into account in determining the amount or value of any payment or
-     license.
-
-     8.4.  In the event of termination under Sections 8.1 or 8.2 above,
-     all end user license agreements (excluding distributors and resellers)
-     which have been validly granted by You or any distributor hereunder
-     prior to termination shall survive termination.
-
-9. LIMITATION OF LIABILITY.
-
-     UNDER NO CIRCUMSTANCES AND UNDER NO LEGAL THEORY, WHETHER TORT
-     (INCLUDING NEGLIGENCE), CONTRACT, OR OTHERWISE, SHALL YOU, THE INITIAL
-     DEVELOPER, ANY OTHER CONTRIBUTOR, OR ANY DISTRIBUTOR OF COVERED CODE,
-     OR ANY SUPPLIER OF ANY OF SUCH PARTIES, BE LIABLE TO ANY PERSON FOR
-     ANY INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES OF ANY
-     CHARACTER INCLUDING, WITHOUT LIMITATION, DAMAGES FOR LOSS OF GOODWILL,
-     WORK STOPPAGE, COMPUTER FAILURE OR MALFUNCTION, OR ANY AND ALL OTHER
-     COMMERCIAL DAMAGES OR LOSSES, EVEN IF SUCH PARTY SHALL HAVE BEEN
-     INFORMED OF THE POSSIBILITY OF SUCH DAMAGES. THIS LIMITATION OF
-     LIABILITY SHALL NOT APPLY TO LIABILITY FOR DEATH OR PERSONAL INJURY
-     RESULTING FROM SUCH PARTY'S NEGLIGENCE TO THE EXTENT APPLICABLE LAW
-     PROHIBITS SUCH LIMITATION. SOME JURISDICTIONS DO NOT ALLOW THE
-     EXCLUSION OR LIMITATION OF INCIDENTAL OR CONSEQUENTIAL DAMAGES, SO
-     THIS EXCLUSION AND LIMITATION MAY NOT APPLY TO YOU.
-
-10. U.S. GOVERNMENT END USERS.
-
-     The Covered Code is a "commercial item," as that term is defined in
-     48 C.F.R. 2.101 (Oct. 1995), consisting of "commercial computer
-     software" and "commercial computer software documentation," as such
-     terms are used in 48 C.F.R. 12.212 (Sept. 1995). Consistent with 48
-     C.F.R. 12.212 and 48 C.F.R. 227.7202-1 through 227.7202-4 (June 1995),
-     all U.S. Government End Users acquire Covered Code with only those
-     rights set forth herein.
-
-11. MISCELLANEOUS.
-
-     This License represents the complete agreement concerning subject
-     matter hereof. If any provision of this License is held to be
-     unenforceable, such provision shall be reformed only to the extent
-     necessary to make it enforceable. This License shall be governed by
-     California law provisions (except to the extent applicable law, if
-     any, provides otherwise), excluding its conflict-of-law provisions.
-     With respect to disputes in which at least one party is a citizen of,
-     or an entity chartered or registered to do business in the United
-     States of America, any litigation relating to this License shall be
-     subject to the jurisdiction of the Federal Courts of the Northern
-     District of California, with venue lying in Santa Clara County,
-     California, with the losing party responsible for costs, including
-     without limitation, court costs and reasonable attorneys' fees and
-     expenses. The application of the United Nations Convention on
-     Contracts for the International Sale of Goods is expressly excluded.
-     Any law or regulation which provides that the language of a contract
-     shall be construed against the drafter shall not apply to this
-     License.
-
-12. RESPONSIBILITY FOR CLAIMS.
-
-     As between Initial Developer and the Contributors, each party is
-     responsible for claims and damages arising, directly or indirectly,
-     out of its utilization of rights under this License and You agree to
-     work with Initial Developer and Contributors to distribute such
-     responsibility on an equitable basis. Nothing herein is intended or
-     shall be deemed to constitute any admission of liability.
-
-13. MULTIPLE-LICENSED CODE.
-
-     Initial Developer may designate portions of the Covered Code as
-     "Multiple-Licensed".  "Multiple-Licensed" means that the Initial
-     Developer permits you to utilize portions of the Covered Code under
-     Your choice of the NPL or the alternative licenses, if any, specified
-     by the Initial Developer in the file described in Exhibit A.
-
-EXHIBIT A -Mozilla Public License.
-
-     ``The contents of this file are subject to the Mozilla Public License
-     Version 1.1 (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.mozilla.org/MPL/
-
-     Software distributed under the License is distributed on an "AS IS"
-     basis, WITHOUT WARRANTY OF ANY KIND, either express or implied. See the
-     License for the specific language governing rights and limitations
-     under the License.
-
-     The Original Code is ______________________________________.
-
-     The Initial Developer of the Original Code is ________________________.
-     Portions created by ______________________ are Copyright (C) ______
-     _______________________. All Rights Reserved.
-
-     Contributor(s): ______________________________________.
-
-     Alternatively, the contents of this file may be used under the terms
-     of the _____ license (the  "[___] License"), in which case the
-     provisions of [______] License are applicable instead of those
-     above.  If you wish to allow use of your version of this file only
-     under the terms of the [____] License and not to allow others to use
-     your version of this file under the MPL, indicate your decision by
-     deleting  the provisions above and replace  them with the notice and
-     other provisions required by the [___] License.  If you do not delete
-     the provisions above, a recipient may use your version of this file
-     under either the MPL or the [___] License."
-
-     [NOTE: The text of this Exhibit A may differ slightly from the text of
-     the notices in the Source Code files of the Original Code. You should
-     use the text of this Exhibit A rather than the text found in the
-     Original Code Source Code for Your Modifications.]
-
+Copyright (C) 2013 Glyptodon LLC
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/guacamole-common-js/jsdoc-conf.json b/guacamole-common-js/jsdoc-conf.json
new file mode 100644
index 0000000..afa0ef8
--- /dev/null
+++ b/guacamole-common-js/jsdoc-conf.json
@@ -0,0 +1,9 @@
+{
+    "source" : {
+        "include" : "src"
+    },
+    "opts" : {
+        "recurse" : true,
+        "destination" : "target/site/jsdoc"
+    }
+}
diff --git a/guacamole-common-js/pom.xml b/guacamole-common-js/pom.xml
index 2ede1ea..a199661 100644
--- a/guacamole-common-js/pom.xml
+++ b/guacamole-common-js/pom.xml
@@ -5,7 +5,7 @@
     <groupId>org.glyptodon.guacamole</groupId>
     <artifactId>guacamole-common-js</artifactId>
     <packaging>pom</packaging>
-    <version>0.7.4</version>
+    <version>0.9.9</version>
     <name>guacamole-common-js</name>
     <url>http://guac-dev.org/</url>
 
@@ -18,18 +18,8 @@
     <!-- All applicable licenses -->
     <licenses>
         <license>
-            <name>Mozilla Public License Version 1.1</name>
-            <url>http://www.mozilla.org/MPL/1.1/</url>
-            <distribution>repo</distribution>
-        </license>
-        <license>
-            <name>GNU General Public License, version 2</name>
-            <url>http://www.gnu.org/licenses/gpl-2.0.html</url>
-            <distribution>repo</distribution>
-        </license>
-        <license>
-            <name>GNU Lesser General Public License, version 2.1</name>
-            <url>http://www.gnu.org/licenses/lgpl-2.1.html</url>
+            <name>The MIT License</name>
+            <url>http://www.opensource.org/licenses/mit-license.php</url>
             <distribution>repo</distribution>
         </license>
     </licenses>
@@ -59,11 +49,12 @@
     </properties>
 
     <build>
-
         <plugins>
+
+            <!-- Assemble JS files into single .zip -->
             <plugin>
                 <artifactId>maven-assembly-plugin</artifactId>
-                <version>2.4</version>
+                <version>2.5.3</version>
                 <configuration>
                     <appendAssemblyId>false</appendAssemblyId>
                     <descriptors>
@@ -80,8 +71,41 @@
                     </execution>
                 </executions>
             </plugin>
-        </plugins>
 
+            <!-- JS/CSS Minification Plugin -->
+            <plugin>
+                <groupId>com.samaxes.maven</groupId>
+                <artifactId>minify-maven-plugin</artifactId>
+                <version>1.6.1</version>
+                <executions>
+                    <execution>
+                        <id>default-minify</id>
+                        <configuration>
+
+                            <charset>UTF-8</charset>
+                            <jsEngine>CLOSURE</jsEngine>
+
+                            <jsSourceDir>/</jsSourceDir>
+                            <jsTargetDir>/</jsTargetDir>
+                            <jsFinalFile>all.js</jsFinalFile>
+
+                            <jsSourceFiles>
+                                <jsSourceFile>common/license.js</jsSourceFile> 
+                            </jsSourceFiles>
+
+                            <jsSourceIncludes>
+                                <jsSourceInclude>modules/**/*.js</jsSourceInclude>
+                            </jsSourceIncludes>
+
+                        </configuration>
+                        <goals>
+                            <goal>minify</goal>
+                        </goals>
+                    </execution>
+                </executions>
+            </plugin>
+
+        </plugins>
     </build>
 
 </project>
diff --git a/guacamole-common-js/src/main/resources/audio.js b/guacamole-common-js/src/main/resources/audio.js
deleted file mode 100644
index 8a3243c..0000000
--- a/guacamole-common-js/src/main/resources/audio.js
+++ /dev/null
@@ -1,228 +0,0 @@
-
-/* ***** BEGIN LICENSE BLOCK *****
- * Version: MPL 1.1/GPL 2.0/LGPL 2.1
- *
- * The contents of this file are subject to the Mozilla Public License Version
- * 1.1 (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.mozilla.org/MPL/
- *
- * Software distributed under the License is distributed on an "AS IS" basis,
- * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- * for the specific language governing rights and limitations under the
- * License.
- *
- * The Original Code is guacamole-common-js.
- *
- * The Initial Developer of the Original Code is
- * Michael Jumper.
- * Portions created by the Initial Developer are Copyright (C) 2010
- * the Initial Developer. All Rights Reserved.
- *
- * Contributor(s):
- *
- * Alternatively, the contents of this file may be used under the terms of
- * either the GNU General Public License Version 2 or later (the "GPL"), or
- * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
- * in which case the provisions of the GPL or the LGPL are applicable instead
- * of those above. If you wish to allow use of your version of this file only
- * under the terms of either the GPL or the LGPL, and not to allow others to
- * use your version of this file under the terms of the MPL, indicate your
- * decision by deleting the provisions above and replace them with the notice
- * and other provisions required by the GPL or the LGPL. If you do not delete
- * the provisions above, a recipient may use your version of this file under
- * the terms of any one of the MPL, the GPL or the LGPL.
- *
- * ***** END LICENSE BLOCK ***** */
-
-/**
- * Namespace for all Guacamole JavaScript objects.
- * @namespace
- */
-var Guacamole = Guacamole || {};
-
-/**
- * Abstract audio channel which queues and plays arbitrary audio data.
- * @constructor
- */
-Guacamole.AudioChannel = function() {
-
-    /**
-     * Reference to this AudioChannel.
-     * @private
-     */
-    var channel = this;
-
-    /**
-     * When the next packet should play.
-     * @private
-     */
-    var next_packet_time = 0;
-
-    /**
-     * Queues up the given data for playing by this channel once all previously
-     * queued data has been played. If no data has been queued, the data will
-     * play immediately.
-     * 
-     * @param {String} mimetype The mimetype of the data provided.
-     * @param {Number} duration The duration of the data provided, in
-     *                          milliseconds.
-     * @param {String} data The base64-encoded data to play.
-     */
-    this.play = function(mimetype, duration, data) {
-
-        var packet =
-            new Guacamole.AudioChannel.Packet(mimetype, data);
-
-        var now = Guacamole.AudioChannel.getTimestamp();
-
-        // If underflow is detected, reschedule new packets relative to now.
-        if (next_packet_time < now)
-            next_packet_time = now;
-
-        // Schedule next packet
-        packet.play(next_packet_time);
-        next_packet_time += duration;
-
-    };
-
-};
-
-// Define context if available
-if (window.webkitAudioContext) {
-    Guacamole.AudioChannel.context = new webkitAudioContext();
-}
-
-/**
- * Returns a base timestamp which can be used for scheduling future audio
- * playback. Scheduling playback for the value returned by this function plus
- * N will cause the associated audio to be played back N milliseconds after
- * the function is called.
- *
- * @return {Number} An arbitrary channel-relative timestamp, in milliseconds.
- */
-Guacamole.AudioChannel.getTimestamp = function() {
-
-    // If we have an audio context, use its timestamp
-    if (Guacamole.AudioChannel.context)
-        return Guacamole.AudioChannel.context.currentTime * 1000;
-
-    // If we have high-resolution timers, use those
-    if (window.performance) {
-
-        if (window.performance.now)
-            return window.performance.now();
-
-        if (window.performance.webkitNow)
-            return window.performance.webkitNow();
-        
-    }
-
-    // Fallback to millisecond-resolution system time
-    return new Date().getTime();
-
-};
-
-/**
- * Abstract representation of an audio packet.
- * 
- * @constructor
- * 
- * @param {String} mimetype The mimetype of the data contained by this packet.
- * @param {String} data The base64-encoded sound data contained by this packet.
- */
-Guacamole.AudioChannel.Packet = function(mimetype, data) {
-
-    /**
-     * Schedules this packet for playback at the given time.
-     *
-     * @function
-     * @param {Number} when The time this packet should be played, in
-     *                      milliseconds.
-     */
-    this.play = undefined; // Defined conditionally depending on support
-
-    // If audio API available, use it.
-    if (Guacamole.AudioChannel.context) {
-
-        var readyBuffer = null;
-
-        // By default, when decoding finishes, store buffer for future
-        // playback
-        var handleReady = function(buffer) {
-            readyBuffer = buffer;
-        };
-
-        // Convert to ArrayBuffer
-        var binary = window.atob(data);
-        var arrayBuffer = new ArrayBuffer(binary.length);
-        var bufferView = new Uint8Array(arrayBuffer);
-
-        for (var i=0; i<binary.length; i++)
-            bufferView[i] = binary.charCodeAt(i);
-
-        // Get context and start decoding
-        Guacamole.AudioChannel.context.decodeAudioData(
-            arrayBuffer,
-            function(buffer) { handleReady(buffer); }
-        );
-
-        // Set up buffer source
-        var source = Guacamole.AudioChannel.context.createBufferSource();
-        source.connect(Guacamole.AudioChannel.context.destination);
-
-        var play_when;
-
-        function playDelayed(buffer) {
-            source.buffer = buffer;
-            source.noteOn(play_when / 1000);
-        }
-
-        /** @ignore */
-        this.play = function(when) {
-            
-            play_when = when;
-            
-            // If buffer available, play it NOW
-            if (readyBuffer)
-                playDelayed(readyBuffer);
-
-            // Otherwise, play when decoded
-            else
-                handleReady = playDelayed;
-
-        };
-
-    }
-
-    else {
-
-        // Build data URI
-        var data_uri = "data:" + mimetype + ";base64," + data;
-       
-        // Create audio element to house and play the data
-        var audio = new Audio();
-        audio.src = data_uri;
-      
-        /** @ignore */
-        this.play = function(when) {
-            
-            // Calculate time until play
-            var now = Guacamole.AudioChannel.getTimestamp();
-            var delay = when - now;
-            
-            // Play now if too late
-            if (delay < 0)
-                audio.play();
-
-            // Otherwise, schedule later playback
-            else
-                window.setTimeout(function() {
-                    audio.play();
-                }, delay);
-
-        };
-
-    }
-
-};
diff --git a/guacamole-common-js/src/main/resources/guacamole.js b/guacamole-common-js/src/main/resources/guacamole.js
deleted file mode 100644
index 01765a1..0000000
--- a/guacamole-common-js/src/main/resources/guacamole.js
+++ /dev/null
@@ -1,1662 +0,0 @@
-
-/* ***** BEGIN LICENSE BLOCK *****
- * Version: MPL 1.1/GPL 2.0/LGPL 2.1
- *
- * The contents of this file are subject to the Mozilla Public License Version
- * 1.1 (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.mozilla.org/MPL/
- *
- * Software distributed under the License is distributed on an "AS IS" basis,
- * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- * for the specific language governing rights and limitations under the
- * License.
- *
- * The Original Code is guacamole-common-js.
- *
- * The Initial Developer of the Original Code is
- * Michael Jumper.
- * Portions created by the Initial Developer are Copyright (C) 2010
- * the Initial Developer. All Rights Reserved.
- *
- * Contributor(s):
- * Matt Hortman
- *
- * Alternatively, the contents of this file may be used under the terms of
- * either the GNU General Public License Version 2 or later (the "GPL"), or
- * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
- * in which case the provisions of the GPL or the LGPL are applicable instead
- * of those above. If you wish to allow use of your version of this file only
- * under the terms of either the GPL or the LGPL, and not to allow others to
- * use your version of this file under the terms of the MPL, indicate your
- * decision by deleting the provisions above and replace them with the notice
- * and other provisions required by the GPL or the LGPL. If you do not delete
- * the provisions above, a recipient may use your version of this file under
- * the terms of any one of the MPL, the GPL or the LGPL.
- *
- * ***** END LICENSE BLOCK ***** */
-
-/**
- * Namespace for all Guacamole JavaScript objects.
- * @namespace
- */
-var Guacamole = Guacamole || {};
-
-/**
- * Simple Guacamole protocol parser that invokes an oninstruction event when
- * full instructions are available from data received via receive().
- * 
- * @constructor
- */
-Guacamole.Parser = function() {
-
-    /**
-     * Reference to this parser.
-     * @private
-     */
-    var parser = this;
-
-    /**
-     * Current buffer of received data. This buffer grows until a full
-     * element is available. After a full element is available, that element
-     * is flushed into the element buffer.
-     * 
-     * @private
-     */
-    var buffer = "";
-
-    /**
-     * Buffer of all received, complete elements. After an entire instruction
-     * is read, this buffer is flushed, and a new instruction begins.
-     * 
-     * @private
-     */
-    var element_buffer = [];
-
-    // The location of the last element's terminator
-    var element_end = -1;
-
-    // Where to start the next length search or the next element
-    var start_index = 0;
-
-    /**
-     * Appends the given instruction data packet to the internal buffer of
-     * this Guacamole.Parser, executing all completed instructions at
-     * the beginning of this buffer, if any.
-     *
-     * @param {String} packet The instruction data to append.
-     */
-    this.receive = function(packet) {
-
-        // Truncate buffer as necessary
-        if (start_index > 4096 && element_end >= start_index) {
-
-            buffer = buffer.substring(start_index);
-
-            // Reset parse relative to truncation
-            element_end -= start_index;
-            start_index = 0;
-
-        }
-
-        // Append data to buffer
-        buffer += packet;
-
-        // While search is within currently received data
-        while (element_end < buffer.length) {
-
-            // If we are waiting for element data
-            if (element_end >= start_index) {
-
-                // We now have enough data for the element. Parse.
-                var element = buffer.substring(start_index, element_end);
-                var terminator = buffer.substring(element_end, element_end+1);
-
-                // Add element to array
-                element_buffer.push(element);
-
-                // If last element, handle instruction
-                if (terminator == ";") {
-
-                    // Get opcode
-                    var opcode = element_buffer.shift();
-
-                    // Call instruction handler.
-                    if (parser.oninstruction != null)
-                        parser.oninstruction(opcode, element_buffer);
-
-                    // Clear elements
-                    element_buffer.length = 0;
-
-                }
-                else if (terminator != ',')
-                    throw new Error("Illegal terminator.");
-
-                // Start searching for length at character after
-                // element terminator
-                start_index = element_end + 1;
-
-            }
-
-            // Search for end of length
-            var length_end = buffer.indexOf(".", start_index);
-            if (length_end != -1) {
-
-                // Parse length
-                var length = parseInt(buffer.substring(element_end+1, length_end));
-                if (length == NaN)
-                    throw new Error("Non-numeric character in element length.");
-
-                // Calculate start of element
-                start_index = length_end + 1;
-
-                // Calculate location of element terminator
-                element_end = start_index + length;
-
-            }
-            
-            // If no period yet, continue search when more data
-            // is received
-            else {
-                start_index = buffer.length;
-                break;
-            }
-
-        } // end parse loop
-
-    };
-
-    /**
-     * 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;
-
-};
-
-
-/**
- * A blob abstraction used by the Guacamole client to facilitate transfer of
- * files or other binary data.
- * 
- * @constructor
- * @param {String} mimetype The mimetype of the data this blob will contain.
- * @param {String} name An arbitrary name for this blob.
- */
-Guacamole.Blob = function(mimetype, name) {
-
-    /**
-     * Reference to this Guacamole.Blob.
-     * @private
-     */
-    var guac_blob = this;
-
-    /**
-     * The length of this Guacamole.Blob in bytes.
-     * @private
-     */
-    var length = 0;
-
-    /**
-     * The mimetype of the data contained within this blob.
-     */
-    this.mimetype = mimetype;
-
-    /**
-     * The name of this blob. In general, this should be an appropriate
-     * filename.
-     */
-    this.name = name;
-
-    // Get blob builder
-    var blob_builder;
-    if      (window.BlobBuilder)       blob_builder = new BlobBuilder();
-    else if (window.WebKitBlobBuilder) blob_builder = new WebKitBlobBuilder();
-    else if (window.MozBlobBuilder)    blob_builder = new MozBlobBuilder();
-    else
-        blob_builder = new (function() {
-
-            var blobs = [];
-
-            this.append = function(data) {
-                blobs.push(new Blob([data], {"type": mimetype}));
-            };
-
-            this.getBlob = function() {
-                return new Blob(blobs, {"type": mimetype});
-            };
-
-        })();
-
-    /**
-     * Appends the given ArrayBuffer to this Guacamole.Blob.
-     * 
-     * @param {ArrayBuffer} buffer An ArrayBuffer containing the data to be
-     *                             appended.
-     */
-    this.append = function(buffer) {
-
-        blob_builder.append(buffer);
-        length += buffer.byteLength;
-
-        // Call handler, if present
-        if (guac_blob.ondata)
-            guac_blob.ondata(buffer.byteLength);
-
-    };
-
-    /**
-     * Closes this Guacamole.Blob such that no further data will be written.
-     */
-    this.close = function() {
-
-        // Call handler, if present
-        if (guac_blob.oncomplete)
-            guac_blob.oncomplete();
-
-        // NOTE: Currently not enforced.
-
-    };
-
-    /**
-     * Returns the current length of this Guacamole.Blob, in bytes.
-     * @return {Number} The current length of this Guacamole.Blob.
-     */
-    this.getLength = function() {
-        return length;
-    };
-
-    /**
-     * Returns the contents of this Guacamole.Blob as a Blob.
-     * @return {Blob} The contents of this Guacamole.Blob.
-     */
-    this.getBlob = function() {
-        return blob_builder.getBlob();
-    };
-
-    /**
-     * Fired once for every blob of data received.
-     * 
-     * @event
-     * @param {Number} length The number of bytes received.
-     */
-    this.ondata = null;
-
-    /**
-     * Fired once this blob is finished and no further data will be written.
-     * @event
-     */
-    this.oncomplete = null;
-
-};
-
-
-/**
- * Guacamole protocol client. Given a display element and {@link Guacamole.Tunnel},
- * automatically handles incoming and outgoing Guacamole instructions via the
- * provided tunnel, updating the display using one or more canvas elements.
- * 
- * @constructor
- * @param {Guacamole.Tunnel} tunnel The tunnel to use to send and receive
- *                                  Guacamole instructions.
- */
-Guacamole.Client = function(tunnel) {
-
-    var guac_client = this;
-
-    var STATE_IDLE          = 0;
-    var STATE_CONNECTING    = 1;
-    var STATE_WAITING       = 2;
-    var STATE_CONNECTED     = 3;
-    var STATE_DISCONNECTING = 4;
-    var STATE_DISCONNECTED  = 5;
-
-    var currentState = STATE_IDLE;
-    
-    var currentTimestamp = 0;
-    var pingInterval = null;
-
-    var displayWidth = 0;
-    var displayHeight = 0;
-    var displayScale = 1;
-
-    /**
-     * Translation from Guacamole protocol line caps to Layer line caps.
-     * @private
-     */
-    var lineCap = {
-        0: "butt",
-        1: "round",
-        2: "square"
-    };
-
-    /**
-     * Translation from Guacamole protocol line caps to Layer line caps.
-     * @private
-     */
-    var lineJoin = {
-        0: "bevel",
-        1: "miter",
-        2: "round"
-    };
-
-    // Create bounding div 
-    var bounds = document.createElement("div");
-    bounds.style.position = "relative";
-    bounds.style.width = (displayWidth*displayScale) + "px";
-    bounds.style.height = (displayHeight*displayScale) + "px";
-
-    // Create display
-    var display = document.createElement("div");
-    display.style.position = "relative";
-    display.style.width = displayWidth + "px";
-    display.style.height = displayHeight + "px";
-
-    // Ensure transformations on display originate at 0,0
-    display.style.transformOrigin =
-    display.style.webkitTransformOrigin =
-    display.style.MozTransformOrigin =
-    display.style.OTransformOrigin =
-    display.style.msTransformOrigin =
-        "0 0";
-
-    // Create default layer
-    var default_layer_container = new Guacamole.Client.LayerContainer(displayWidth, displayHeight);
-
-    // Position default layer
-    var default_layer_container_element = default_layer_container.getElement();
-    default_layer_container_element.style.position = "absolute";
-    default_layer_container_element.style.left = "0px";
-    default_layer_container_element.style.top  = "0px";
-    default_layer_container_element.style.overflow = "hidden";
-
-    // Create cursor layer
-    var cursor = new Guacamole.Client.LayerContainer(0, 0);
-    cursor.getLayer().setChannelMask(Guacamole.Layer.SRC);
-    cursor.getLayer().autoflush = true;
-
-    // Position cursor layer
-    var cursor_element = cursor.getElement();
-    cursor_element.style.position = "absolute";
-    cursor_element.style.left = "0px";
-    cursor_element.style.top  = "0px";
-
-    // Add default layer and cursor to display
-    display.appendChild(default_layer_container.getElement());
-    display.appendChild(cursor.getElement());
-
-    // Add display to bounds
-    bounds.appendChild(display);
-
-    // Initially, only default layer exists
-    var layers =  [default_layer_container];
-
-    // No initial buffers
-    var buffers = [];
-
-    // No initial parsers
-    var parsers = [];
-
-    // No initial audio channels 
-    var audio_channels = [];
-
-    // No initial blobs
-    var blobs = [];
-
-    tunnel.onerror = function(message) {
-        if (guac_client.onerror)
-            guac_client.onerror(message);
-    };
-
-    function setState(state) {
-        if (state != currentState) {
-            currentState = state;
-            if (guac_client.onstatechange)
-                guac_client.onstatechange(currentState);
-        }
-    }
-
-    function isConnected() {
-        return currentState == STATE_CONNECTED
-            || currentState == STATE_WAITING;
-    }
-
-    var cursorHotspotX = 0;
-    var cursorHotspotY = 0;
-
-    var cursorX = 0;
-    var cursorY = 0;
-
-    function moveCursor(x, y) {
-
-        // Move cursor layer
-        cursor.translate(x - cursorHotspotX, y - cursorHotspotY);
-
-        // Update stored position
-        cursorX = x;
-        cursorY = y;
-
-    }
-
-    /**
-     * Returns an element containing the display of this Guacamole.Client.
-     * Adding the element returned by this function to an element in the body
-     * of a document will cause the client's display to be visible.
-     * 
-     * @return {Element} An element containing ths display of this
-     *                   Guacamole.Client.
-     */
-    this.getDisplay = function() {
-        return bounds;
-    };
-
-    /**
-     * Sends the current size of the screen.
-     * 
-     * @param {Number} width The width of the screen.
-     * @param {Number} height The height of the screen.
-     */
-    this.sendSize = function(width, height) {
-
-        // Do not send requests if not connected
-        if (!isConnected())
-            return;
-
-        tunnel.sendMessage("size", width, height);
-
-    };
-
-    /**
-     * Sends a key event having the given properties as if the user
-     * pressed or released a key.
-     * 
-     * @param {Boolean} pressed Whether the key is pressed (true) or released
-     *                          (false).
-     * @param {Number} keysym The keysym of the key being pressed or released.
-     */
-    this.sendKeyEvent = function(pressed, keysym) {
-        // Do not send requests if not connected
-        if (!isConnected())
-            return;
-
-        tunnel.sendMessage("key", keysym, pressed);
-    };
-
-    /**
-     * Sends a mouse event having the properties provided by the given mouse
-     * state.
-     * 
-     * @param {Guacamole.Mouse.State} mouseState The state of the mouse to send
-     *                                           in the mouse event.
-     */
-    this.sendMouseState = function(mouseState) {
-
-        // Do not send requests if not connected
-        if (!isConnected())
-            return;
-
-        // Update client-side cursor
-        moveCursor(
-            Math.floor(mouseState.x),
-            Math.floor(mouseState.y)
-        );
-
-        // Build mask
-        var buttonMask = 0;
-        if (mouseState.left)   buttonMask |= 1;
-        if (mouseState.middle) buttonMask |= 2;
-        if (mouseState.right)  buttonMask |= 4;
-        if (mouseState.up)     buttonMask |= 8;
-        if (mouseState.down)   buttonMask |= 16;
-
-        // Send message
-        tunnel.sendMessage("mouse", Math.floor(mouseState.x), Math.floor(mouseState.y), buttonMask);
-    };
-
-    /**
-     * Sets the clipboard of the remote client to the given text data.
-     * 
-     * @param {String} data The data to send as the clipboard contents.
-     */
-    this.setClipboard = function(data) {
-
-        // Do not send requests if not connected
-        if (!isConnected())
-            return;
-
-        tunnel.sendMessage("clipboard", data);
-    };
-
-    /**
-     * Fired whenever the state of this Guacamole.Client changes.
-     * 
-     * @event
-     * @param {Number} state The new state of the client.
-     */
-    this.onstatechange = null;
-
-    /**
-     * Fired when the remote client sends a name update.
-     * 
-     * @event
-     * @param {String} name The new name of this client.
-     */
-    this.onname = null;
-
-    /**
-     * Fired when an error is reported by the remote client, and the connection
-     * is being closed.
-     * 
-     * @event
-     * @param {String} error A human-readable description of the error.
-     */
-    this.onerror = null;
-
-    /**
-     * Fired when the clipboard of the remote client is changing.
-     * 
-     * @event
-     * @param {String} data The new text data of the remote clipboard.
-     */
-    this.onclipboard = null;
-
-    /**
-     * Fired when the default layer (and thus the entire Guacamole display)
-     * is resized.
-     * 
-     * @event
-     * @param {Number} width The new width of the Guacamole display.
-     * @param {Number} height The new height of the Guacamole display.
-     */
-    this.onresize = null;
-
-    /**
-     * Fired when a blob is created. The blob provided to this event handler
-     * will contain its own event handlers for received data and the close
-     * event.
-     * 
-     * @event
-     * @param {Guacamole.Blob} blob A container for blob data that will receive
-     *                              data from the server.
-     */
-    this.onblob = null;
-
-    // Layers
-    function getBufferLayer(index) {
-
-        index = -1 - index;
-        var buffer = buffers[index];
-
-        // Create buffer if necessary
-        if (buffer == null) {
-            buffer = new Guacamole.Layer(0, 0);
-            buffer.autoflush = 1;
-            buffer.autosize = 1;
-            buffers[index] = buffer;
-        }
-
-        return buffer;
-
-    }
-
-    function getLayerContainer(index) {
-
-        var layer = layers[index];
-        if (layer == null) {
-
-            // Add new layer
-            layer = new Guacamole.Client.LayerContainer(displayWidth, displayHeight);
-            layers[index] = layer;
-
-            // Get and position layer
-            var layer_element = layer.getElement();
-            layer_element.style.position = "absolute";
-            layer_element.style.left = "0px";
-            layer_element.style.top = "0px";
-            layer_element.style.overflow = "hidden";
-
-            // Add to default layer container
-            default_layer_container.getElement().appendChild(layer_element);
-
-        }
-
-        return layer;
-
-    }
-
-    function getLayer(index) {
-       
-        // If buffer, just get layer
-        if (index < 0)
-            return getBufferLayer(index);
-
-        // Otherwise, retrieve layer from layer container
-        return getLayerContainer(index).getLayer();
-
-    }
-
-    function getParser(index) {
-
-        var parser = parsers[index];
-
-        // If parser not yet created, create it, and tie to the
-        // oninstruction handler of the tunnel.
-        if (parser == null) {
-            parser = parsers[index] = new Guacamole.Parser();
-            parser.oninstruction = tunnel.oninstruction;
-        }
-
-        return parser;
-
-    }
-
-    function getAudioChannel(index) {
-
-        var audio_channel = audio_channels[index];
-
-        // If audio channel not yet created, create it
-        if (audio_channel == null)
-            audio_channel = audio_channels[index] = new Guacamole.AudioChannel();
-
-        return audio_channel;
-
-    }
-
-    /**
-     * Handlers for all defined layer properties.
-     * @private
-     */
-    var layerPropertyHandlers = {
-
-        "miter-limit": function(layer, value) {
-            layer.setMiterLimit(parseFloat(value));
-        }
-
-    };
-    
-    /**
-     * Handlers for all instruction opcodes receivable by a Guacamole protocol
-     * client.
-     * @private
-     */
-    var instructionHandlers = {
-
-        "arc": function(parameters) {
-
-            var layer = getLayer(parseInt(parameters[0]));
-            var x = parseInt(parameters[1]);
-            var y = parseInt(parameters[2]);
-            var radius = parseInt(parameters[3]);
-            var startAngle = parseFloat(parameters[4]);
-            var endAngle = parseFloat(parameters[5]);
-            var negative = parseInt(parameters[6]);
-
-            layer.arc(x, y, radius, startAngle, endAngle, negative != 0);
-
-        },
-
-        "audio": function(parameters) {
-
-            var channel = getAudioChannel(parseInt(parameters[0]));
-            var mimetype = parameters[1];
-            var duration = parseFloat(parameters[2]);
-            var data = parameters[3];
-
-            channel.play(mimetype, duration, data);
-
-        },
-
-        "blob": function(parameters) {
-
-            // Get blob
-            var blob_index = parseInt(parameters[0]);
-            var data = parameters[1];
-            var blob = blobs[blob_index];
-
-            // Convert to ArrayBuffer
-            var binary = window.atob(data);
-            var arrayBuffer = new ArrayBuffer(binary.length);
-            var bufferView = new Uint8Array(arrayBuffer);
-
-            for (var i=0; i<binary.length; i++)
-                bufferView[i] = binary.charCodeAt(i);
-
-            // Write data
-            blob.append(arrayBuffer);
-
-        },
-
-        "cfill": function(parameters) {
-
-            var channelMask = parseInt(parameters[0]);
-            var layer = getLayer(parseInt(parameters[1]));
-            var r = parseInt(parameters[2]);
-            var g = parseInt(parameters[3]);
-            var b = parseInt(parameters[4]);
-            var a = parseInt(parameters[5]);
-
-            layer.setChannelMask(channelMask);
-
-            layer.fillColor(r, g, b, a);
-
-        },
-
-        "clip": function(parameters) {
-
-            var layer = getLayer(parseInt(parameters[0]));
-
-            layer.clip();
-
-        },
-
-        "clipboard": function(parameters) {
-            if (guac_client.onclipboard) guac_client.onclipboard(parameters[0]);
-        },
-
-        "close": function(parameters) {
-
-            var layer = getLayer(parseInt(parameters[0]));
-
-            layer.close();
-
-        },
-
-        "copy": function(parameters) {
-
-            var srcL = getLayer(parseInt(parameters[0]));
-            var srcX = parseInt(parameters[1]);
-            var srcY = parseInt(parameters[2]);
-            var srcWidth = parseInt(parameters[3]);
-            var srcHeight = parseInt(parameters[4]);
-            var channelMask = parseInt(parameters[5]);
-            var dstL = getLayer(parseInt(parameters[6]));
-            var dstX = parseInt(parameters[7]);
-            var dstY = parseInt(parameters[8]);
-
-            dstL.setChannelMask(channelMask);
-
-            dstL.copy(
-                srcL,
-                srcX,
-                srcY,
-                srcWidth, 
-                srcHeight, 
-                dstX,
-                dstY 
-            );
-
-        },
-
-        "cstroke": function(parameters) {
-
-            var channelMask = parseInt(parameters[0]);
-            var layer = getLayer(parseInt(parameters[1]));
-            var cap = lineCap[parseInt(parameters[2])];
-            var join = lineJoin[parseInt(parameters[3])];
-            var thickness = parseInt(parameters[4]);
-            var r = parseInt(parameters[5]);
-            var g = parseInt(parameters[6]);
-            var b = parseInt(parameters[7]);
-            var a = parseInt(parameters[8]);
-
-            layer.setChannelMask(channelMask);
-
-            layer.strokeColor(cap, join, thickness, r, g, b, a);
-
-        },
-
-        "cursor": function(parameters) {
-
-            cursorHotspotX = parseInt(parameters[0]);
-            cursorHotspotY = parseInt(parameters[1]);
-            var srcL = getLayer(parseInt(parameters[2]));
-            var srcX = parseInt(parameters[3]);
-            var srcY = parseInt(parameters[4]);
-            var srcWidth = parseInt(parameters[5]);
-            var srcHeight = parseInt(parameters[6]);
-
-            // Reset cursor size
-            cursor.resize(srcWidth, srcHeight);
-
-            // Draw cursor to cursor layer
-            cursor.getLayer().copy(
-                srcL,
-                srcX,
-                srcY,
-                srcWidth, 
-                srcHeight, 
-                0,
-                0 
-            );
-
-            // Update cursor position (hotspot may have changed)
-            moveCursor(cursorX, cursorY);
-
-        },
-
-        "curve": function(parameters) {
-
-            var layer = getLayer(parseInt(parameters[0]));
-            var cp1x = parseInt(parameters[1]);
-            var cp1y = parseInt(parameters[2]);
-            var cp2x = parseInt(parameters[3]);
-            var cp2y = parseInt(parameters[4]);
-            var x = parseInt(parameters[5]);
-            var y = parseInt(parameters[6]);
-
-            layer.curveTo(cp1x, cp1y, cp2x, cp2y, x, y);
-
-        },
-
-        "dispose": function(parameters) {
-            
-            var layer_index = parseInt(parameters[0]);
-
-            // If visible layer, remove from parent
-            if (layer_index > 0) {
-
-                // Get container element
-                var layer_container = getLayerContainer(layer_index).getElement();
-
-                // Remove from parent
-                layer_container.parentNode.removeChild(layer_container);
-
-                // Delete reference
-                delete layers[layer_index];
-
-            }
-
-            // If buffer, just delete reference
-            else if (layer_index < 0)
-                delete buffers[-1 - layer_index];
-
-            // Attempting to dispose the root layer currently has no effect.
-
-        },
-
-        "distort": function(parameters) {
-
-            var layer_index = parseInt(parameters[0]);
-            var a = parseFloat(parameters[1]);
-            var b = parseFloat(parameters[2]);
-            var c = parseFloat(parameters[3]);
-            var d = parseFloat(parameters[4]);
-            var e = parseFloat(parameters[5]);
-            var f = parseFloat(parameters[6]);
-
-            // Only valid for visible layers (not buffers)
-            if (layer_index >= 0) {
-
-                // Get container element
-                var layer_container = getLayerContainer(layer_index).getElement();
-
-                // Set layer transform 
-                layer_container.transform(a, b, c, d, e, f);
-
-             }
-
-        },
- 
-        "error": function(parameters) {
-            if (guac_client.onerror) guac_client.onerror(parameters[0]);
-            guac_client.disconnect();
-        },
-
-        "end": function(parameters) {
-
-            // Get blob
-            var blob_index = parseInt(parameters[0]);
-            var blob = blobs[blob_index];
-
-            // Close blob
-            blob.close();
-
-        },
-
-        "file": function(parameters) {
-
-            var blob_index = parseInt(parameters[0]);
-            var mimetype = parameters[1];
-            var filename = parameters[2];
-
-            // Create blob
-            var blob = blobs[blob_index] = new Guacamole.Blob(mimetype, filename);
-
-            // Call handler now that blob is created
-            if (guac_client.onblob)
-                guac_client.onblob(blob);
-
-        },
-
-        "identity": function(parameters) {
-
-            var layer = getLayer(parseInt(parameters[0]));
-
-            layer.setTransform(1, 0, 0, 1, 0, 0);
-
-        },
-
-        "lfill": function(parameters) {
-
-            var channelMask = parseInt(parameters[0]);
-            var layer = getLayer(parseInt(parameters[1]));
-            var srcLayer = getLayer(parseInt(parameters[2]));
-
-            layer.setChannelMask(channelMask);
-
-            layer.fillLayer(srcLayer);
-
-        },
-
-        "line": function(parameters) {
-
-            var layer = getLayer(parseInt(parameters[0]));
-            var x = parseInt(parameters[1]);
-            var y = parseInt(parameters[2]);
-
-            layer.lineTo(x, y);
-
-        },
-
-        "lstroke": function(parameters) {
-
-            var channelMask = parseInt(parameters[0]);
-            var layer = getLayer(parseInt(parameters[1]));
-            var srcLayer = getLayer(parseInt(parameters[2]));
-
-            layer.setChannelMask(channelMask);
-
-            layer.strokeLayer(srcLayer);
-
-        },
-
-        "move": function(parameters) {
-            
-            var layer_index = parseInt(parameters[0]);
-            var parent_index = parseInt(parameters[1]);
-            var x = parseInt(parameters[2]);
-            var y = parseInt(parameters[3]);
-            var z = parseInt(parameters[4]);
-
-            // Only valid for non-default layers
-            if (layer_index > 0 && parent_index >= 0) {
-
-                // Get container element
-                var layer_container = getLayerContainer(layer_index);
-                var layer_container_element = layer_container.getElement();
-                var parent = getLayerContainer(parent_index).getElement();
-
-                // Set parent if necessary
-                if (!(layer_container_element.parentNode === parent))
-                    parent.appendChild(layer_container_element);
-
-                // Move layer
-                layer_container.translate(x, y);
-                layer_container_element.style.zIndex = z;
-
-            }
-
-        },
-
-        "name": function(parameters) {
-            if (guac_client.onname) guac_client.onname(parameters[0]);
-        },
-
-        "nest": function(parameters) {
-            var parser = getParser(parseInt(parameters[0]));
-            parser.receive(parameters[1]);
-        },
-
-        "png": function(parameters) {
-
-            var channelMask = parseInt(parameters[0]);
-            var layer = getLayer(parseInt(parameters[1]));
-            var x = parseInt(parameters[2]);
-            var y = parseInt(parameters[3]);
-            var data = parameters[4];
-
-            layer.setChannelMask(channelMask);
-
-            layer.draw(
-                x,
-                y,
-                "data:image/png;base64," + data
-            );
-
-        },
-
-        "pop": function(parameters) {
-
-            var layer = getLayer(parseInt(parameters[0]));
-
-            layer.pop();
-
-        },
-
-        "push": function(parameters) {
-
-            var layer = getLayer(parseInt(parameters[0]));
-
-            layer.push();
-
-        },
- 
-        "rect": function(parameters) {
-
-            var layer = getLayer(parseInt(parameters[0]));
-            var x = parseInt(parameters[1]);
-            var y = parseInt(parameters[2]);
-            var w = parseInt(parameters[3]);
-            var h = parseInt(parameters[4]);
-
-            layer.rect(x, y, w, h);
-
-        },
-        
-        "reset": function(parameters) {
-
-            var layer = getLayer(parseInt(parameters[0]));
-
-            layer.reset();
-
-        },
-        
-        "set": function(parameters) {
-
-            var layer = getLayer(parseInt(parameters[0]));
-            var name = parameters[1];
-            var value = parameters[2];
-
-            // Call property handler if defined
-            var handler = layerPropertyHandlers[name];
-            if (handler)
-                handler(layer, value);
-
-        },
-
-        "shade": function(parameters) {
-            
-            var layer_index = parseInt(parameters[0]);
-            var a = parseInt(parameters[1]);
-
-            // Only valid for visible layers (not buffers)
-            if (layer_index >= 0) {
-
-                // Get container element
-                var layer_container = getLayerContainer(layer_index).getElement();
-
-                // Set layer opacity
-                layer_container.style.opacity = a/255.0;
-
-            }
-
-        },
-
-        "size": function(parameters) {
-
-            var layer_index = parseInt(parameters[0]);
-            var width = parseInt(parameters[1]);
-            var height = parseInt(parameters[2]);
-
-            // If not buffer, resize layer and container
-            if (layer_index >= 0) {
-
-                // Resize layer
-                var layer_container = getLayerContainer(layer_index);
-                layer_container.resize(width, height);
-
-                // If layer is default, resize display
-                if (layer_index == 0) {
-
-                    displayWidth = width;
-                    displayHeight = height;
-
-                    // Update (set) display size
-                    display.style.width = displayWidth + "px";
-                    display.style.height = displayHeight + "px";
-
-                    // Update bounds size
-                    bounds.style.width = (displayWidth*displayScale) + "px";
-                    bounds.style.height = (displayHeight*displayScale) + "px";
-
-                    // Call resize event handler if defined
-                    if (guac_client.onresize)
-                        guac_client.onresize(width, height);
-
-                }
-
-            }
-
-            // If buffer, resize layer only
-            else {
-                var layer = getBufferLayer(parseInt(parameters[0]));
-                layer.resize(width, height);
-            }
-
-        },
-        
-        "start": function(parameters) {
-
-            var layer = getLayer(parseInt(parameters[0]));
-            var x = parseInt(parameters[1]);
-            var y = parseInt(parameters[2]);
-
-            layer.moveTo(x, y);
-
-        },
-
-        "sync": function(parameters) {
-
-            var timestamp = parameters[0];
-
-            // When all layers have finished rendering all instructions
-            // UP TO THIS POINT IN TIME, send sync response.
-
-            var layersToSync = 0;
-            function syncLayer() {
-
-                layersToSync--;
-
-                // Send sync response when layers are finished
-                if (layersToSync == 0) {
-                    if (timestamp != currentTimestamp) {
-                        tunnel.sendMessage("sync", timestamp);
-                        currentTimestamp = timestamp;
-                    }
-                }
-
-            }
-
-            // Count active, not-ready layers and install sync tracking hooks
-            for (var i=0; i<layers.length; i++) {
-
-                var layer = layers[i].getLayer();
-                if (layer) {
-
-                    // Flush layer
-                    layer.flush();
-
-                    // If still not ready, sync later
-                    if (!layer.isReady()) {
-                        layersToSync++;
-                        layer.sync(syncLayer);
-                    }
-
-                }
-
-            }
-
-            // If all layers are ready, then we didn't install any hooks.
-            // Send sync message now,
-            if (layersToSync == 0) {
-                if (timestamp != currentTimestamp) {
-                    tunnel.sendMessage("sync", timestamp);
-                    currentTimestamp = timestamp;
-                }
-            }
-
-            // If received first update, no longer waiting.
-            if (currentState == STATE_WAITING)
-                setState(STATE_CONNECTED);
-
-        },
-
-        "transfer": function(parameters) {
-
-            var srcL = getLayer(parseInt(parameters[0]));
-            var srcX = parseInt(parameters[1]);
-            var srcY = parseInt(parameters[2]);
-            var srcWidth = parseInt(parameters[3]);
-            var srcHeight = parseInt(parameters[4]);
-            var transferFunction = Guacamole.Client.DefaultTransferFunction[parameters[5]];
-            var dstL = getLayer(parseInt(parameters[6]));
-            var dstX = parseInt(parameters[7]);
-            var dstY = parseInt(parameters[8]);
-
-            dstL.transfer(
-                srcL,
-                srcX,
-                srcY,
-                srcWidth, 
-                srcHeight, 
-                dstX,
-                dstY,
-                transferFunction
-            );
-
-        },
-
-        "transform": function(parameters) {
-
-            var layer = getLayer(parseInt(parameters[0]));
-            var a = parseFloat(parameters[1]);
-            var b = parseFloat(parameters[2]);
-            var c = parseFloat(parameters[3]);
-            var d = parseFloat(parameters[4]);
-            var e = parseFloat(parameters[5]);
-            var f = parseFloat(parameters[6]);
-
-            layer.transform(a, b, c, d, e, f);
-
-        },
-
-        "video": function(parameters) {
-
-            var layer = getLayer(parseInt(parameters[0]));
-            var mimetype = parameters[1];
-            var duration = parseFloat(parameters[2]);
-            var data = parameters[3];
-
-            layer.play(mimetype, duration, "data:" + mimetype + ";base64," + data);
-
-        }
-
-
-    };
-
-
-    tunnel.oninstruction = function(opcode, parameters) {
-
-        var handler = instructionHandlers[opcode];
-        if (handler)
-            handler(parameters);
-
-    };
-
-
-    /**
-     * Sends a disconnect instruction to the server and closes the tunnel.
-     */
-    this.disconnect = function() {
-
-        // Only attempt disconnection not disconnected.
-        if (currentState != STATE_DISCONNECTED
-                && currentState != STATE_DISCONNECTING) {
-
-            setState(STATE_DISCONNECTING);
-
-            // Stop ping
-            if (pingInterval)
-                window.clearInterval(pingInterval);
-
-            // Send disconnect message and disconnect
-            tunnel.sendMessage("disconnect");
-            tunnel.disconnect();
-            setState(STATE_DISCONNECTED);
-
-        }
-
-    };
-    
-    /**
-     * Connects the underlying tunnel of this Guacamole.Client, passing the
-     * given arbitrary data to the tunnel during the connection process.
-     *
-     * @param data Arbitrary connection data to be sent to the underlying
-     *             tunnel during the connection process.
-     */
-    this.connect = function(data) {
-
-        setState(STATE_CONNECTING);
-
-        try {
-            tunnel.connect(data);
-        }
-        catch (e) {
-            setState(STATE_IDLE);
-            throw e;
-        }
-
-        // Ping every 5 seconds (ensure connection alive)
-        pingInterval = window.setInterval(function() {
-            tunnel.sendMessage("sync", currentTimestamp);
-        }, 5000);
-
-        setState(STATE_WAITING);
-    };
-
-    /**
-     * Sets the scale of the client display element such that it renders at
-     * a relatively smaller or larger size, without affecting the true
-     * resolution of the display.
-     *
-     * @param {Number} scale The scale to resize to, where 1.0 is normal
-     *                       size (1:1 scale).
-     */
-    this.scale = function(scale) {
-
-        display.style.transform =
-        display.style.WebkitTransform =
-        display.style.MozTransform =
-        display.style.OTransform =
-        display.style.msTransform =
-
-            "scale(" + scale + "," + scale + ")";
-
-        displayScale = scale;
-
-        // Update bounds size
-        bounds.style.width = (displayWidth*displayScale) + "px";
-        bounds.style.height = (displayHeight*displayScale) + "px";
-
-    };
-
-    /**
-     * Returns the width of the display.
-     *
-     * @return {Number} The width of the display.
-     */
-    this.getWidth = function() {
-        return displayWidth;
-    };
-
-    /**
-     * Returns the height of the display.
-     *
-     * @return {Number} The height of the display.
-     */
-    this.getHeight = function() {
-        return displayHeight;
-    };
-
-    /**
-     * Returns the scale of the display.
-     *
-     * @return {Number} The scale of the display.
-     */
-    this.getScale = function() {
-        return displayScale;
-    };
-
-    /**
-     * Returns a canvas element containing the entire display, with all child
-     * layers composited within.
-     *
-     * @return {HTMLCanvasElement} A new canvas element containing a copy of
-     *                             the display.
-     */
-    this.flatten = function() {
-       
-        // Get source and destination canvases
-        var source = getLayer(0).getCanvas();
-        var canvas = document.createElement("canvas");
-
-        // Set dimensions
-        canvas.width = source.width;
-        canvas.height = source.height;
-
-        // Copy image from source
-        var context = canvas.getContext("2d");
-        context.drawImage(source, 0, 0);
-        
-        // Return new canvas copy
-        return canvas;
-        
-    };
-
-};
-
-/**
- * Simple container for Guacamole.Layer, allowing layers to be easily
- * repositioned and nested. This allows certain operations to be accelerated
- * through DOM manipulation, rather than raster operations.
- * 
- * @constructor
- * 
- * @param {Number} width The width of the Layer, in pixels. The canvas element
- *                       backing this Layer will be given this width.
- *                       
- * @param {Number} height The height of the Layer, in pixels. The canvas element
- *                        backing this Layer will be given this height.
- */
-Guacamole.Client.LayerContainer = function(width, height) {
-
-    /**
-     * Reference to this LayerContainer.
-     * @private
-     */
-    var layer_container = this;
-
-    // Create layer with given size
-    var layer = new Guacamole.Layer(width, height);
-
-    // Set layer position
-    var canvas = layer.getCanvas();
-    canvas.style.position = "absolute";
-    canvas.style.left = "0px";
-    canvas.style.top = "0px";
-
-    // Create div with given size
-    var div = document.createElement("div");
-    div.appendChild(canvas);
-    div.style.width = width + "px";
-    div.style.height = height + "px";
-
-    /**
-     * Changes the size of this LayerContainer and the contained Layer to the
-     * given width and height.
-     * 
-     * @param {Number} width The new width to assign to this Layer.
-     * @param {Number} height The new height to assign to this Layer.
-     */
-    this.resize = function(width, height) {
-
-        // Resize layer
-        layer.resize(width, height);
-
-        // Resize containing div
-        div.style.width = width + "px";
-        div.style.height = height + "px";
-
-    };
-  
-    /**
-     * Returns the Layer contained within this LayerContainer.
-     * @returns {Guacamole.Layer} The Layer contained within this
-     *                            LayerContainer.
-     */
-    this.getLayer = function() {
-        return layer;
-    };
-
-    /**
-     * Returns the element containing the Layer within this LayerContainer.
-     * @returns {Element} The element containing the Layer within this
-     *                    LayerContainer.
-     */
-    this.getElement = function() {
-        return div;
-    };
-
-    /**
-     * The translation component of this LayerContainer's transform.
-     * @private
-     */
-    var translate = "translate(0px, 0px)"; // (0, 0)
-
-    /**
-     * The arbitrary matrix component of this LayerContainer's transform.
-     * @private
-     */
-    var matrix = "matrix(1, 0, 0, 1, 0, 0)"; // Identity
-
-    /**
-     * Moves the upper-left corner of this LayerContainer to the given X and Y
-     * coordinate.
-     * 
-     * @param {Number} x The X coordinate to move to.
-     * @param {Number} y The Y coordinate to move to.
-     */
-    this.translate = function(x, y) {
-
-        // Generate translation
-        translate = "translate("
-                        + x + "px,"
-                        + y + "px)";
-
-        // Set layer transform 
-        div.style.transform =
-        div.style.WebkitTransform =
-        div.style.MozTransform =
-        div.style.OTransform =
-        div.style.msTransform =
-
-            translate + " " + matrix;
-
-    };
-
-    /**
-     * Applies the given affine transform (defined with six values from the
-     * transform's matrix).
-     * 
-     * @param {Number} a The first value in the affine transform's matrix.
-     * @param {Number} b The second value in the affine transform's matrix.
-     * @param {Number} c The third value in the affine transform's matrix.
-     * @param {Number} d The fourth value in the affine transform's matrix.
-     * @param {Number} e The fifth value in the affine transform's matrix.
-     * @param {Number} f The sixth value in the affine transform's matrix.
-     */
-    this.transform = function(a, b, c, d, e, f) {
-
-        // Generate matrix transformation
-        matrix =
-
-            /* a c e
-             * b d f
-             * 0 0 1
-             */
-    
-            "matrix(" + a + "," + b + "," + c + "," + d + "," + e + "," + f + ")";
-
-        // Set layer transform 
-        div.style.transform =
-        div.style.WebkitTransform =
-        div.style.MozTransform =
-        div.style.OTransform =
-        div.style.msTransform =
-
-            translate + " " + matrix;
-
-    };
-
-};
-
-/**
- * Map of all Guacamole binary raster operations to transfer functions.
- * @private
- */
-Guacamole.Client.DefaultTransferFunction = {
-
-    /* BLACK */
-    0x0: function (src, dst) {
-        dst.red = dst.green = dst.blue = 0x00;
-    },
-
-    /* WHITE */
-    0xF: function (src, dst) {
-        dst.red = dst.green = dst.blue = 0xFF;
-    },
-
-    /* SRC */
-    0x3: function (src, dst) {
-        dst.red   = src.red;
-        dst.green = src.green;
-        dst.blue  = src.blue;
-        dst.alpha = src.alpha;
-    },
-
-    /* DEST (no-op) */
-    0x5: function (src, dst) {
-        // Do nothing
-    },
-
-    /* Invert SRC */
-    0xC: function (src, dst) {
-        dst.red   = 0xFF & ~src.red;
-        dst.green = 0xFF & ~src.green;
-        dst.blue  = 0xFF & ~src.blue;
-        dst.alpha =  src.alpha;
-    },
-    
-    /* Invert DEST */
-    0xA: function (src, dst) {
-        dst.red   = 0xFF & ~dst.red;
-        dst.green = 0xFF & ~dst.green;
-        dst.blue  = 0xFF & ~dst.blue;
-    },
-
-    /* AND */
-    0x1: function (src, dst) {
-        dst.red   =  ( src.red   &  dst.red);
-        dst.green =  ( src.green &  dst.green);
-        dst.blue  =  ( src.blue  &  dst.blue);
-    },
-
-    /* NAND */
-    0xE: function (src, dst) {
-        dst.red   = 0xFF & ~( src.red   &  dst.red);
-        dst.green = 0xFF & ~( src.green &  dst.green);
-        dst.blue  = 0xFF & ~( src.blue  &  dst.blue);
-    },
-
-    /* OR */
-    0x7: function (src, dst) {
-        dst.red   =  ( src.red   |  dst.red);
-        dst.green =  ( src.green |  dst.green);
-        dst.blue  =  ( src.blue  |  dst.blue);
-    },
-
-    /* NOR */
-    0x8: function (src, dst) {
-        dst.red   = 0xFF & ~( src.red   |  dst.red);
-        dst.green = 0xFF & ~( src.green |  dst.green);
-        dst.blue  = 0xFF & ~( src.blue  |  dst.blue);
-    },
-
-    /* XOR */
-    0x6: function (src, dst) {
-        dst.red   =  ( src.red   ^  dst.red);
-        dst.green =  ( src.green ^  dst.green);
-        dst.blue  =  ( src.blue  ^  dst.blue);
-    },
-
-    /* XNOR */
-    0x9: function (src, dst) {
-        dst.red   = 0xFF & ~( src.red   ^  dst.red);
-        dst.green = 0xFF & ~( src.green ^  dst.green);
-        dst.blue  = 0xFF & ~( src.blue  ^  dst.blue);
-    },
-
-    /* AND inverted source */
-    0x4: function (src, dst) {
-        dst.red   =  0xFF & (~src.red   &  dst.red);
-        dst.green =  0xFF & (~src.green &  dst.green);
-        dst.blue  =  0xFF & (~src.blue  &  dst.blue);
-    },
-
-    /* OR inverted source */
-    0xD: function (src, dst) {
-        dst.red   =  0xFF & (~src.red   |  dst.red);
-        dst.green =  0xFF & (~src.green |  dst.green);
-        dst.blue  =  0xFF & (~src.blue  |  dst.blue);
-    },
-
-    /* AND inverted destination */
-    0x2: function (src, dst) {
-        dst.red   =  0xFF & ( src.red   & ~dst.red);
-        dst.green =  0xFF & ( src.green & ~dst.green);
-        dst.blue  =  0xFF & ( src.blue  & ~dst.blue);
-    },
-
-    /* OR inverted destination */
-    0xB: function (src, dst) {
-        dst.red   =  0xFF & ( src.red   | ~dst.red);
-        dst.green =  0xFF & ( src.green | ~dst.green);
-        dst.blue  =  0xFF & ( src.blue  | ~dst.blue);
-    }
-
-};
diff --git a/guacamole-common-js/src/main/resources/keyboard.js b/guacamole-common-js/src/main/resources/keyboard.js
deleted file mode 100644
index 46b2bfa..0000000
--- a/guacamole-common-js/src/main/resources/keyboard.js
+++ /dev/null
@@ -1,622 +0,0 @@
-
-/* ***** BEGIN LICENSE BLOCK *****
- * Version: MPL 1.1/GPL 2.0/LGPL 2.1
- *
- * The contents of this file are subject to the Mozilla Public License Version
- * 1.1 (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.mozilla.org/MPL/
- *
- * Software distributed under the License is distributed on an "AS IS" basis,
- * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- * for the specific language governing rights and limitations under the
- * License.
- *
- * The Original Code is guacamole-common-js.
- *
- * The Initial Developer of the Original Code is
- * Michael Jumper.
- * Portions created by the Initial Developer are Copyright (C) 2010
- * the Initial Developer. All Rights Reserved.
- *
- * Contributor(s):
- *
- * Alternatively, the contents of this file may be used under the terms of
- * either the GNU General Public License Version 2 or later (the "GPL"), or
- * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
- * in which case the provisions of the GPL or the LGPL are applicable instead
- * of those above. If you wish to allow use of your version of this file only
- * under the terms of either the GPL or the LGPL, and not to allow others to
- * use your version of this file under the terms of the MPL, indicate your
- * decision by deleting the provisions above and replace them with the notice
- * and other provisions required by the GPL or the LGPL. If you do not delete
- * the provisions above, a recipient may use your version of this file under
- * the terms of any one of the MPL, the GPL or the LGPL.
- *
- * ***** END LICENSE BLOCK ***** */
-
-/**
- * Namespace for all Guacamole JavaScript objects.
- * @namespace
- */
-var Guacamole = Guacamole || {};
-
-/**
- * Provides cross-browser and cross-keyboard keyboard for a specific element.
- * Browser and keyboard layout variation is abstracted away, providing events
- * which represent keys as their corresponding X11 keysym.
- * 
- * @constructor
- * @param {Element} element The Element to use to provide keyboard events.
- */
-Guacamole.Keyboard = function(element) {
-
-    /**
-     * Reference to this Guacamole.Keyboard.
-     * @private
-     */
-    var guac_keyboard = this;
-
-    /**
-     * Fired whenever the user presses a key with the element associated
-     * with this Guacamole.Keyboard in focus.
-     * 
-     * @event
-     * @param {Number} keysym The keysym of the key being pressed.
-     */
-    this.onkeydown = null;
-
-    /**
-     * Fired whenever the user releases a key with the element associated
-     * with this Guacamole.Keyboard in focus.
-     * 
-     * @event
-     * @param {Number} keysym The keysym of the key being released.
-     */
-    this.onkeyup = null;
-
-    /**
-     * Map of known JavaScript keycodes which do not map to typable characters
-     * to their unshifted X11 keysym equivalents.
-     * @private
-     */
-    var unshiftedKeysym = {
-        8:   [0xFF08], // backspace
-        9:   [0xFF09], // tab
-        13:  [0xFF0D], // enter
-        16:  [0xFFE1, 0xFFE1, 0xFFE2], // shift
-        17:  [0xFFE3, 0xFFE3, 0xFFE4], // ctrl
-        18:  [0xFFE9, 0xFFE9, 0xFFEA], // alt
-        19:  [0xFF13], // pause/break
-        20:  [0xFFE5], // caps lock
-        27:  [0xFF1B], // escape
-        32:  [0x0020], // space
-        33:  [0xFF55], // page up
-        34:  [0xFF56], // page down
-        35:  [0xFF57], // end
-        36:  [0xFF50], // home
-        37:  [0xFF51], // left arrow
-        38:  [0xFF52], // up arrow
-        39:  [0xFF53], // right arrow
-        40:  [0xFF54], // down arrow
-        45:  [0xFF63], // insert
-        46:  [0xFFFF], // delete
-        91:  [0xFFEB], // left window key (super_l)
-        92:  [0xFF67], // right window key (menu key?)
-        93:  null,     // select key
-        112: [0xFFBE], // f1
-        113: [0xFFBF], // f2
-        114: [0xFFC0], // f3
-        115: [0xFFC1], // f4
-        116: [0xFFC2], // f5
-        117: [0xFFC3], // f6
-        118: [0xFFC4], // f7
-        119: [0xFFC5], // f8
-        120: [0xFFC6], // f9
-        121: [0xFFC7], // f10
-        122: [0xFFC8], // f11
-        123: [0xFFC9], // f12
-        144: [0xFF7F], // num lock
-        145: [0xFF14]  // scroll lock
-    };
-
-    /**
-     * Map of known JavaScript keyidentifiers which do not map to typable
-     * characters to their unshifted X11 keysym equivalents.
-     * @private
-     */
-    var keyidentifier_keysym = {
-        "AllCandidates": [0xFF3D],
-        "Alphanumeric": [0xFF30],
-        "Alt": [0xFFE9, 0xFFE9, 0xFFEA],
-        "Attn": [0xFD0E],
-        "AltGraph": [0xFFEA],
-        "CapsLock": [0xFFE5],
-        "Clear": [0xFF0B],
-        "Convert": [0xFF21],
-        "Copy": [0xFD15],
-        "Crsel": [0xFD1C],
-        "CodeInput": [0xFF37],
-        "Control": [0xFFE3, 0xFFE3, 0xFFE4],
-        "Down": [0xFF54],
-        "End": [0xFF57],
-        "Enter": [0xFF0D],
-        "EraseEof": [0xFD06],
-        "Execute": [0xFF62],
-        "Exsel": [0xFD1D],
-        "F1": [0xFFBE],
-        "F2": [0xFFBF],
-        "F3": [0xFFC0],
-        "F4": [0xFFC1],
-        "F5": [0xFFC2],
-        "F6": [0xFFC3],
-        "F7": [0xFFC4],
-        "F8": [0xFFC5],
-        "F9": [0xFFC6],
-        "F10": [0xFFC7],
-        "F11": [0xFFC8],
-        "F12": [0xFFC9],
-        "F13": [0xFFCA],
-        "F14": [0xFFCB],
-        "F15": [0xFFCC],
-        "F16": [0xFFCD],
-        "F17": [0xFFCE],
-        "F18": [0xFFCF],
-        "F19": [0xFFD0],
-        "F20": [0xFFD1],
-        "F21": [0xFFD2],
-        "F22": [0xFFD3],
-        "F23": [0xFFD4],
-        "F24": [0xFFD5],
-        "Find": [0xFF68],
-        "FullWidth": null,
-        "HalfWidth": null,
-        "HangulMode": [0xFF31],
-        "HanjaMode": [0xFF34],
-        "Help": [0xFF6A],
-        "Hiragana": [0xFF25],
-        "Home": [0xFF50],
-        "Insert": [0xFF63],
-        "JapaneseHiragana": [0xFF25],
-        "JapaneseKatakana": [0xFF26],
-        "JapaneseRomaji": [0xFF24],
-        "JunjaMode": [0xFF38],
-        "KanaMode": [0xFF2D],
-        "KanjiMode": [0xFF21],
-        "Katakana": [0xFF26],
-        "Left": [0xFF51],
-        "Meta": [0xFFE7],
-        "NumLock": [0xFF7F],
-        "PageDown": [0xFF55],
-        "PageUp": [0xFF56],
-        "Pause": [0xFF13],
-        "PreviousCandidate": [0xFF3E],
-        "PrintScreen": [0xFD1D],
-        "Right": [0xFF53],
-        "RomanCharacters": null,
-        "Scroll": [0xFF14],
-        "Select": [0xFF60],
-        "Shift": [0xFFE1, 0xFFE1, 0xFFE2],
-        "Up": [0xFF52],
-        "Undo": [0xFF65],
-        "Win": [0xFFEB]
-    };
-
-    /**
-     * Map of known JavaScript keycodes which do not map to typable characters
-     * to their shifted X11 keysym equivalents. Keycodes must only be listed
-     * here if their shifted X11 keysym equivalents differ from their unshifted
-     * equivalents.
-     * @private
-     */
-    var shiftedKeysym = {
-        18:  [0xFFE7, 0xFFE7, 0xFFEA]  // alt
-    };
-
-    /**
-     * All keysyms which should not repeat when held down.
-     * @private
-     */
-    var no_repeat = {
-        0xFFE1: true, // Left shift
-        0xFFE2: true, // Right shift
-        0xFFE3: true, // Left ctrl 
-        0xFFE4: true, // Right ctrl 
-        0xFFE9: true, // Left alt
-        0xFFEA: true  // Right alt (or AltGr)
-    };
-
-    /**
-     * All modifiers and their states.
-     */
-    this.modifiers = {
-        
-        /**
-         * Whether shift is currently pressed.
-         */
-        "shift": false,
-        
-        /**
-         * Whether ctrl is currently pressed.
-         */
-        "ctrl" : false,
-        
-        /**
-         * Whether alt is currently pressed.
-         */
-        "alt"  : false,
-        
-        /**
-         * Whether meta (apple key) is currently pressed.
-         */
-        "meta" : false
-
-    };
-
-    /**
-     * The state of every key, indexed by keysym. If a particular key is
-     * pressed, the value of pressed for that keysym will be true. If a key
-     * is not currently pressed, it will not be defined. 
-     */
-    this.pressed = {};
-
-    /**
-     * The keysym associated with a given keycode when keydown fired.
-     * @private
-     */
-    var keydownChar = [];
-
-    /**
-     * Timeout before key repeat starts.
-     * @private
-     */
-    var key_repeat_timeout = null;
-
-    /**
-     * Interval which presses and releases the last key pressed while that
-     * key is still being held down.
-     * @private
-     */
-    var key_repeat_interval = null;
-
-    /**
-     * Given an array of keysyms indexed by location, returns the keysym
-     * for the given location, or the keysym for the standard location if
-     * undefined.
-     * 
-     * @param {Array} keysyms An array of keysyms, where the index of the
-     *                        keysym in the array is the location value.
-     * @param {Number} location The location on the keyboard corresponding to
-     *                          the key pressed, as defined at:
-     *                          http://www.w3.org/TR/DOM-Level-3-Events/#events-KeyboardEvent
-     */
-    function get_keysym(keysyms, location) {
-
-        if (!keysyms)
-            return null;
-
-        return keysyms[location] || keysyms[0];
-    }
-
-    function keysym_from_key_identifier(shifted, keyIdentifier, location) {
-
-        var unicodePrefixLocation = keyIdentifier.indexOf("U+");
-        if (unicodePrefixLocation >= 0) {
-
-            var hex = keyIdentifier.substring(unicodePrefixLocation+2);
-            var codepoint = parseInt(hex, 16);
-            var typedCharacter;
-
-            // Convert case if shifted
-            if (shifted == 0)
-                typedCharacter = String.fromCharCode(codepoint).toLowerCase();
-            else
-                typedCharacter = String.fromCharCode(codepoint).toUpperCase();
-
-            // Get codepoint
-            codepoint = typedCharacter.charCodeAt(0);
-
-            return keysym_from_charcode(codepoint);
-
-        }
-
-        return get_keysym(keyidentifier_keysym[keyIdentifier], location);
-
-    }
-
-    function isControlCharacter(codepoint) {
-        return codepoint <= 0x1F || (codepoint >= 0x7F && codepoint <= 0x9F);
-    }
-
-    function keysym_from_charcode(codepoint) {
-
-        // Keysyms for control characters
-        if (isControlCharacter(codepoint)) return 0xFF00 | codepoint;
-
-        // Keysyms for ASCII chars
-        if (codepoint >= 0x0000 && codepoint <= 0x00FF)
-            return codepoint;
-
-        // Keysyms for Unicode
-        if (codepoint >= 0x0100 && codepoint <= 0x10FFFF)
-            return 0x01000000 | codepoint;
-
-        return null;
-
-    }
-
-    function keysym_from_keycode(keyCode, location) {
-
-        var keysyms;
-
-        // If not shifted, just return unshifted keysym
-        if (!guac_keyboard.modifiers.shift)
-            keysyms = unshiftedKeysym[keyCode];
-
-        // Otherwise, return shifted keysym, if defined
-        else
-            keysyms = shiftedKeysym[keyCode] || unshiftedKeysym[keyCode];
-
-        return get_keysym(keysyms, location);
-
-    }
-
-
-    /**
-     * Marks a key as pressed, firing the keydown event if registered. Key
-     * repeat for the pressed key will start after a delay if that key is
-     * not a modifier.
-     * @private
-     */
-    function press_key(keysym) {
-
-        // Don't bother with pressing the key if the key is unknown
-        if (keysym == null) return;
-
-        // Only press if released
-        if (!guac_keyboard.pressed[keysym]) {
-
-            // Mark key as pressed
-            guac_keyboard.pressed[keysym] = true;
-
-            // Send key event
-            if (guac_keyboard.onkeydown) {
-                guac_keyboard.onkeydown(keysym);
-
-                // Stop any current repeat
-                window.clearTimeout(key_repeat_timeout);
-                window.clearInterval(key_repeat_interval);
-
-                // Repeat after a delay as long as pressed
-                if (!no_repeat[keysym])
-                    key_repeat_timeout = window.setTimeout(function() {
-                        key_repeat_interval = window.setInterval(function() {
-                            guac_keyboard.onkeyup(keysym);
-                            guac_keyboard.onkeydown(keysym);
-                        }, 50);
-                    }, 500);
-
-
-            }
-        }
-
-    }
-
-    /**
-     * Marks a key as released, firing the keyup event if registered.
-     * @private
-     */
-    function release_key(keysym) {
-
-        // Only release if pressed
-        if (guac_keyboard.pressed[keysym]) {
-            
-            // Mark key as released
-            delete guac_keyboard.pressed[keysym];
-
-            // Stop repeat
-            window.clearTimeout(key_repeat_timeout);
-            window.clearInterval(key_repeat_interval);
-
-            // Send key event
-            if (keysym != null && guac_keyboard.onkeyup)
-                guac_keyboard.onkeyup(keysym);
-
-        }
-
-    }
-
-    function isTypable(keyIdentifier) {
-
-        // Find unicode prefix
-        var unicodePrefixLocation = keyIdentifier.indexOf("U+");
-        if (unicodePrefixLocation == -1)
-            return false;
-
-        // Parse codepoint value
-        var hex = keyIdentifier.substring(unicodePrefixLocation+2);
-        var codepoint = parseInt(hex, 16);
-
-        // If control character, not typable
-        if (isControlCharacter(codepoint)) return false;
-
-        // Otherwise, typable
-        return true;
-
-    }
-
-    /**
-     * Given a keyboard event, updates the local modifier state and remote
-     * key state based on the modifier flags within the event. This function
-     * pays no attention to keycodes.
-     * 
-     * @param {KeyboardEvent} e The keyboard event containing the flags to update.
-     */
-    function update_modifier_state(e) {
-
-        // Release alt if implicitly released
-        if (guac_keyboard.modifiers.alt && e.altKey === false) {
-            release_key(0xFFE9); // Left alt
-            release_key(0xFFEA); // Right alt (or AltGr)
-            guac_keyboard.modifiers.alt = false;
-        }
-
-        // Release shift if implicitly released
-        if (guac_keyboard.modifiers.shift && e.shiftKey === false) {
-            release_key(0xFFE1); // Left shift
-            release_key(0xFFE2); // Right shift
-            guac_keyboard.modifiers.shift = false;
-        }
-
-        // Release ctrl if implicitly released
-        if (guac_keyboard.modifiers.ctrl && e.ctrlKey === false) {
-            release_key(0xFFE3); // Left ctrl 
-            release_key(0xFFE4); // Right ctrl 
-            guac_keyboard.modifiers.ctrl = false;
-        }
-
-    }
-
-    // When key pressed
-    element.addEventListener("keydown", function(e) {
-
-        // Only intercept if handler set
-        if (!guac_keyboard.onkeydown) return;
-
-        var keynum;
-        if (window.event) keynum = window.event.keyCode;
-        else if (e.which) keynum = e.which;
-
-        // Get key location
-        var location = e.location || e.keyLocation || 0;
-
-        // Ignore any unknown key events
-        if (keynum == 0 && !e.keyIdentifier) {
-            e.preventDefault();
-            return;
-        }
-
-        // Fix modifier states
-        update_modifier_state(e);
-
-        // Ctrl/Alt/Shift/Meta
-        if      (keynum == 16) guac_keyboard.modifiers.shift = true;
-        else if (keynum == 17) guac_keyboard.modifiers.ctrl  = true;
-        else if (keynum == 18) guac_keyboard.modifiers.alt   = true;
-        else if (keynum == 91) guac_keyboard.modifiers.meta  = true;
-
-        // Try to get keysym from keycode
-        var keysym = keysym_from_keycode(keynum, location);
-
-        // By default, we expect a corresponding keypress event
-        var expect_keypress = true;
-
-        // If key is known from keycode, prevent default
-        if (keysym)
-            expect_keypress = false;
-        
-        // Also try to get get keysym from keyIdentifier
-        if (e.keyIdentifier) {
-
-            keysym = keysym ||
-            keysym_from_key_identifier(guac_keyboard.modifiers.shift,
-                e.keyIdentifier, location);
-
-            // Prevent default if non-typable character or if modifier combination
-            // likely to be eaten by browser otherwise (NOTE: We must not prevent
-            // default for Ctrl+Alt, as that combination is commonly used for
-            // AltGr. If we receive AltGr, we need to handle keypress, which
-            // means we cannot cancel keydown).
-            if (!isTypable(e.keyIdentifier)
-                || ( guac_keyboard.modifiers.ctrl && !guac_keyboard.modifiers.alt)
-                || (!guac_keyboard.modifiers.ctrl &&  guac_keyboard.modifiers.alt)
-                || (guac_keyboard.modifiers.meta))
-                expect_keypress = false;
-            
-        }
-
-        // If we do not expect to handle via keypress, handle now
-        if (!expect_keypress) {
-            e.preventDefault();
-
-            // Press key if known
-            if (keysym != null) {
-                keydownChar[keynum] = keysym;
-                press_key(keysym);
-                
-                // If a key is pressed while meta is held down, the keyup will never be sent in Chrome, so send it now. (bug #108404)
-                if(guac_keyboard.modifiers.meta) {
-                    release_key(keysym);
-                }
-            }
-            
-        }
-
-    }, true);
-
-    // When key pressed
-    element.addEventListener("keypress", function(e) {
-
-        // Only intercept if handler set
-        if (!guac_keyboard.onkeydown && !guac_keyboard.onkeyup) return;
-
-        e.preventDefault();
-
-        var keynum;
-        if (window.event) keynum = window.event.keyCode;
-        else if (e.which) keynum = e.which;
-
-        var keysym = keysym_from_charcode(keynum);
-
-        // Fix modifier states
-        update_modifier_state(e);
-
-        // If event identified as a typable character, and we're holding Ctrl+Alt,
-        // assume Ctrl+Alt is actually AltGr, and release both.
-        if (!isControlCharacter(keynum) && guac_keyboard.modifiers.ctrl && guac_keyboard.modifiers.alt) {
-            release_key(0xFFE3); // Left ctrl
-            release_key(0xFFE4); // Right ctrl
-            release_key(0xFFE9); // Left alt
-            release_key(0xFFEA); // Right alt
-        }
-
-        // Send press + release if keysym known
-        if (keysym != null) {
-            press_key(keysym);
-            release_key(keysym);
-        }
-
-    }, true);
-
-    // When key released
-    element.addEventListener("keyup", function(e) {
-
-        // Only intercept if handler set
-        if (!guac_keyboard.onkeyup) return;
-
-        e.preventDefault();
-
-        var keynum;
-        if (window.event) keynum = window.event.keyCode;
-        else if (e.which) keynum = e.which;
-        
-        // Fix modifier states
-        update_modifier_state(e);
-
-        // Ctrl/Alt/Shift/Meta
-        if      (keynum == 16) guac_keyboard.modifiers.shift = false;
-        else if (keynum == 17) guac_keyboard.modifiers.ctrl  = false;
-        else if (keynum == 18) guac_keyboard.modifiers.alt   = false;
-        else if (keynum == 91) guac_keyboard.modifiers.meta  = false;
-
-        // Send release event if original key known
-        var keydown_keysym = keydownChar[keynum];
-        if (keydown_keysym != null)
-            release_key(keydown_keysym);
-
-        // Clear character record
-        keydownChar[keynum] = null;
-
-    }, true);
-
-};
diff --git a/guacamole-common-js/src/main/resources/layer.js b/guacamole-common-js/src/main/resources/layer.js
deleted file mode 100644
index e3e8260..0000000
--- a/guacamole-common-js/src/main/resources/layer.js
+++ /dev/null
@@ -1,1210 +0,0 @@
-
-/* ***** BEGIN LICENSE BLOCK *****
- * Version: MPL 1.1/GPL 2.0/LGPL 2.1
- *
- * The contents of this file are subject to the Mozilla Public License Version
- * 1.1 (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.mozilla.org/MPL/
- *
- * Software distributed under the License is distributed on an "AS IS" basis,
- * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- * for the specific language governing rights and limitations under the
- * License.
- *
- * The Original Code is guacamole-common-js.
- *
- * The Initial Developer of the Original Code is
- * Michael Jumper.
- * Portions created by the Initial Developer are Copyright (C) 2010
- * the Initial Developer. All Rights Reserved.
- *
- * Contributor(s):
- *
- * Alternatively, the contents of this file may be used under the terms of
- * either the GNU General Public License Version 2 or later (the "GPL"), or
- * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
- * in which case the provisions of the GPL or the LGPL are applicable instead
- * of those above. If you wish to allow use of your version of this file only
- * under the terms of either the GPL or the LGPL, and not to allow others to
- * use your version of this file under the terms of the MPL, indicate your
- * decision by deleting the provisions above and replace them with the notice
- * and other provisions required by the GPL or the LGPL. If you do not delete
- * the provisions above, a recipient may use your version of this file under
- * the terms of any one of the MPL, the GPL or the LGPL.
- *
- * ***** END LICENSE BLOCK ***** */
-
-/**
- * Namespace for all Guacamole JavaScript objects.
- * @namespace
- */
-var Guacamole = Guacamole || {};
-
-/**
- * Abstract ordered drawing surface. Each Layer contains a canvas element and
- * provides simple drawing instructions for drawing to that canvas element,
- * however unlike the canvas element itself, drawing operations on a Layer are
- * guaranteed to run in order, even if such an operation must wait for an image
- * to load before completing.
- * 
- * @constructor
- * 
- * @param {Number} width The width of the Layer, in pixels. The canvas element
- *                       backing this Layer will be given this width.
- *                       
- * @param {Number} height The height of the Layer, in pixels. The canvas element
- *                        backing this Layer will be given this height.
- */
-Guacamole.Layer = function(width, height) {
-
-    /**
-     * Reference to this Layer.
-     * @private
-     */
-    var layer = this;
-
-    /**
-     * The canvas element backing this Layer.
-     * @private
-     */
-    var display = document.createElement("canvas");
-
-    /**
-     * The 2D display context of the canvas element backing this Layer.
-     * @private
-     */
-    var displayContext = display.getContext("2d");
-    displayContext.save();
-
-    /**
-     * The queue of all pending Tasks. Tasks will be run in order, with new
-     * tasks added at the end of the queue and old tasks removed from the
-     * front of the queue (FIFO).
-     * @private
-     */
-    var tasks = new Array();
-
-    /**
-     * Whether a new path should be started with the next path drawing
-     * operations.
-     * @private
-     */
-    var pathClosed = true;
-
-    /**
-     * The number of states on the state stack.
-     * 
-     * Note that there will ALWAYS be one element on the stack, but that
-     * element is not exposed. It is only used to reset the layer to its
-     * initial state.
-     * 
-     * @private
-     */
-    var stackSize = 0;
-
-    /**
-     * Map of all Guacamole channel masks to HTML5 canvas composite operation
-     * names. Not all channel mask combinations are currently implemented.
-     * @private
-     */
-    var compositeOperation = {
-     /* 0x0 NOT IMPLEMENTED */
-        0x1: "destination-in",
-        0x2: "destination-out",
-     /* 0x3 NOT IMPLEMENTED */
-        0x4: "source-in",
-     /* 0x5 NOT IMPLEMENTED */
-        0x6: "source-atop",
-     /* 0x7 NOT IMPLEMENTED */
-        0x8: "source-out",
-        0x9: "destination-atop",
-        0xA: "xor",
-        0xB: "destination-over",
-        0xC: "copy",
-     /* 0xD NOT IMPLEMENTED */
-        0xE: "source-over",
-        0xF: "lighter"
-    };
-
-    /**
-     * Resizes the canvas element backing this Layer without testing the
-     * new size. This function should only be used internally.
-     * 
-     * @private
-     * @param {Number} newWidth The new width to assign to this Layer.
-     * @param {Number} newHeight The new height to assign to this Layer.
-     */
-    function resize(newWidth, newHeight) {
-
-        // Only preserve old data if width/height are both non-zero
-        var oldData = null;
-        if (width != 0 && height != 0) {
-
-            // Create canvas and context for holding old data
-            oldData = document.createElement("canvas");
-            oldData.width = width;
-            oldData.height = height;
-
-            var oldDataContext = oldData.getContext("2d");
-
-            // Copy image data from current
-            oldDataContext.drawImage(display,
-                    0, 0, width, height,
-                    0, 0, width, height);
-
-        }
-
-        // Preserve composite operation
-        var oldCompositeOperation = displayContext.globalCompositeOperation;
-
-        // Resize canvas
-        display.width = newWidth;
-        display.height = newHeight;
-
-        // Redraw old data, if any
-        if (oldData)
-                displayContext.drawImage(oldData, 
-                    0, 0, width, height,
-                    0, 0, width, height);
-
-        // Restore composite operation
-        displayContext.globalCompositeOperation = oldCompositeOperation;
-
-        width = newWidth;
-        height = newHeight;
-
-        // Acknowledge reset of stack (happens on resize of canvas)
-        stackSize = 0;
-        displayContext.save();
-
-    }
-
-    /**
-     * Given the X and Y coordinates of the upper-left corner of a rectangle
-     * and the rectangle's width and height, resize the backing canvas element
-     * as necessary to ensure that the rectangle fits within the canvas
-     * element's coordinate space. This function will only make the canvas
-     * larger. If the rectangle already fits within the canvas element's
-     * coordinate space, the canvas is left unchanged.
-     * 
-     * @private
-     * @param {Number} x The X coordinate of the upper-left corner of the
-     *                   rectangle to fit.
-     * @param {Number} y The Y coordinate of the upper-left corner of the
-     *                   rectangle to fit.
-     * @param {Number} w The width of the the rectangle to fit.
-     * @param {Number} h The height of the the rectangle to fit.
-     */
-    function fitRect(x, y, w, h) {
-        
-        // Calculate bounds
-        var opBoundX = w + x;
-        var opBoundY = h + y;
-        
-        // Determine max width
-        var resizeWidth;
-        if (opBoundX > width)
-            resizeWidth = opBoundX;
-        else
-            resizeWidth = width;
-
-        // Determine max height
-        var resizeHeight;
-        if (opBoundY > height)
-            resizeHeight = opBoundY;
-        else
-            resizeHeight = height;
-
-        // Resize if necessary
-        if (resizeWidth != width || resizeHeight != height)
-            resize(resizeWidth, resizeHeight);
-
-    }
-
-    /**
-     * A container for an task handler. Each operation which must be ordered
-     * is associated with a Task that goes into a task queue. Tasks in this
-     * queue are executed in order once their handlers are set, while Tasks 
-     * without handlers block themselves and any following Tasks from running.
-     *
-     * @constructor
-     * @private
-     * @param {function} taskHandler The function to call when this task 
-     *                               runs, if any.
-     * @param {boolean} blocked Whether this task should start blocked.
-     */
-    function Task(taskHandler, blocked) {
-       
-        var task = this;
-       
-        /**
-         * Whether this Task is blocked.
-         * 
-         * @type boolean
-         */
-        this.blocked = blocked;
-
-        /**
-         * The handler this Task is associated with, if any.
-         * 
-         * @type function
-         */
-        this.handler = taskHandler;
-       
-        /**
-         * Unblocks this Task, allowing it to run.
-         */
-        this.unblock = function() {
-            if (task.blocked) {
-                task.blocked = false;
-
-                // Flush automatically if enabled
-                if (layer.autoflush || !flushComplete)
-                    layer.flush();
-
-            }
-        }
-
-    }
-
-    /**
-     * If no tasks are pending or running, run the provided handler immediately,
-     * if any. Otherwise, schedule a task to run immediately after all currently
-     * running or pending tasks are complete.
-     * 
-     * @private
-     * @param {function} handler The function to call when possible, if any.
-     * @param {boolean} blocked Whether the task should start blocked.
-     * @returns {Task} The Task created and added to the queue for future
-     *                 running, if any, or null if the handler was run
-     *                 immediately and no Task needed to be created.
-     */
-    function scheduleTask(handler, blocked) {
-        
-        // If no pending tasks, just call (if available) and exit
-        if (layer.autoflush && layer.isReady() && !blocked) {
-            if (handler) handler();
-            return null;
-        }
-
-        // If tasks are pending/executing, schedule a pending task
-        // and return a reference to it.
-        var task = new Task(handler, blocked);
-        tasks.push(task);
-        return task;
-        
-    }
-
-    /**
-     * Whether all previous calls to flush() have completed. If a task was
-     * waiting in the queue when flush() was called but still blocked, the
-     * queue will continue to flush outside the original flush() call until
-     * the queue is empty.
-     * 
-     * @private
-     */
-    var flushComplete = true;
-
-    /**
-     * Whether tasks are currently being actively flushed. As flush() is not
-     * reentrant, this flag prevents calls of flush() from overlapping.
-     * @private
-     */
-    var tasksInProgress = false;
-
-    /**
-     * Run any Tasks which were pending but are now ready to run and are not
-     * blocked by other Tasks.
-     */
-    this.flush = function() {
-
-        if (tasksInProgress)
-            return;
-
-        tasksInProgress = true;
-        flushComplete = false;
-
-        // Draw all pending tasks.
-        var task;
-        while ((task = tasks[0]) != null && !task.blocked) {
-            tasks.shift();
-            if (task.handler) task.handler();
-        }
-
-        // If all pending draws have been flushed
-        if (layer.isReady())
-            flushComplete = true;
-
-        tasksInProgress = false;
-
-    };
-
-    /**
-     * Schedules a task within the current layer just as scheduleTast() does,
-     * except that another specified layer will be blocked until this task
-     * completes, and this task will not start until the other layer is
-     * ready.
-     * 
-     * Essentially, a task is scheduled in both layers, and the specified task
-     * will only be performed once both layers are ready, and neither layer may
-     * proceed until this task completes.
-     * 
-     * Note that there is no way to specify whether the task starts blocked,
-     * as whether the task is blocked depends completely on whether the
-     * other layer is currently ready.
-     * 
-     * @private
-     * @param {Guacamole.Layer} otherLayer The other layer which must be blocked
-     *                          until this task completes.
-     * @param {function} handler The function to call when possible.
-     */
-    function scheduleTaskSynced(otherLayer, handler) {
-
-        // If we ARE the other layer, no need to sync.
-        // Syncing would result in deadlock.
-        if (layer === otherLayer)
-            scheduleTask(handler);
-
-        // Otherwise synchronize operation with other layer
-        else {
-
-            var drawComplete = false;
-            var layerLock = null;
-
-            function performTask() {
-
-                // Perform task
-                handler();
-
-                // Unblock the other layer now that draw is complete
-                if (layerLock != null) 
-                    layerLock.unblock();
-
-                // Flag operation as done
-                drawComplete = true;
-
-            }
-
-            // Currently blocked draw task
-            var task = scheduleTask(performTask, true);
-
-            // Unblock draw task once source layer is ready
-            otherLayer.sync(task.unblock);
-
-            // Block other layer until draw completes
-            // Note that the draw MAY have already been performed at this point,
-            // in which case creating a lock on the other layer will lead to
-            // deadlock (the draw task has already run and will thus never
-            // clear the lock)
-            if (!drawComplete)
-                layerLock = otherLayer.sync(null, true);
-
-        }
-    }
-
-    /**
-     * Set to true if this Layer should resize itself to accomodate the
-     * dimensions of any drawing operation, and false (the default) otherwise.
-     * 
-     * Note that setting this property takes effect immediately, and thus may
-     * take effect on operations that were started in the past but have not
-     * yet completed. If you wish the setting of this flag to only modify
-     * future operations, you will need to make the setting of this flag an
-     * operation with sync().
-     * 
-     * @example
-     * // Set autosize to true for all future operations
-     * layer.sync(function() {
-     *     layer.autosize = true;
-     * });
-     * 
-     * @type Boolean
-     * @default false
-     */
-    this.autosize = false;
-
-    /**
-     * Set to true to allow operations to flush automatically, instantly
-     * affecting the layer. By default, operations are buffered and only
-     * drawn when flush() is called.
-     * 
-     * @type Boolean
-     * @default false
-     */
-    this.autoflush = false;
-
-    /**
-     * Returns the canvas element backing this Layer.
-     * @returns {Element} The canvas element backing this Layer.
-     */
-    this.getCanvas = function() {
-        return display;
-    };
-
-    /**
-     * Returns whether this Layer is ready. A Layer is ready if it has no
-     * pending operations and no operations in-progress.
-     * 
-     * @returns {Boolean} true if this Layer is ready, false otherwise.
-     */
-    this.isReady = function() {
-        return tasks.length == 0;
-    };
-
-    /**
-     * Changes the size of this Layer to the given width and height. Resizing
-     * is only attempted if the new size provided is actually different from
-     * the current size.
-     * 
-     * @param {Number} newWidth The new width to assign to this Layer.
-     * @param {Number} newHeight The new height to assign to this Layer.
-     */
-    this.resize = function(newWidth, newHeight) {
-        scheduleTask(function() {
-            if (newWidth != width || newHeight != height)
-                resize(newWidth, newHeight);
-        });
-    };
-
-    /**
-     * Draws the specified image at the given coordinates. The image specified
-     * must already be loaded.
-     * 
-     * @param {Number} x The destination X coordinate.
-     * @param {Number} y The destination Y coordinate.
-     * @param {Image} image The image to draw. Note that this is an Image
-     *                      object - not a URL.
-     */
-    this.drawImage = function(x, y, image) {
-        scheduleTask(function() {
-            if (layer.autosize != 0) fitRect(x, y, image.width, image.height);
-            displayContext.drawImage(image, x, y);
-        });
-    };
-
-    /**
-     * Draws the image at the specified URL at the given coordinates. The image
-     * will be loaded automatically, and this and any future operations will
-     * wait for the image to finish loading.
-     * 
-     * @param {Number} x The destination X coordinate.
-     * @param {Number} y The destination Y coordinate.
-     * @param {String} url The URL of the image to draw.
-     */
-    this.draw = function(x, y, url) {
-
-        var task = scheduleTask(function() {
-            if (layer.autosize != 0) fitRect(x, y, image.width, image.height);
-            displayContext.drawImage(image, x, y);
-        }, true);
-
-        var image = new Image();
-        image.onload = task.unblock;
-        image.src = url;
-
-    };
-
-    /**
-     * Plays the video at the specified URL within this layer. The video
-     * will be loaded automatically, and this and any future operations will
-     * wait for the video to finish loading. Future operations will not be
-     * executed until the video finishes playing.
-     * 
-     * @param {String} mimetype The mimetype of the video to play.
-     * @param {Number} duration The duration of the video in milliseconds.
-     * @param {String} url The URL of the video to play.
-     */
-    this.play = function(mimetype, duration, url) {
-
-        // Start loading the video
-        var video = document.createElement("video");
-        video.type = mimetype;
-        video.src = url;
-
-        // Main task - playing the video
-        var task = scheduleTask(function() {
-            video.play();
-        }, true);
-
-        // Lock which will be cleared after video ends
-        var lock = scheduleTask(null, true);
-
-        // Start copying frames when playing
-        video.addEventListener("play", function() {
-            
-            function render_callback() {
-                displayContext.drawImage(video, 0, 0, width, height);
-                if (!video.ended)
-                    window.setTimeout(render_callback, 20);
-                else
-                    lock.unblock();
-            }
-            
-            render_callback();
-            
-        }, false);
-
-        // Unblock future operations after an error
-        video.addEventListener("error", lock.unblock, false);
-
-        // Play video as soon as current tasks are complete, now that the
-        // lock has been set up.
-        task.unblock();
-
-    };
-
-    /**
-     * Run an arbitrary function as soon as currently pending operations
-     * are complete.
-     * 
-     * @param {function} handler The function to call once all currently
-     *                           pending operations are complete.
-     * @param {boolean} blocked Whether the task should start blocked.
-     */
-    this.sync = scheduleTask;
-
-    /**
-     * Transfer a rectangle of image data from one Layer to this Layer using the
-     * specified transfer function.
-     * 
-     * @param {Guacamole.Layer} srcLayer The Layer to copy image data from.
-     * @param {Number} srcx The X coordinate of the upper-left corner of the
-     *                      rectangle within the source Layer's coordinate
-     *                      space to copy data from.
-     * @param {Number} srcy The Y coordinate of the upper-left corner of the
-     *                      rectangle within the source Layer's coordinate
-     *                      space to copy data from.
-     * @param {Number} srcw The width of the rectangle within the source Layer's
-     *                      coordinate space to copy data from.
-     * @param {Number} srch The height of the rectangle within the source
-     *                      Layer's coordinate space to copy data from.
-     * @param {Number} x The destination X coordinate.
-     * @param {Number} y The destination Y coordinate.
-     * @param {Function} transferFunction The transfer function to use to
-     *                                    transfer data from source to
-     *                                    destination.
-     */
-    this.transfer = function(srcLayer, srcx, srcy, srcw, srch, x, y, transferFunction) {
-        scheduleTaskSynced(srcLayer, function() {
-
-            var srcCanvas = srcLayer.getCanvas();
-
-            // If entire rectangle outside source canvas, stop
-            if (srcx >= srcCanvas.width || srcy >= srcCanvas.height) return;
-
-            // Otherwise, clip rectangle to area
-            if (srcx + srcw > srcCanvas.width)
-                srcw = srcCanvas.width - srcx;
-
-            if (srcy + srch > srcCanvas.height)
-                srch = srcCanvas.height - srcy;
-
-            // Stop if nothing to draw.
-            if (srcw == 0 || srch == 0) return;
-
-            if (layer.autosize != 0) fitRect(x, y, srcw, srch);
-
-            // Get image data from src and dst
-            var src = srcLayer.getCanvas().getContext("2d").getImageData(srcx, srcy, srcw, srch);
-            var dst = displayContext.getImageData(x , y, srcw, srch);
-
-            // Apply transfer for each pixel
-            for (var i=0; i<srcw*srch*4; i+=4) {
-
-                // Get source pixel environment
-                var src_pixel = new Guacamole.Layer.Pixel(
-                    src.data[i],
-                    src.data[i+1],
-                    src.data[i+2],
-                    src.data[i+3]
-                );
-                    
-                // Get destination pixel environment
-                var dst_pixel = new Guacamole.Layer.Pixel(
-                    dst.data[i],
-                    dst.data[i+1],
-                    dst.data[i+2],
-                    dst.data[i+3]
-                );
-
-                // Apply transfer function
-                transferFunction(src_pixel, dst_pixel);
-
-                // Save pixel data
-                dst.data[i  ] = dst_pixel.red;
-                dst.data[i+1] = dst_pixel.green;
-                dst.data[i+2] = dst_pixel.blue;
-                dst.data[i+3] = dst_pixel.alpha;
-
-            }
-
-            // Draw image data
-            displayContext.putImageData(dst, x, y);
-
-        });
-    };
-
-    /**
-     * Copy a rectangle of image data from one Layer to this Layer. This
-     * operation will copy exactly the image data that will be drawn once all
-     * operations of the source Layer that were pending at the time this
-     * function was called are complete. This operation will not alter the
-     * size of the source Layer even if its autosize property is set to true.
-     * 
-     * @param {Guacamole.Layer} srcLayer The Layer to copy image data from.
-     * @param {Number} srcx The X coordinate of the upper-left corner of the
-     *                      rectangle within the source Layer's coordinate
-     *                      space to copy data from.
-     * @param {Number} srcy The Y coordinate of the upper-left corner of the
-     *                      rectangle within the source Layer's coordinate
-     *                      space to copy data from.
-     * @param {Number} srcw The width of the rectangle within the source Layer's
-     *                      coordinate space to copy data from.
-     * @param {Number} srch The height of the rectangle within the source
-     *                      Layer's coordinate space to copy data from.
-     * @param {Number} x The destination X coordinate.
-     * @param {Number} y The destination Y coordinate.
-     */
-    this.copy = function(srcLayer, srcx, srcy, srcw, srch, x, y) {
-        scheduleTaskSynced(srcLayer, function() {
-
-            var srcCanvas = srcLayer.getCanvas();
-
-            // If entire rectangle outside source canvas, stop
-            if (srcx >= srcCanvas.width || srcy >= srcCanvas.height) return;
-
-            // Otherwise, clip rectangle to area
-            if (srcx + srcw > srcCanvas.width)
-                srcw = srcCanvas.width - srcx;
-
-            if (srcy + srch > srcCanvas.height)
-                srch = srcCanvas.height - srcy;
-
-            // Stop if nothing to draw.
-            if (srcw == 0 || srch == 0) return;
-
-            if (layer.autosize != 0) fitRect(x, y, srcw, srch);
-            displayContext.drawImage(srcCanvas, srcx, srcy, srcw, srch, x, y, srcw, srch);
-
-        });
-    };
-
-    /**
-     * Starts a new path at the specified point.
-     * 
-     * @param {Number} x The X coordinate of the point to draw.
-     * @param {Number} y The Y coordinate of the point to draw.
-     */
-    this.moveTo = function(x, y) {
-        scheduleTask(function() {
-            
-            // Start a new path if current path is closed
-            if (pathClosed) {
-                displayContext.beginPath();
-                pathClosed = false;
-            }
-            
-            if (layer.autosize != 0) fitRect(x, y, 0, 0);
-            displayContext.moveTo(x, y);
-            
-        });
-    };
-
-    /**
-     * Add the specified line to the current path.
-     * 
-     * @param {Number} x The X coordinate of the endpoint of the line to draw.
-     * @param {Number} y The Y coordinate of the endpoint of the line to draw.
-     */
-    this.lineTo = function(x, y) {
-        scheduleTask(function() {
-            
-            // Start a new path if current path is closed
-            if (pathClosed) {
-                displayContext.beginPath();
-                pathClosed = false;
-            }
-            
-            if (layer.autosize != 0) fitRect(x, y, 0, 0);
-            displayContext.lineTo(x, y);
-            
-        });
-    };
-
-    /**
-     * Add the specified arc to the current path.
-     * 
-     * @param {Number} x The X coordinate of the center of the circle which
-     *                   will contain the arc.
-     * @param {Number} y The Y coordinate of the center of the circle which
-     *                   will contain the arc.
-     * @param {Number} radius The radius of the circle.
-     * @param {Number} startAngle The starting angle of the arc, in radians.
-     * @param {Number} endAngle The ending angle of the arc, in radians.
-     * @param {Boolean} negative Whether the arc should be drawn in order of
-     *                           decreasing angle.
-     */
-    this.arc = function(x, y, radius, startAngle, endAngle, negative) {
-        scheduleTask(function() {
-            
-            // Start a new path if current path is closed
-            if (pathClosed) {
-                displayContext.beginPath();
-                pathClosed = false;
-            }
-            
-            if (layer.autosize != 0) fitRect(x, y, 0, 0);
-            displayContext.arc(x, y, radius, startAngle, endAngle, negative);
-            
-        });
-    };
-
-    /**
-     * Starts a new path at the specified point.
-     * 
-     * @param {Number} cp1x The X coordinate of the first control point.
-     * @param {Number} cp1y The Y coordinate of the first control point.
-     * @param {Number} cp2x The X coordinate of the second control point.
-     * @param {Number} cp2y The Y coordinate of the second control point.
-     * @param {Number} x The X coordinate of the endpoint of the curve.
-     * @param {Number} y The Y coordinate of the endpoint of the curve.
-     */
-    this.curveTo = function(cp1x, cp1y, cp2x, cp2y, x, y) {
-        scheduleTask(function() {
-            
-            // Start a new path if current path is closed
-            if (pathClosed) {
-                displayContext.beginPath();
-                pathClosed = false;
-            }
-            
-            if (layer.autosize != 0) fitRect(x, y, 0, 0);
-            displayContext.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y);
-            
-        });
-    };
-
-    /**
-     * Closes the current path by connecting the end point with the start
-     * point (if any) with a straight line.
-     */
-    this.close = function() {
-        scheduleTask(function() {
-            
-            // Close path
-            displayContext.closePath();
-            pathClosed = true;
-            
-        });
-    };
-
-    /**
-     * Add the specified rectangle to the current path.
-     * 
-     * @param {Number} x The X coordinate of the upper-left corner of the
-     *                   rectangle to draw.
-     * @param {Number} y The Y coordinate of the upper-left corner of the
-     *                   rectangle to draw.
-     * @param {Number} w The width of the rectangle to draw.
-     * @param {Number} h The height of the rectangle to draw.
-     */
-    this.rect = function(x, y, w, h) {
-        scheduleTask(function() {
-            
-            // Start a new path if current path is closed
-            if (pathClosed) {
-                displayContext.beginPath();
-                pathClosed = false;
-            }
-            
-            if (layer.autosize != 0) fitRect(x, y, w, h);
-            displayContext.rect(x, y, w, h);
-            
-        });
-    };
-
-    /**
-     * Clip all future drawing operations by the current path. The current path
-     * is implicitly closed. The current path can continue to be reused
-     * for other operations (such as fillColor()) but a new path will be started
-     * once a path drawing operation (path() or rect()) is used.
-     */
-    this.clip = function() {
-        scheduleTask(function() {
-
-            // Set new clipping region
-            displayContext.clip();
-
-            // Path now implicitly closed
-            pathClosed = true;
-
-        });
-    };
-
-    /**
-     * Stroke the current path with the specified color. The current path
-     * is implicitly closed. The current path can continue to be reused
-     * for other operations (such as clip()) but a new path will be started
-     * once a path drawing operation (path() or rect()) is used.
-     * 
-     * @param {String} cap The line cap style. Can be "round", "square",
-     *                     or "butt".
-     * @param {String} join The line join style. Can be "round", "bevel",
-     *                      or "miter".
-     * @param {Number} thickness The line thickness in pixels.
-     * @param {Number} r The red component of the color to fill.
-     * @param {Number} g The green component of the color to fill.
-     * @param {Number} b The blue component of the color to fill.
-     * @param {Number} a The alpha component of the color to fill.
-     */
-    this.strokeColor = function(cap, join, thickness, r, g, b, a) {
-        scheduleTask(function() {
-
-            // Stroke with color
-            displayContext.lineCap = cap;
-            displayContext.lineJoin = join;
-            displayContext.lineWidth = thickness;
-            displayContext.strokeStyle = "rgba(" + r + "," + g + "," + b + "," + a/255.0 + ")";
-            displayContext.stroke();
-
-            // Path now implicitly closed
-            pathClosed = true;
-
-        });
-    };
-
-    /**
-     * Fills the current path with the specified color. The current path
-     * is implicitly closed. The current path can continue to be reused
-     * for other operations (such as clip()) but a new path will be started
-     * once a path drawing operation (path() or rect()) is used.
-     * 
-     * @param {Number} r The red component of the color to fill.
-     * @param {Number} g The green component of the color to fill.
-     * @param {Number} b The blue component of the color to fill.
-     * @param {Number} a The alpha component of the color to fill.
-     */
-    this.fillColor = function(r, g, b, a) {
-        scheduleTask(function() {
-
-            // Fill with color
-            displayContext.fillStyle = "rgba(" + r + "," + g + "," + b + "," + a/255.0 + ")";
-            displayContext.fill();
-
-            // Path now implicitly closed
-            pathClosed = true;
-
-        });
-    };
-
-    /**
-     * Stroke the current path with the image within the specified layer. The
-     * image data will be tiled infinitely within the stroke. The current path
-     * is implicitly closed. The current path can continue to be reused
-     * for other operations (such as clip()) but a new path will be started
-     * once a path drawing operation (path() or rect()) is used.
-     * 
-     * @param {String} cap The line cap style. Can be "round", "square",
-     *                     or "butt".
-     * @param {String} join The line join style. Can be "round", "bevel",
-     *                      or "miter".
-     * @param {Number} thickness The line thickness in pixels.
-     * @param {Guacamole.Layer} srcLayer The layer to use as a repeating pattern
-     *                                   within the stroke.
-     */
-    this.strokeLayer = function(cap, join, thickness, srcLayer) {
-        scheduleTaskSynced(srcLayer, function() {
-
-            // Stroke with image data
-            displayContext.lineCap = cap;
-            displayContext.lineJoin = join;
-            displayContext.lineWidth = thickness;
-            displayContext.strokeStyle = displayContext.createPattern(
-                srcLayer.getCanvas(),
-                "repeat"
-            );
-            displayContext.stroke();
-
-            // Path now implicitly closed
-            pathClosed = true;
-
-        });
-    };
-
-    /**
-     * Fills the current path with the image within the specified layer. The
-     * image data will be tiled infinitely within the stroke. The current path
-     * is implicitly closed. The current path can continue to be reused
-     * for other operations (such as clip()) but a new path will be started
-     * once a path drawing operation (path() or rect()) is used.
-     * 
-     * @param {Guacamole.Layer} srcLayer The layer to use as a repeating pattern
-     *                                   within the fill.
-     */
-    this.fillLayer = function(srcLayer) {
-        scheduleTask(function() {
-
-            // Fill with image data 
-            displayContext.fillStyle = displayContext.createPattern(
-                srcLayer.getCanvas(),
-                "repeat"
-            );
-            displayContext.fill();
-
-            // Path now implicitly closed
-            pathClosed = true;
-
-        });
-    };
-
-    /**
-     * Push current layer state onto stack.
-     */
-    this.push = function() {
-        scheduleTask(function() {
-
-            // Save current state onto stack
-            displayContext.save();
-            stackSize++;
-
-        });
-    };
-
-    /**
-     * Pop layer state off stack.
-     */
-    this.pop = function() {
-        scheduleTask(function() {
-
-            // Restore current state from stack
-            if (stackSize > 0) {
-                displayContext.restore();
-                stackSize--;
-            }
-
-        });
-    };
-
-    /**
-     * Reset the layer, clearing the stack, the current path, and any transform
-     * matrix.
-     */
-    this.reset = function() {
-        scheduleTask(function() {
-
-            // Clear stack
-            while (stackSize > 0) {
-                displayContext.restore();
-                stackSize--;
-            }
-
-            // Restore to initial state
-            displayContext.restore();
-            displayContext.save();
-
-            // Clear path
-            displayContext.beginPath();
-            pathClosed = false;
-
-        });
-    };
-
-    /**
-     * Sets the given affine transform (defined with six values from the
-     * transform's matrix).
-     * 
-     * @param {Number} a The first value in the affine transform's matrix.
-     * @param {Number} b The second value in the affine transform's matrix.
-     * @param {Number} c The third value in the affine transform's matrix.
-     * @param {Number} d The fourth value in the affine transform's matrix.
-     * @param {Number} e The fifth value in the affine transform's matrix.
-     * @param {Number} f The sixth value in the affine transform's matrix.
-     */
-    this.setTransform = function(a, b, c, d, e, f) {
-        scheduleTask(function() {
-
-            // Set transform
-            displayContext.setTransform(
-                a, b, c,
-                d, e, f
-              /*0, 0, 1*/
-            );
-
-        });
-    };
-
-
-    /**
-     * Applies the given affine transform (defined with six values from the
-     * transform's matrix).
-     * 
-     * @param {Number} a The first value in the affine transform's matrix.
-     * @param {Number} b The second value in the affine transform's matrix.
-     * @param {Number} c The third value in the affine transform's matrix.
-     * @param {Number} d The fourth value in the affine transform's matrix.
-     * @param {Number} e The fifth value in the affine transform's matrix.
-     * @param {Number} f The sixth value in the affine transform's matrix.
-     */
-    this.transform = function(a, b, c, d, e, f) {
-        scheduleTask(function() {
-
-            // Apply transform
-            displayContext.transform(
-                a, b, c,
-                d, e, f
-              /*0, 0, 1*/
-            );
-
-        });
-    };
-
-
-    /**
-     * Sets the channel mask for future operations on this Layer.
-     * 
-     * The channel mask is a Guacamole-specific compositing operation identifier
-     * with a single bit representing each of four channels (in order): source
-     * image where destination transparent, source where destination opaque,
-     * destination where source transparent, and destination where source
-     * opaque.
-     * 
-     * @param {Number} mask The channel mask for future operations on this
-     *                      Layer.
-     */
-    this.setChannelMask = function(mask) {
-        scheduleTask(function() {
-            displayContext.globalCompositeOperation = compositeOperation[mask];
-        });
-    };
-
-    /**
-     * Sets the miter limit for stroke operations using the miter join. This
-     * limit is the maximum ratio of the size of the miter join to the stroke
-     * width. If this ratio is exceeded, the miter will not be drawn for that
-     * joint of the path.
-     * 
-     * @param {Number} limit The miter limit for stroke operations using the
-     *                       miter join.
-     */
-    this.setMiterLimit = function(limit) {
-        scheduleTask(function() {
-            displayContext.miterLimit = limit;
-        });
-    };
-
-    // Initialize canvas dimensions
-    display.width = width;
-    display.height = height;
-
-};
-
-/**
- * Channel mask for the composite operation "rout".
- */
-Guacamole.Layer.ROUT  = 0x2;
-
-/**
- * Channel mask for the composite operation "atop".
- */
-Guacamole.Layer.ATOP  = 0x6;
-
-/**
- * Channel mask for the composite operation "xor".
- */
-Guacamole.Layer.XOR   = 0xA;
-
-/**
- * Channel mask for the composite operation "rover".
- */
-Guacamole.Layer.ROVER = 0xB;
-
-/**
- * Channel mask for the composite operation "over".
- */
-Guacamole.Layer.OVER  = 0xE;
-
-/**
- * Channel mask for the composite operation "plus".
- */
-Guacamole.Layer.PLUS  = 0xF;
-
-/**
- * Channel mask for the composite operation "rin".
- * Beware that WebKit-based browsers may leave the contents of the destionation
- * layer where the source layer is transparent, despite the definition of this
- * operation.
- */
-Guacamole.Layer.RIN   = 0x1;
-
-/**
- * Channel mask for the composite operation "in".
- * Beware that WebKit-based browsers may leave the contents of the destionation
- * layer where the source layer is transparent, despite the definition of this
- * operation.
- */
-Guacamole.Layer.IN    = 0x4;
-
-/**
- * Channel mask for the composite operation "out".
- * Beware that WebKit-based browsers may leave the contents of the destionation
- * layer where the source layer is transparent, despite the definition of this
- * operation.
- */
-Guacamole.Layer.OUT   = 0x8;
-
-/**
- * Channel mask for the composite operation "ratop".
- * Beware that WebKit-based browsers may leave the contents of the destionation
- * layer where the source layer is transparent, despite the definition of this
- * operation.
- */
-Guacamole.Layer.RATOP = 0x9;
-
-/**
- * Channel mask for the composite operation "src".
- * Beware that WebKit-based browsers may leave the contents of the destionation
- * layer where the source layer is transparent, despite the definition of this
- * operation.
- */
-Guacamole.Layer.SRC   = 0xC;
-
-
-/**
- * Represents a single pixel of image data. All components have a minimum value
- * of 0 and a maximum value of 255.
- * 
- * @constructor
- * 
- * @param {Number} r The red component of this pixel.
- * @param {Number} g The green component of this pixel.
- * @param {Number} b The blue component of this pixel.
- * @param {Number} a The alpha component of this pixel.
- */
-Guacamole.Layer.Pixel = function(r, g, b, a) {
-
-    /**
-     * The red component of this pixel, where 0 is the minimum value,
-     * and 255 is the maximum.
-     */
-    this.red   = r;
-
-    /**
-     * The green component of this pixel, where 0 is the minimum value,
-     * and 255 is the maximum.
-     */
-    this.green = g;
-
-    /**
-     * The blue component of this pixel, where 0 is the minimum value,
-     * and 255 is the maximum.
-     */
-    this.blue  = b;
-
-    /**
-     * The alpha component of this pixel, where 0 is the minimum value,
-     * and 255 is the maximum.
-     */
-    this.alpha = a;
-
-};
diff --git a/guacamole-common-js/src/main/resources/mouse.js b/guacamole-common-js/src/main/resources/mouse.js
deleted file mode 100644
index 33637ff..0000000
--- a/guacamole-common-js/src/main/resources/mouse.js
+++ /dev/null
@@ -1,836 +0,0 @@
-
-/* ***** BEGIN LICENSE BLOCK *****
- * Version: MPL 1.1/GPL 2.0/LGPL 2.1
- *
- * The contents of this file are subject to the Mozilla Public License Version
- * 1.1 (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.mozilla.org/MPL/
- *
- * Software distributed under the License is distributed on an "AS IS" basis,
- * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- * for the specific language governing rights and limitations under the
- * License.
- *
- * The Original Code is guacamole-common-js.
- *
- * The Initial Developer of the Original Code is
- * Michael Jumper.
- * Portions created by the Initial Developer are Copyright (C) 2010
- * the Initial Developer. All Rights Reserved.
- *
- * Contributor(s):
- *
- * Alternatively, the contents of this file may be used under the terms of
- * either the GNU General Public License Version 2 or later (the "GPL"), or
- * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
- * in which case the provisions of the GPL or the LGPL are applicable instead
- * of those above. If you wish to allow use of your version of this file only
- * under the terms of either the GPL or the LGPL, and not to allow others to
- * use your version of this file under the terms of the MPL, indicate your
- * decision by deleting the provisions above and replace them with the notice
- * and other provisions required by the GPL or the LGPL. If you do not delete
- * the provisions above, a recipient may use your version of this file under
- * the terms of any one of the MPL, the GPL or the LGPL.
- *
- * ***** END LICENSE BLOCK ***** */
-
-/**
- * Namespace for all Guacamole JavaScript objects.
- * @namespace
- */
-var Guacamole = Guacamole || {};
-
-/**
- * Provides cross-browser mouse events for a given element. The events of
- * the given element are automatically populated with handlers that translate
- * mouse events into a non-browser-specific event provided by the
- * Guacamole.Mouse instance.
- * 
- * @constructor
- * @param {Element} element The Element to use to provide mouse events.
- */
-Guacamole.Mouse = function(element) {
-
-    /**
-     * Reference to this Guacamole.Mouse.
-     * @private
-     */
-    var guac_mouse = this;
-
-    /**
-     * The number of mousemove events to require before re-enabling mouse
-     * event handling after receiving a touch event.
-     */
-    this.touchMouseThreshold = 3;
-
-    /**
-     * The minimum amount of pixels scrolled required for a single scroll button
-     * click.
-     */
-    this.scrollThreshold = 120;
-
-    /**
-     * The number of pixels to scroll per line.
-     */
-    this.PIXELS_PER_LINE = 40;
-
-    /**
-     * The number of pixels to scroll per page.
-     */
-    this.PIXELS_PER_PAGE = 640;
-
-    /**
-     * The current mouse state. The properties of this state are updated when
-     * mouse events fire. This state object is also passed in as a parameter to
-     * the handler of any mouse events.
-     * 
-     * @type Guacamole.Mouse.State
-     */
-    this.currentState = new Guacamole.Mouse.State(
-        0, 0, 
-        false, false, false, false, false
-    );
-
-    /**
-     * Fired whenever the user presses a mouse button down over the element
-     * associated with this Guacamole.Mouse.
-     * 
-     * @event
-     * @param {Guacamole.Mouse.State} state The current mouse state.
-     */
-	this.onmousedown = null;
-
-    /**
-     * Fired whenever the user releases a mouse button down over the element
-     * associated with this Guacamole.Mouse.
-     * 
-     * @event
-     * @param {Guacamole.Mouse.State} state The current mouse state.
-     */
-	this.onmouseup = null;
-
-    /**
-     * Fired whenever the user moves the mouse over the element associated with
-     * this Guacamole.Mouse.
-     * 
-     * @event
-     * @param {Guacamole.Mouse.State} state The current mouse state.
-     */
-	this.onmousemove = null;
-
-    /**
-     * Counter of mouse events to ignore. This decremented by mousemove, and
-     * while non-zero, mouse events will have no effect.
-     * @private
-     */
-    var ignore_mouse = 0;
-
-    /**
-     * Cumulative scroll delta amount. This value is accumulated through scroll
-     * events and results in scroll button clicks if it exceeds a certain
-     * threshold.
-     */
-    var scroll_delta = 0;
-
-    function cancelEvent(e) {
-        e.stopPropagation();
-        if (e.preventDefault) e.preventDefault();
-        e.returnValue = false;
-    }
-
-    // Block context menu so right-click gets sent properly
-    element.addEventListener("contextmenu", function(e) {
-        cancelEvent(e);
-    }, false);
-
-    element.addEventListener("mousemove", function(e) {
-
-        cancelEvent(e);
-
-        // If ignoring events, decrement counter
-        if (ignore_mouse) {
-            ignore_mouse--;
-            return;
-        }
-
-        guac_mouse.currentState.fromClientPosition(element, e.clientX, e.clientY);
-
-        if (guac_mouse.onmousemove)
-            guac_mouse.onmousemove(guac_mouse.currentState);
-
-    }, false);
-
-    element.addEventListener("mousedown", function(e) {
-
-        cancelEvent(e);
-
-        // Do not handle if ignoring events
-        if (ignore_mouse)
-            return;
-
-        switch (e.button) {
-            case 0:
-                guac_mouse.currentState.left = true;
-                break;
-            case 1:
-                guac_mouse.currentState.middle = true;
-                break;
-            case 2:
-                guac_mouse.currentState.right = true;
-                break;
-        }
-
-        if (guac_mouse.onmousedown)
-            guac_mouse.onmousedown(guac_mouse.currentState);
-
-    }, false);
-
-    element.addEventListener("mouseup", function(e) {
-
-        cancelEvent(e);
-
-        // Do not handle if ignoring events
-        if (ignore_mouse)
-            return;
-
-        switch (e.button) {
-            case 0:
-                guac_mouse.currentState.left = false;
-                break;
-            case 1:
-                guac_mouse.currentState.middle = false;
-                break;
-            case 2:
-                guac_mouse.currentState.right = false;
-                break;
-        }
-
-        if (guac_mouse.onmouseup)
-            guac_mouse.onmouseup(guac_mouse.currentState);
-
-    }, false);
-
-    element.addEventListener("mouseout", function(e) {
-
-        // Get parent of the element the mouse pointer is leaving
-       	if (!e) e = window.event;
-
-        // Check that mouseout is due to actually LEAVING the element
-        var target = e.relatedTarget || e.toElement;
-        while (target != null) {
-            if (target === element)
-                return;
-            target = target.parentNode;
-        }
-
-        cancelEvent(e);
-
-        // Release all buttons
-        if (guac_mouse.currentState.left
-            || guac_mouse.currentState.middle
-            || guac_mouse.currentState.right) {
-
-            guac_mouse.currentState.left = false;
-            guac_mouse.currentState.middle = false;
-            guac_mouse.currentState.right = false;
-
-            if (guac_mouse.onmouseup)
-                guac_mouse.onmouseup(guac_mouse.currentState);
-        }
-
-    }, false);
-
-    // Override selection on mouse event element.
-    element.addEventListener("selectstart", function(e) {
-        cancelEvent(e);
-    }, false);
-
-    // Ignore all pending mouse events when touch events are the apparent source
-    function ignorePendingMouseEvents() { ignore_mouse = guac_mouse.touchMouseThreshold; }
-
-    element.addEventListener("touchmove",  ignorePendingMouseEvents, false);
-    element.addEventListener("touchstart", ignorePendingMouseEvents, false);
-    element.addEventListener("touchend",   ignorePendingMouseEvents, false);
-
-    // Scroll wheel support
-    function mousewheel_handler(e) {
-
-        // Determine approximate scroll amount (in pixels)
-        var delta = e.deltaY || -e.wheelDeltaY || -e.wheelDelta;
-
-        // If successfully retrieved scroll amount, convert to pixels if not
-        // already in pixels
-        if (delta) {
-
-            // Convert to pixels if delta was lines
-            if (e.deltaMode === 1)
-                delta = e.deltaY * guac_mouse.PIXELS_PER_LINE;
-
-            // Convert to pixels if delta was pages
-            else if (e.deltaMode === 2)
-                delta = e.deltaY * guac_mouse.PIXELS_PER_PAGE;
-
-        }
-
-        // Otherwise, assume legacy mousewheel event and line scrolling
-        else
-            delta = e.detail * guac_mouse.PIXELS_PER_LINE;
-        
-        // Update overall delta
-        scroll_delta += delta;
-
-        // Up
-        while (scroll_delta <= -guac_mouse.scrollThreshold) {
-
-            if (guac_mouse.onmousedown) {
-                guac_mouse.currentState.up = true;
-                guac_mouse.onmousedown(guac_mouse.currentState);
-            }
-
-            if (guac_mouse.onmouseup) {
-                guac_mouse.currentState.up = false;
-                guac_mouse.onmouseup(guac_mouse.currentState);
-            }
-
-            scroll_delta += guac_mouse.scrollThreshold;
-
-        }
-
-        // Down
-        while (scroll_delta >= guac_mouse.scrollThreshold) {
-
-            if (guac_mouse.onmousedown) {
-                guac_mouse.currentState.down = true;
-                guac_mouse.onmousedown(guac_mouse.currentState);
-            }
-
-            if (guac_mouse.onmouseup) {
-                guac_mouse.currentState.down = false;
-                guac_mouse.onmouseup(guac_mouse.currentState);
-            }
-
-            scroll_delta -= guac_mouse.scrollThreshold;
-
-        }
-
-        cancelEvent(e);
-
-    }
-
-    element.addEventListener('DOMMouseScroll', mousewheel_handler, false);
-    element.addEventListener('mousewheel',     mousewheel_handler, false);
-    element.addEventListener('wheel',          mousewheel_handler, false);
-
-};
-
-
-/**
- * Provides cross-browser relative touch event translation for a given element.
- * 
- * Touch events are translated into mouse events as if the touches occurred
- * on a touchpad (drag to push the mouse pointer, tap to click).
- * 
- * @constructor
- * @param {Element} element The Element to use to provide touch events.
- */
-Guacamole.Mouse.Touchpad = function(element) {
-
-    /**
-     * Reference to this Guacamole.Mouse.Touchpad.
-     * @private
-     */
-    var guac_touchpad = this;
-
-    /**
-     * The distance a two-finger touch must move per scrollwheel event, in
-     * pixels.
-     */
-    this.scrollThreshold = 20 * (window.devicePixelRatio || 1);
-
-    /**
-     * The maximum number of milliseconds to wait for a touch to end for the
-     * gesture to be considered a click.
-     */
-    this.clickTimingThreshold = 250;
-
-    /**
-     * The maximum number of pixels to allow a touch to move for the gesture to
-     * be considered a click.
-     */
-    this.clickMoveThreshold = 10 * (window.devicePixelRatio || 1);
-
-    /**
-     * The current mouse state. The properties of this state are updated when
-     * mouse events fire. This state object is also passed in as a parameter to
-     * the handler of any mouse events.
-     * 
-     * @type Guacamole.Mouse.State
-     */
-    this.currentState = new Guacamole.Mouse.State(
-        0, 0, 
-        false, false, false, false, false
-    );
-
-    /**
-     * Fired whenever a mouse button is effectively pressed. This can happen
-     * as part of a "click" gesture initiated by the user by tapping one
-     * or more fingers over the touchpad element, as part of a "scroll"
-     * gesture initiated by dragging two fingers up or down, etc.
-     * 
-     * @event
-     * @param {Guacamole.Mouse.State} state The current mouse state.
-     */
-	this.onmousedown = null;
-
-    /**
-     * Fired whenever a mouse button is effectively released. This can happen
-     * as part of a "click" gesture initiated by the user by tapping one
-     * or more fingers over the touchpad element, as part of a "scroll"
-     * gesture initiated by dragging two fingers up or down, etc.
-     * 
-     * @event
-     * @param {Guacamole.Mouse.State} state The current mouse state.
-     */
-	this.onmouseup = null;
-
-    /**
-     * Fired whenever the user moves the mouse by dragging their finger over
-     * the touchpad element.
-     * 
-     * @event
-     * @param {Guacamole.Mouse.State} state The current mouse state.
-     */
-	this.onmousemove = null;
-
-    var touch_count = 0;
-    var last_touch_x = 0;
-    var last_touch_y = 0;
-    var last_touch_time = 0;
-    var pixels_moved = 0;
-
-    var touch_buttons = {
-        1: "left",
-        2: "right",
-        3: "middle"
-    };
-
-    var gesture_in_progress = false;
-    var click_release_timeout = null;
-
-    element.addEventListener("touchend", function(e) {
-        
-        e.stopPropagation();
-        e.preventDefault();
-            
-        // If we're handling a gesture AND this is the last touch
-        if (gesture_in_progress && e.touches.length == 0) {
-            
-            var time = new Date().getTime();
-
-            // Get corresponding mouse button
-            var button = touch_buttons[touch_count];
-
-            // If mouse already down, release anad clear timeout
-            if (guac_touchpad.currentState[button]) {
-
-                // Fire button up event
-                guac_touchpad.currentState[button] = false;
-                if (guac_touchpad.onmouseup)
-                    guac_touchpad.onmouseup(guac_touchpad.currentState);
-
-                // Clear timeout, if set
-                if (click_release_timeout) {
-                    window.clearTimeout(click_release_timeout);
-                    click_release_timeout = null;
-                }
-
-            }
-
-            // If single tap detected (based on time and distance)
-            if (time - last_touch_time <= guac_touchpad.clickTimingThreshold
-                    && pixels_moved < guac_touchpad.clickMoveThreshold) {
-
-                // Fire button down event
-                guac_touchpad.currentState[button] = true;
-                if (guac_touchpad.onmousedown)
-                    guac_touchpad.onmousedown(guac_touchpad.currentState);
-
-                // Delay mouse up - mouse up should be canceled if
-                // touchstart within timeout.
-                click_release_timeout = window.setTimeout(function() {
-                    
-                    // Fire button up event
-                    guac_touchpad.currentState[button] = false;
-                    if (guac_touchpad.onmouseup)
-                        guac_touchpad.onmouseup(guac_touchpad.currentState);
-                    
-                    // Gesture now over
-                    gesture_in_progress = false;
-
-                }, guac_touchpad.clickTimingThreshold);
-
-            }
-
-            // If we're not waiting to see if this is a click, stop gesture
-            if (!click_release_timeout)
-                gesture_in_progress = false;
-
-        }
-
-    }, false);
-
-    element.addEventListener("touchstart", function(e) {
-
-        e.stopPropagation();
-        e.preventDefault();
-
-        // Track number of touches, but no more than three
-        touch_count = Math.min(e.touches.length, 3);
-
-        // Clear timeout, if set
-        if (click_release_timeout) {
-            window.clearTimeout(click_release_timeout);
-            click_release_timeout = null;
-        }
-
-        // Record initial touch location and time for touch movement
-        // and tap gestures
-        if (!gesture_in_progress) {
-
-            // Stop mouse events while touching
-            gesture_in_progress = true;
-
-            // Record touch location and time
-            var starting_touch = e.touches[0];
-            last_touch_x = starting_touch.clientX;
-            last_touch_y = starting_touch.clientY;
-            last_touch_time = new Date().getTime();
-            pixels_moved = 0;
-
-        }
-
-    }, false);
-
-    element.addEventListener("touchmove", function(e) {
-
-        e.stopPropagation();
-        e.preventDefault();
-
-        // Get change in touch location
-        var touch = e.touches[0];
-        var delta_x = touch.clientX - last_touch_x;
-        var delta_y = touch.clientY - last_touch_y;
-
-        // Track pixels moved
-        pixels_moved += Math.abs(delta_x) + Math.abs(delta_y);
-
-        // If only one touch involved, this is mouse move
-        if (touch_count == 1) {
-
-            // Calculate average velocity in Manhatten pixels per millisecond
-            var velocity = pixels_moved / (new Date().getTime() - last_touch_time);
-
-            // Scale mouse movement relative to velocity
-            var scale = 1 + velocity;
-
-            // Update mouse location
-            guac_touchpad.currentState.x += delta_x*scale;
-            guac_touchpad.currentState.y += delta_y*scale;
-
-            // Prevent mouse from leaving screen
-
-            if (guac_touchpad.currentState.x < 0)
-                guac_touchpad.currentState.x = 0;
-            else if (guac_touchpad.currentState.x >= element.offsetWidth)
-                guac_touchpad.currentState.x = element.offsetWidth - 1;
-
-            if (guac_touchpad.currentState.y < 0)
-                guac_touchpad.currentState.y = 0;
-            else if (guac_touchpad.currentState.y >= element.offsetHeight)
-                guac_touchpad.currentState.y = element.offsetHeight - 1;
-
-            // Fire movement event, if defined
-            if (guac_touchpad.onmousemove)
-                guac_touchpad.onmousemove(guac_touchpad.currentState);
-
-            // Update touch location
-            last_touch_x = touch.clientX;
-            last_touch_y = touch.clientY;
-
-        }
-
-        // Interpret two-finger swipe as scrollwheel
-        else if (touch_count == 2) {
-
-            // If change in location passes threshold for scroll
-            if (Math.abs(delta_y) >= guac_touchpad.scrollThreshold) {
-
-                // Decide button based on Y movement direction
-                var button;
-                if (delta_y > 0) button = "down";
-                else             button = "up";
-
-                // Fire button down event
-                guac_touchpad.currentState[button] = true;
-                if (guac_touchpad.onmousedown)
-                    guac_touchpad.onmousedown(guac_touchpad.currentState);
-
-                // Fire button up event
-                guac_touchpad.currentState[button] = false;
-                if (guac_touchpad.onmouseup)
-                    guac_touchpad.onmouseup(guac_touchpad.currentState);
-
-                // Only update touch location after a scroll has been
-                // detected
-                last_touch_x = touch.clientX;
-                last_touch_y = touch.clientY;
-
-            }
-
-        }
-
-    }, false);
-
-};
-
-/**
- * Provides cross-browser absolute touch event translation for a given element.
- * 
- * Touch events are translated into mouse events as if the touches occurred
- * on a touchscreen (tapping anywhere on the screen clicks at that point,
- * long-press to right-click).
- * 
- * @constructor
- * @param {Element} element The Element to use to provide touch events.
- */
-Guacamole.Mouse.Touchscreen = function(element) {
-
-    /**
-     * Reference to this Guacamole.Mouse.Touchscreen.
-     * @private
-     */
-    var guac_touchscreen = this;
-
-    /**
-     * The distance a two-finger touch must move per scrollwheel event, in
-     * pixels.
-     */
-    this.scrollThreshold = 20 * (window.devicePixelRatio || 1);
-
-    /**
-     * The current mouse state. The properties of this state are updated when
-     * mouse events fire. This state object is also passed in as a parameter to
-     * the handler of any mouse events.
-     * 
-     * @type Guacamole.Mouse.State
-     */
-    this.currentState = new Guacamole.Mouse.State(
-        0, 0, 
-        false, false, false, false, false
-    );
-
-    /**
-     * Fired whenever a mouse button is effectively pressed. This can happen
-     * as part of a "mousedown" gesture initiated by the user by pressing one
-     * finger over the touchscreen element, as part of a "scroll" gesture
-     * initiated by dragging two fingers up or down, etc.
-     * 
-     * @event
-     * @param {Guacamole.Mouse.State} state The current mouse state.
-     */
-	this.onmousedown = null;
-
-    /**
-     * Fired whenever a mouse button is effectively released. This can happen
-     * as part of a "mouseup" gesture initiated by the user by removing the
-     * finger pressed against the touchscreen element, or as part of a "scroll"
-     * gesture initiated by dragging two fingers up or down, etc.
-     * 
-     * @event
-     * @param {Guacamole.Mouse.State} state The current mouse state.
-     */
-	this.onmouseup = null;
-
-    /**
-     * Fired whenever the user moves the mouse by dragging their finger over
-     * the touchscreen element. Note that unlike Guacamole.Mouse.Touchpad,
-     * dragging a finger over the touchscreen element will always cause
-     * the mouse button to be effectively down, as if clicking-and-dragging.
-     * 
-     * @event
-     * @param {Guacamole.Mouse.State} state The current mouse state.
-     */
-	this.onmousemove = null;
-
-    element.addEventListener("touchend", function(e) {
-        
-        // Ignore if more than one touch
-        if (e.touches.length + e.changedTouches.length != 1)
-            return;
-
-        e.stopPropagation();
-        e.preventDefault();
-
-        // Release button
-        guac_touchscreen.currentState.left = false;
-
-        // Fire release event when the last touch is released, if event defined
-        if (e.touches.length == 0 && guac_touchscreen.onmouseup)
-            guac_touchscreen.onmouseup(guac_touchscreen.currentState);
-
-    }, false);
-
-    element.addEventListener("touchstart", function(e) {
-
-        // Ignore if more than one touch
-        if (e.touches.length != 1)
-            return;
-
-        e.stopPropagation();
-        e.preventDefault();
-
-        // Get touch
-        var touch = e.touches[0];
-
-        // Update state
-        guac_touchscreen.currentState.left = true;
-        guac_touchscreen.currentState.fromClientPosition(element, touch.clientX, touch.clientY);
-
-        // Fire press event, if defined
-        if (guac_touchscreen.onmousedown)
-            guac_touchscreen.onmousedown(guac_touchscreen.currentState);
-
-
-    }, false);
-
-    element.addEventListener("touchmove", function(e) {
-
-        // Ignore if more than one touch
-        if (e.touches.length != 1)
-            return;
-
-        e.stopPropagation();
-        e.preventDefault();
-
-        // Get touch
-        var touch = e.touches[0];
-
-        // Update state
-        guac_touchscreen.currentState.fromClientPosition(element, touch.clientX, touch.clientY);
-
-        // Fire movement event, if defined
-        if (guac_touchscreen.onmousemove)
-            guac_touchscreen.onmousemove(guac_touchscreen.currentState);
-
-    }, false);
-
-};
-
-/**
- * Simple container for properties describing the state of a mouse.
- * 
- * @constructor
- * @param {Number} x The X position of the mouse pointer in pixels.
- * @param {Number} y The Y position of the mouse pointer in pixels.
- * @param {Boolean} left Whether the left mouse button is pressed. 
- * @param {Boolean} middle Whether the middle mouse button is pressed. 
- * @param {Boolean} right Whether the right mouse button is pressed. 
- * @param {Boolean} up Whether the up mouse button is pressed (the fourth
- *                     button, usually part of a scroll wheel). 
- * @param {Boolean} down Whether the down mouse button is pressed (the fifth
- *                       button, usually part of a scroll wheel). 
- */
-Guacamole.Mouse.State = function(x, y, left, middle, right, up, down) {
-
-    /**
-     * Reference to this Guacamole.Mouse.State.
-     * @private
-     */
-    var guac_state = this;
-
-    /**
-     * The current X position of the mouse pointer.
-     * @type Number
-     */
-    this.x = x;
-
-    /**
-     * The current Y position of the mouse pointer.
-     * @type Number
-     */
-    this.y = y;
-
-    /**
-     * Whether the left mouse button is currently pressed.
-     * @type Boolean
-     */
-    this.left = left;
-
-    /**
-     * Whether the middle mouse button is currently pressed.
-     * @type Boolean
-     */
-    this.middle = middle
-
-    /**
-     * Whether the right mouse button is currently pressed.
-     * @type Boolean
-     */
-    this.right = right;
-
-    /**
-     * Whether the up mouse button is currently pressed. This is the fourth
-     * mouse button, associated with upward scrolling of the mouse scroll
-     * wheel.
-     * @type Boolean
-     */
-    this.up = up;
-
-    /**
-     * Whether the down mouse button is currently pressed. This is the fifth 
-     * mouse button, associated with downward scrolling of the mouse scroll
-     * wheel.
-     * @type Boolean
-     */
-    this.down = down;
-
-    /**
-     * Updates the position represented within this state object by the given
-     * element and clientX/clientY coordinates (commonly available within event
-     * objects). Position is translated from clientX/clientY (relative to
-     * viewport) to element-relative coordinates.
-     * 
-     * @param {Element} element The element the coordinates should be relative
-     *                          to.
-     * @param {Number} clientX The X coordinate to translate, viewport-relative.
-     * @param {Number} clientY The Y coordinate to translate, viewport-relative.
-     */
-    this.fromClientPosition = function(element, clientX, clientY) {
-    
-        guac_state.x = clientX - element.offsetLeft;
-        guac_state.y = clientY - element.offsetTop;
-
-        // This is all JUST so we can get the mouse position within the element
-        var parent = element.offsetParent;
-        while (parent && !(parent === document.body)) {
-            guac_state.x -= parent.offsetLeft - parent.scrollLeft;
-            guac_state.y -= parent.offsetTop  - parent.scrollTop;
-
-            parent = parent.offsetParent;
-        }
-
-        // Element ultimately depends on positioning within document body,
-        // take document scroll into account. 
-        if (parent) {
-            var documentScrollLeft = document.body.scrollLeft || document.documentElement.scrollLeft;
-            var documentScrollTop = document.body.scrollTop || document.documentElement.scrollTop;
-
-            guac_state.x -= parent.offsetLeft - documentScrollLeft;
-            guac_state.y -= parent.offsetTop  - documentScrollTop;
-        }
-
-    };
-
-};
-
diff --git a/guacamole-common-js/src/main/resources/oskeyboard.js b/guacamole-common-js/src/main/resources/oskeyboard.js
deleted file mode 100644
index 7da6b4b..0000000
--- a/guacamole-common-js/src/main/resources/oskeyboard.js
+++ /dev/null
@@ -1,653 +0,0 @@
-
-/* ***** BEGIN LICENSE BLOCK *****
- * Version: MPL 1.1/GPL 2.0/LGPL 2.1
- *
- * The contents of this file are subject to the Mozilla Public License Version
- * 1.1 (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.mozilla.org/MPL/
- *
- * Software distributed under the License is distributed on an "AS IS" basis,
- * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- * for the specific language governing rights and limitations under the
- * License.
- *
- * The Original Code is guac-common-js.
- *
- * The Initial Developer of the Original Code is
- * Michael Jumper.
- * Portions created by the Initial Developer are Copyright (C) 2010
- * the Initial Developer. All Rights Reserved.
- *
- * Contributor(s):
- *
- * Alternatively, the contents of this file may be used under the terms of
- * either the GNU General Public License Version 2 or later (the "GPL"), or
- * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
- * in which case the provisions of the GPL or the LGPL are applicable instead
- * of those above. If you wish to allow use of your version of this file only
- * under the terms of either the GPL or the LGPL, and not to allow others to
- * use your version of this file under the terms of the MPL, indicate your
- * decision by deleting the provisions above and replace them with the notice
- * and other provisions required by the GPL or the LGPL. If you do not delete
- * the provisions above, a recipient may use your version of this file under
- * the terms of any one of the MPL, the GPL or the LGPL.
- *
- * ***** END LICENSE BLOCK ***** */
-
-/**
- * Namespace for all Guacamole JavaScript objects.
- * @namespace
- */
-var Guacamole = Guacamole || {};
-
-/**
- * Dynamic on-screen keyboard. Given the URL to an XML keyboard layout file,
- * this object will download and use the XML to construct a clickable on-screen
- * keyboard with its own key events.
- * 
- * @constructor
- * @param {String} url The URL of an XML keyboard layout file.
- */
-Guacamole.OnScreenKeyboard = function(url) {
-
-    var on_screen_keyboard = this;
-
-    /**
-     * State of all modifiers. This is the bitwise OR of all active modifier
-     * values.
-     * 
-     * @private
-     */
-    var modifiers = 0;
-
-    var scaledElements = [];
-    
-    var modifier_masks = {};
-    var next_mask = 1;
-
-    /**
-     * Adds a class to an element.
-     * 
-     * @private
-     * @function
-     * @param {Element} element The element to add a class to.
-     * @param {String} classname The name of the class to add.
-     */
-    var addClass;
-
-    /**
-     * Removes a class from an element.
-     * 
-     * @private
-     * @function
-     * @param {Element} element The element to remove a class from.
-     * @param {String} classname The name of the class to remove.
-     */
-    var removeClass;
-
-    /**
-     * The number of mousemove events to require before re-enabling mouse
-     * event handling after receiving a touch event.
-     */
-    this.touchMouseThreshold = 3;
-
-    /**
-     * Counter of mouse events to ignore. This decremented by mousemove, and
-     * while non-zero, mouse events will have no effect.
-     * @private
-     */
-    var ignore_mouse = 0;
-
-    // Ignore all pending mouse events when touch events are the apparent source
-    function ignorePendingMouseEvents() { ignore_mouse = on_screen_keyboard.touchMouseThreshold; }
-
-    // If Node.classList is supported, implement addClass/removeClass using that
-    if (Node.classList) {
-
-        /** @ignore */
-        addClass = function(element, classname) {
-            element.classList.add(classname);
-        };
-        
-        /** @ignore */
-        removeClass = function(element, classname) {
-            element.classList.remove(classname);
-        };
-        
-    }
-
-    // Otherwise, implement own
-    else {
-
-        /** @ignore */
-        addClass = function(element, classname) {
-
-            // Simply add new class
-            element.className += " " + classname;
-
-        };
-        
-        /** @ignore */
-        removeClass = function(element, classname) {
-
-            // Filter out classes with given name
-            element.className = element.className.replace(/([^ ]+)[ ]*/g,
-                function(match, testClassname, spaces, offset, string) {
-
-                    // If same class, remove
-                    if (testClassname == classname)
-                        return "";
-
-                    // Otherwise, allow
-                    return match;
-                    
-                }
-            );
-
-        };
-        
-    }
-
-    // Returns a unique power-of-two value for the modifier with the
-    // given name. The same value will be returned for the same modifier.
-    function getModifierMask(name) {
-        
-        var value = modifier_masks[name];
-        if (!value) {
-
-            // Get current modifier, advance to next
-            value = next_mask;
-            next_mask <<= 1;
-
-            // Store value of this modifier
-            modifier_masks[name] = value;
-
-        }
-
-        return value;
-            
-    }
-
-    function ScaledElement(element, width, height, scaleFont) {
-
-        this.width = width;
-        this.height = height;
-
-        this.scale = function(pixels) {
-            element.style.width      = (width  * pixels) + "px";
-            element.style.height     = (height * pixels) + "px";
-
-            if (scaleFont) {
-                element.style.lineHeight = (height * pixels) + "px";
-                element.style.fontSize   = pixels + "px";
-            }
-        }
-
-    }
-
-    // For each child of element, call handler defined in next
-    function parseChildren(element, next) {
-
-        var children = element.childNodes;
-        for (var i=0; i<children.length; i++) {
-
-            // Get child node
-            var child = children[i];
-
-            // Do not parse text nodes
-            if (!child.tagName)
-                continue;
-
-            // Get handler for node
-            var handler = next[child.tagName];
-
-            // Call handler if defined
-            if (handler)
-                handler(child);
-
-            // Throw exception if no handler
-            else
-                throw new Error(
-                      "Unexpected " + child.tagName
-                    + " within " + element.tagName
-                );
-
-        }
-
-    }
-
-    // Create keyboard
-    var keyboard = document.createElement("div");
-    keyboard.className = "guac-keyboard";
-
-    // Retrieve keyboard XML
-    var xmlhttprequest = new XMLHttpRequest();
-    xmlhttprequest.open("GET", url, false);
-    xmlhttprequest.send(null);
-
-    var xml = xmlhttprequest.responseXML;
-
-    if (xml) {
-
-        function parse_row(e) {
-            
-            var row = document.createElement("div");
-            row.className = "guac-keyboard-row";
-
-            parseChildren(e, {
-                
-                "column": function(e) {
-                    row.appendChild(parse_column(e));
-                },
-                
-                "gap": function parse_gap(e) {
-
-                    // Create element
-                    var gap = document.createElement("div");
-                    gap.className = "guac-keyboard-gap";
-
-                    // Set gap size
-                    var gap_units = 1;
-                    if (e.getAttribute("size"))
-                        gap_units = parseFloat(e.getAttribute("size"));
-
-                    scaledElements.push(new ScaledElement(gap, gap_units, gap_units));
-                    row.appendChild(gap);
-
-                },
-                
-                "key": function parse_key(e) {
-                    
-                    // Create element
-                    var key_element = document.createElement("div");
-                    key_element.className = "guac-keyboard-key";
-
-                    // Append class if specified
-                    if (e.getAttribute("class"))
-                        key_element.className += " " + e.getAttribute("class");
-
-                    // Position keys using container div
-                    var key_container_element = document.createElement("div");
-                    key_container_element.className = "guac-keyboard-key-container";
-                    key_container_element.appendChild(key_element);
-
-                    // Create key
-                    var key = new Guacamole.OnScreenKeyboard.Key();
-
-                    // Set key size
-                    var key_units = 1;
-                    if (e.getAttribute("size"))
-                        key_units = parseFloat(e.getAttribute("size"));
-
-                    key.size = key_units;
-
-                    parseChildren(e, {
-                        "cap": function parse_cap(e) {
-
-                            // TODO: Handle "sticky" attribute
-                            
-                            // Get content of key cap
-                            var content = e.textContent || e.text;
-
-                            // If read as blank, assume cap is a single space.
-                            if (content.length == 0)
-                                content = " ";
-                            
-                            // Get keysym
-                            var real_keysym = null;
-                            if (e.getAttribute("keysym"))
-                                real_keysym = parseInt(e.getAttribute("keysym"));
-
-                            // If no keysym specified, try to get from key content
-                            else if (content.length == 1) {
-
-                                var charCode = content.charCodeAt(0);
-                                if (charCode >= 0x0000 && charCode <= 0x00FF)
-                                    real_keysym = charCode;
-                                else if (charCode >= 0x0100 && charCode <= 0x10FFFF)
-                                    real_keysym = 0x01000000 | charCode;
-
-                            }
-                            
-                            // Create cap
-                            var cap = new Guacamole.OnScreenKeyboard.Cap(content, real_keysym);
-
-                            if (e.getAttribute("modifier"))
-                                cap.modifier = e.getAttribute("modifier");
-                            
-                            // Create cap element
-                            var cap_element = document.createElement("div");
-                            cap_element.className = "guac-keyboard-cap";
-                            cap_element.textContent = content;
-                            key_element.appendChild(cap_element);
-
-                            // Append class if specified
-                            if (e.getAttribute("class"))
-                                cap_element.className += " " + e.getAttribute("class");
-
-                            // Get modifier value
-                            var modifierValue = 0;
-                            if (e.getAttribute("if")) {
-
-                                // Get modifier value for specified comma-delimited
-                                // list of required modifiers.
-                                var requirements = e.getAttribute("if").split(",");
-                                for (var i=0; i<requirements.length; i++) {
-                                    modifierValue |= getModifierMask(requirements[i]);
-                                    addClass(cap_element, "guac-keyboard-requires-" + requirements[i]);
-                                    addClass(key_element, "guac-keyboard-uses-" + requirements[i]);
-                                }
-
-                            }
-
-                            // Store cap
-                            key.modifierMask |= modifierValue;
-                            key.caps[modifierValue] = cap;
-
-                        }
-                    });
-
-                    scaledElements.push(new ScaledElement(key_container_element, key_units, 1, true));
-                    row.appendChild(key_container_element);
-
-                    // Set up click handler for key
-                    function press() {
-
-                        // Press key if not yet pressed
-                        if (!key.pressed) {
-
-                            addClass(key_element, "guac-keyboard-pressed");
-
-                            // Get current cap based on modifier state
-                            var cap = key.getCap(modifiers);
-
-                            // Update modifier state
-                            if (cap.modifier) {
-
-                                // Construct classname for modifier
-                                var modifierClass = "guac-keyboard-modifier-" + cap.modifier;
-                                var modifierMask = getModifierMask(cap.modifier);
-
-                                // Toggle modifier state
-                                modifiers ^= modifierMask;
-
-                                // Activate modifier if pressed
-                                if (modifiers & modifierMask) {
-                                    
-                                    addClass(keyboard, modifierClass);
-                                    
-                                    // Send key event
-                                    if (on_screen_keyboard.onkeydown && cap.keysym)
-                                        on_screen_keyboard.onkeydown(cap.keysym);
-
-                                }
-
-                                // Deactivate if not pressed
-                                else {
-
-                                    removeClass(keyboard, modifierClass);
-                                    
-                                    // Send key event
-                                    if (on_screen_keyboard.onkeyup && cap.keysym)
-                                        on_screen_keyboard.onkeyup(cap.keysym);
-
-                                }
-
-                            }
-
-                            // If not modifier, send key event now
-                            else if (on_screen_keyboard.onkeydown && cap.keysym)
-                                on_screen_keyboard.onkeydown(cap.keysym);
-
-                            // Mark key as pressed
-                            key.pressed = true;
-
-                        }
-
-                    }
-
-                    function release() {
-
-                        // Release key if currently pressed
-                        if (key.pressed) {
-
-                            // Get current cap based on modifier state
-                            var cap = key.getCap(modifiers);
-
-                            removeClass(key_element, "guac-keyboard-pressed");
-
-                            // Send key event if not a modifier key
-                            if (!cap.modifier && on_screen_keyboard.onkeyup && cap.keysym)
-                                on_screen_keyboard.onkeyup(cap.keysym);
-
-                            // Mark key as released
-                            key.pressed = false;
-
-                        }
-
-                    }
-
-                    function touchPress(e) {
-                        e.preventDefault();
-                        ignore_mouse = on_screen_keyboard.touchMouseThreshold;
-                        press();
-                    }
-
-                    function touchRelease(e) {
-                        e.preventDefault();
-                        ignore_mouse = on_screen_keyboard.touchMouseThreshold;
-                        release();
-                    }
-
-                    function mousePress(e) {
-                        e.preventDefault();
-                        if (ignore_mouse == 0)
-                            press();
-                    }
-
-                    function mouseRelease(e) {
-                        e.preventDefault();
-                        if (ignore_mouse == 0)
-                            release();
-                    }
-
-                    key_element.addEventListener("touchstart", touchPress, true);
-                    key_element.addEventListener("touchend",   touchRelease, true);
-
-                    key_element.addEventListener("mousedown", mousePress,   true);
-                    key_element.addEventListener("mouseup",   mouseRelease, true);
-                    key_element.addEventListener("mouseout",  mouseRelease, true);
-
-                }
-                
-            });
-
-            return row;
-
-        }
-
-        function parse_column(e) {
-            
-            var col = document.createElement("div");
-            col.className = "guac-keyboard-column";
-
-            if (col.getAttribute("align"))
-                col.style.textAlign = col.getAttribute("align");
-
-            // Columns can only contain rows
-            parseChildren(e, {
-                "row": function(e) {
-                    col.appendChild(parse_row(e));
-                }
-            });
-
-            return col;
-
-        }
-
-
-        // Parse document
-        var keyboard_element = xml.documentElement;
-        if (keyboard_element.tagName != "keyboard")
-            throw new Error("Root element must be keyboard");
-
-        // Get attributes
-        if (!keyboard_element.getAttribute("size"))
-            throw new Error("size attribute is required for keyboard");
-        
-        var keyboard_size = parseFloat(keyboard_element.getAttribute("size"));
-        
-        parseChildren(keyboard_element, {
-            
-            "row": function(e) {
-                keyboard.appendChild(parse_row(e));
-            },
-            
-            "column": function(e) {
-                keyboard.appendChild(parse_column(e));
-            }
-            
-        });
-
-    }
-
-    // Do not allow selection or mouse movement to propagate/register.
-    keyboard.onselectstart =
-    keyboard.onmousemove   =
-    keyboard.onmouseup     =
-    keyboard.onmousedown   =
-    function(e) {
-
-        // If ignoring events, decrement counter
-        if (ignore_mouse)
-            ignore_mouse--;
-
-        e.stopPropagation();
-        return false;
-
-    };
-
-    /**
-     * Fired whenever the user presses a key on this Guacamole.OnScreenKeyboard.
-     * 
-     * @event
-     * @param {Number} keysym The keysym of the key being pressed.
-     */
-    this.onkeydown = null;
-
-    /**
-     * Fired whenever the user releases a key on this Guacamole.OnScreenKeyboard.
-     * 
-     * @event
-     * @param {Number} keysym The keysym of the key being released.
-     */
-    this.onkeyup   = null;
-
-    /**
-     * Returns the element containing the entire on-screen keyboard.
-     * @returns {Element} The element containing the entire on-screen keyboard.
-     */
-    this.getElement = function() {
-        return keyboard;
-    };
-
-    /**
-     * Resizes all elements within this Guacamole.OnScreenKeyboard such that
-     * the width is close to but does not exceed the specified width. The
-     * height of the keyboard is determined based on the width.
-     * 
-     * @param {Number} width The width to resize this Guacamole.OnScreenKeyboard
-     *                       to, in pixels.
-     */
-    this.resize = function(width) {
-
-        // Get pixel size of a unit
-        var unit = Math.floor(width * 10 / keyboard_size) / 10;
-
-        // Resize all scaled elements
-        for (var i=0; i<scaledElements.length; i++) {
-            var scaledElement = scaledElements[i];
-            scaledElement.scale(unit)
-        }
-
-    };
-
-};
-
-
-/**
- * Basic representation of a single key of a keyboard. Each key has a set of
- * caps associated with tuples of modifiers. The cap determins what happens
- * when a key is pressed, while it is the state of modifier keys that determines
- * what cap is in effect on any particular key.
- * 
- * @constructor
- */
-Guacamole.OnScreenKeyboard.Key = function() {
-
-    var key = this;
-
-    /**
-     * Whether this key is currently pressed.
-     */
-    this.pressed = false;
-
-    /**
-     * Width of the key, relative to the size of the keyboard.
-     */
-    this.size = 1;
-
-    /**
-     * An associative map of all caps by modifier.
-     */
-    this.caps = {};
-
-    /**
-     * Bit mask with all modifiers that affect this key set.
-     */
-    this.modifierMask = 0;
-
-    /**
-     * Given the bitwise OR of all active modifiers, returns the key cap
-     * which applies.
-     */
-    this.getCap = function(modifier) {
-        return key.caps[modifier & key.modifierMask];
-    };
-
-};
-
-/**
- * Basic representation of a cap of a key. The cap is the visible part of a key
- * and determines the active behavior of a key when pressed. The state of all
- * modifiers on the keyboard determines the active cap for all keys, thus
- * each cap is associated with a set of modifiers.
- * 
- * @constructor
- * @param {String} text The text to be displayed within this cap.
- * @param {Number} keysym The keysym this cap sends when its associated key is
- *                        pressed or released.
- * @param {String} modifier The modifier represented by this cap.
- */
-Guacamole.OnScreenKeyboard.Cap = function(text, keysym, modifier) {
-    
-    /**
-     * Modifier represented by this keycap
-     */
-    this.modifier = null;
-    
-    /**
-     * The text to be displayed within this keycap
-     */
-    this.text = text;
-
-    /**
-     * The keysym this cap sends when its associated key is pressed/released
-     */
-    this.keysym = keysym;
-
-    // Set modifier if provided
-    if (modifier) this.modifier = modifier;
-    
-};
diff --git a/guacamole-common-js/src/main/resources/tunnel.js b/guacamole-common-js/src/main/resources/tunnel.js
deleted file mode 100644
index b66d830..0000000
--- a/guacamole-common-js/src/main/resources/tunnel.js
+++ /dev/null
@@ -1,832 +0,0 @@
-
-/* ***** BEGIN LICENSE BLOCK *****
- * Version: MPL 1.1/GPL 2.0/LGPL 2.1
- *
- * The contents of this file are subject to the Mozilla Public License Version
- * 1.1 (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.mozilla.org/MPL/
- *
- * Software distributed under the License is distributed on an "AS IS" basis,
- * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- * for the specific language governing rights and limitations under the
- * License.
- *
- * The Original Code is guacamole-common-js.
- *
- * The Initial Developer of the Original Code is
- * Michael Jumper.
- * Portions created by the Initial Developer are Copyright (C) 2010
- * the Initial Developer. All Rights Reserved.
- *
- * Contributor(s):
- *
- * Alternatively, the contents of this file may be used under the terms of
- * either the GNU General Public License Version 2 or later (the "GPL"), or
- * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
- * in which case the provisions of the GPL or the LGPL are applicable instead
- * of those above. If you wish to allow use of your version of this file only
- * under the terms of either the GPL or the LGPL, and not to allow others to
- * use your version of this file under the terms of the MPL, indicate your
- * decision by deleting the provisions above and replace them with the notice
- * and other provisions required by the GPL or the LGPL. If you do not delete
- * the provisions above, a recipient may use your version of this file under
- * the terms of any one of the MPL, the GPL or the LGPL.
- *
- * ***** END LICENSE BLOCK ***** */
-
-/**
- * Namespace for all Guacamole JavaScript objects.
- * @namespace
- */
-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) {};
-    
-    /**
-     * Fired whenever an error is encountered by the tunnel.
-     * 
-     * @event
-     * @param {String} message A human-readable description of the error that
-     *                         occurred.
-     */
-    this.onerror = 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;
-
-};
-
-/**
- * Guacamole Tunnel implemented over HTTP via XMLHttpRequest.
- * 
- * @constructor
- * @augments Guacamole.Tunnel
- * @param {String} tunnelURL The URL of the HTTP tunneling service.
- */
-Guacamole.HTTPTunnel = function(tunnelURL) {
-
-    /**
-     * Reference to this HTTP tunnel.
-     * @private
-     */
-    var tunnel = this;
-
-    var tunnel_uuid;
-
-    var TUNNEL_CONNECT = tunnelURL + "?connect";
-    var TUNNEL_READ    = tunnelURL + "?read:";
-    var TUNNEL_WRITE   = tunnelURL + "?write:";
-
-    var STATE_IDLE          = 0;
-    var STATE_CONNECTED     = 1;
-    var STATE_DISCONNECTED  = 2;
-
-    var currentState = STATE_IDLE;
-
-    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 = "";
-
-    this.sendMessage = function() {
-
-        // Do not attempt to send messages if not connected
-        if (currentState != STATE_CONNECTED)
-            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() {
-
-        if (outputMessageBuffer.length > 0) {
-
-            sendingMessages = true;
-
-            var message_xmlhttprequest = new XMLHttpRequest();
-            message_xmlhttprequest.open("POST", TUNNEL_WRITE + tunnel_uuid);
-            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 getHTTPTunnelErrorMessage(xmlhttprequest) {
-
-        var status = xmlhttprequest.status;
-
-        // Special cases
-        if (status == 0)   return "Disconnected";
-        if (status == 200) return "Success";
-        if (status == 403) return "Unauthorized";
-        if (status == 404) return "Connection closed"; /* While it may be more
-                                                        * accurate to say the
-                                                        * connection does not
-                                                        * exist, it is confusing
-                                                        * to the user.
-                                                        * 
-                                                        * In general, this error
-                                                        * will only happen when
-                                                        * the tunnel does not
-                                                        * exist, which happens
-                                                        * after the connection
-                                                        * is closed and the
-                                                        * tunnel is detached.
-                                                        */
-        // Internal server errors
-        if (status >= 500 && status <= 599) return "Server error";
-
-        // Otherwise, unknown
-        return "Unknown error";
-
-    }
-
-    function handleHTTPTunnelError(xmlhttprequest) {
-
-        // Get error message
-        var message = getHTTPTunnelErrorMessage(xmlhttprequest);
-
-        // Call error handler
-        if (tunnel.onerror) tunnel.onerror(message);
-
-        // Finish
-        tunnel.disconnect();
-
-    }
-
-
-    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 (currentState != STATE_CONNECTED) {
-                
-                // 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 == null && status == 200)
-                nextRequest = makeRequest();
-
-            // Parse stream when data is received and when complete.
-            if (xmlhttprequest.readyState == 3 ||
-                xmlhttprequest.readyState == 4) {
-
-                // Also poll every 30ms (some browsers don't repeatedly call onreadystatechange for new data)
-                if (pollingMode == POLLING_ENABLED) {
-                    if (xmlhttprequest.readyState == 3 && interval == null)
-                        interval = setInterval(parseResponse, 30);
-                    else if (xmlhttprequest.readyState == 4 && interval != null)
-                        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 != null)
-                                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 != null)
-                                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.send(null);
-
-        return xmlhttprequest;
-
-    }
-
-    this.connect = function(data) {
-
-        // Start tunnel and connect synchronously
-        var connect_xmlhttprequest = new XMLHttpRequest();
-        connect_xmlhttprequest.open("POST", TUNNEL_CONNECT, false);
-        connect_xmlhttprequest.setRequestHeader("Content-type", "application/x-www-form-urlencoded; charset=UTF-8");
-        connect_xmlhttprequest.send(data);
-
-        // If failure, throw error
-        if (connect_xmlhttprequest.status != 200) {
-            var message = getHTTPTunnelErrorMessage(connect_xmlhttprequest);
-            throw new Error(message);
-        }
-
-        // Get UUID from response
-        tunnel_uuid = connect_xmlhttprequest.responseText;
-
-        // Start reading data
-        currentState = STATE_CONNECTED;
-        handleResponse(makeRequest());
-
-    };
-
-    this.disconnect = function() {
-        currentState = STATE_DISCONNECTED;
-    };
-
-};
-
-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 WebSocket protocol corresponding to the protocol used for the current
-     * location.
-     * @private
-     */
-    var ws_protocol = {
-        "http:":  "ws:",
-        "https:": "wss:"
-    };
-
-    var status_code = {
-        1000: "Connection closed normally.",
-        1001: "Connection shut down.",
-        1002: "Protocol error.",
-        1003: "Invalid data.",
-        1004: "[UNKNOWN, RESERVED]",
-        1005: "No status code present.",
-        1006: "Connection closed abnormally.",
-        1007: "Inconsistent data type.",
-        1008: "Policy violation.",
-        1009: "Message too large.",
-        1010: "Extension negotiation failed."
-    };
-
-    var STATE_IDLE          = 0;
-    var STATE_CONNECTED     = 1;
-    var STATE_DISCONNECTED  = 2;
-
-    var currentState = STATE_IDLE;
-    
-    // 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;
-
-        }
-
-    }
-
-    this.sendMessage = function(elements) {
-
-        // Do not attempt to send messages if not connected
-        if (currentState != STATE_CONNECTED)
-            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) {
-
-        // Connect socket
-        socket = new WebSocket(tunnelURL + "?" + data, "guacamole");
-
-        socket.onopen = function(event) {
-            currentState = STATE_CONNECTED;
-        };
-
-        socket.onclose = function(event) {
-
-            // If connection closed abnormally, signal error.
-            if (event.code != 1000 && tunnel.onerror)
-                tunnel.onerror(status_code[event.code]);
-
-        };
-        
-        socket.onerror = function(event) {
-
-            // Call error handler
-            if (tunnel.onerror) tunnel.onerror(event.data);
-
-        };
-
-        socket.onmessage = function(event) {
-
-            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
-                    throw new 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();
-
-                    // Call instruction handler.
-                    if (tunnel.oninstruction != null)
-                        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() {
-        currentState = STATE_DISCONNECTED;
-        socket.close();
-    };
-
-};
-
-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 {...} tunnel_chain The tunnels to use, in order of priority.
- */
-Guacamole.ChainedTunnel = function(tunnel_chain) {
-
-    /**
-     * Reference to this chained tunnel.
-     * @private
-     */
-    var chained_tunnel = this;
-
-    /**
-     * The currently wrapped tunnel, if any.
-     * @private
-     */
-    var current_tunnel = null;
-
-    /**
-     * 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 = [];
-
-    // 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) {
-
-        // Clear handlers of current tunnel, if any
-        if (current_tunnel) {
-            current_tunnel.onerror = null;
-            current_tunnel.oninstruction = null;
-        }
-
-        // Set own functions to tunnel's functions
-        chained_tunnel.disconnect    = tunnel.disconnect;
-        chained_tunnel.sendMessage   = tunnel.sendMessage;
-        
-        // Record current tunnel
-        current_tunnel = tunnel;
-
-        // Wrap own oninstruction within current tunnel
-        current_tunnel.oninstruction = function(opcode, elements) {
-            
-            // Invoke handler
-            chained_tunnel.oninstruction(opcode, elements);
-
-            // Use handler permanently from now on
-            current_tunnel.oninstruction = chained_tunnel.oninstruction;
-
-            // Pass through errors (without trying other tunnels)
-            current_tunnel.onerror = chained_tunnel.onerror;
-            
-        }
-
-        // Attach next tunnel on error
-        current_tunnel.onerror = function(message) {
-
-            // Get next tunnel
-            var next_tunnel = tunnels.shift();
-
-            // If there IS a next tunnel, try using it.
-            if (next_tunnel)
-                attach(next_tunnel);
-
-            // Otherwise, call error handler
-            else if (chained_tunnel.onerror)
-                chained_tunnel.onerror(message);
-
-        };
-
-        try {
-            
-            // Attempt connection
-            current_tunnel.connect(connect_data);
-            
-        }
-        catch (e) {
-            
-            // Call error handler of current tunnel on error
-            current_tunnel.onerror(e.message);
-            
-        }
-
-
-    }
-
-    this.connect = function(data) {
-       
-        // Remember connect data
-        connect_data = data;
-
-        // Get first tunnel
-        var next_tunnel = 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("No tunnels to try.");
-
-    };
-    
-};
-
-Guacamole.ChainedTunnel.prototype = new Guacamole.Tunnel();
diff --git a/guacamole-common-js/src/main/webapp/common/license.js b/guacamole-common-js/src/main/webapp/common/license.js
new file mode 100644
index 0000000..2d7de92
--- /dev/null
+++ b/guacamole-common-js/src/main/webapp/common/license.js
@@ -0,0 +1,23 @@
+/*! (C) 2014 Glyptodon LLC - glyptodon.org/MIT-LICENSE */
+
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
diff --git a/guacamole-common-js/src/main/webapp/modules/ArrayBufferReader.js b/guacamole-common-js/src/main/webapp/modules/ArrayBufferReader.js
new file mode 100644
index 0000000..d022d4c
--- /dev/null
+++ b/guacamole-common-js/src/main/webapp/modules/ArrayBufferReader.js
@@ -0,0 +1,79 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+var Guacamole = Guacamole || {};
+
+/**
+ * A reader which automatically handles the given input stream, returning
+ * strictly received packets as array buffers. Note that this object will
+ * overwrite any installed event handlers on the given Guacamole.InputStream.
+ * 
+ * @constructor
+ * @param {Guacamole.InputStream} stream The stream that data will be read
+ *                                       from.
+ */
+Guacamole.ArrayBufferReader = function(stream) {
+
+    /**
+     * Reference to this Guacamole.InputStream.
+     * @private
+     */
+    var guac_reader = this;
+
+    // Receive blobs as array buffers
+    stream.onblob = function(data) {
+
+        // Convert to ArrayBuffer
+        var binary = window.atob(data);
+        var arrayBuffer = new ArrayBuffer(binary.length);
+        var bufferView = new Uint8Array(arrayBuffer);
+
+        for (var i=0; i<binary.length; i++)
+            bufferView[i] = binary.charCodeAt(i);
+
+        // Call handler, if present
+        if (guac_reader.ondata)
+            guac_reader.ondata(arrayBuffer);
+
+    };
+
+    // Simply call onend when end received
+    stream.onend = function() {
+        if (guac_reader.onend)
+            guac_reader.onend();
+    };
+
+    /**
+     * Fired once for every blob of data received.
+     * 
+     * @event
+     * @param {ArrayBuffer} buffer The data packet received.
+     */
+    this.ondata = null;
+
+    /**
+     * Fired once this stream is finished and no further data will be written.
+     * @event
+     */
+    this.onend = null;
+
+};
\ No newline at end of file
diff --git a/guacamole-common-js/src/main/webapp/modules/ArrayBufferWriter.js b/guacamole-common-js/src/main/webapp/modules/ArrayBufferWriter.js
new file mode 100644
index 0000000..bee5375
--- /dev/null
+++ b/guacamole-common-js/src/main/webapp/modules/ArrayBufferWriter.js
@@ -0,0 +1,103 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+var Guacamole = Guacamole || {};
+
+/**
+ * A writer which automatically writes to the given output stream with arbitrary
+ * binary data, supplied as ArrayBuffers.
+ * 
+ * @constructor
+ * @param {Guacamole.OutputStream} stream The stream that data will be written
+ *                                        to.
+ */
+Guacamole.ArrayBufferWriter = function(stream) {
+
+    /**
+     * Reference to this Guacamole.StringWriter.
+     * @private
+     */
+    var guac_writer = this;
+
+    // Simply call onack for acknowledgements
+    stream.onack = function(status) {
+        if (guac_writer.onack)
+            guac_writer.onack(status);
+    };
+
+    /**
+     * Encodes the given data as base64, sending it as a blob. The data must
+     * be small enough to fit into a single blob instruction.
+     * 
+     * @private
+     * @param {Uint8Array} bytes The data to send.
+     */
+    function __send_blob(bytes) {
+
+        var binary = "";
+
+        // Produce binary string from bytes in buffer
+        for (var i=0; i<bytes.byteLength; i++)
+            binary += String.fromCharCode(bytes[i]);
+
+        // Send as base64
+        stream.sendBlob(window.btoa(binary));
+
+    }
+
+    /**
+     * Sends the given data.
+     * 
+     * @param {ArrayBuffer|TypedArray} data The data to send.
+     */
+    this.sendData = function(data) {
+
+        var bytes = new Uint8Array(data);
+
+        // If small enough to fit into single instruction, send as-is
+        if (bytes.length <= 8064)
+            __send_blob(bytes);
+
+        // Otherwise, send as multiple instructions
+        else {
+            for (var offset=0; offset<bytes.length; offset += 8064)
+                __send_blob(bytes.subarray(offset, offset + 8094));
+        }
+
+    };
+
+    /**
+     * Signals that no further text will be sent, effectively closing the
+     * stream.
+     */
+    this.sendEnd = function() {
+        stream.sendEnd();
+    };
+
+    /**
+     * Fired for received data, if acknowledged by the server.
+     * @event
+     * @param {Guacamole.Status} status The status of the operation.
+     */
+    this.onack = null;
+
+};
\ No newline at end of file
diff --git a/guacamole-common-js/src/main/webapp/modules/AudioPlayer.js b/guacamole-common-js/src/main/webapp/modules/AudioPlayer.js
new file mode 100644
index 0000000..760851a
--- /dev/null
+++ b/guacamole-common-js/src/main/webapp/modules/AudioPlayer.js
@@ -0,0 +1,653 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+var Guacamole = Guacamole || {};
+
+/**
+ * Abstract audio player which accepts, queues and plays back arbitrary audio
+ * data. It is up to implementations of this class to provide some means of
+ * handling a provided Guacamole.InputStream. Data received along the provided
+ * stream is to be played back immediately.
+ *
+ * @constructor
+ */
+Guacamole.AudioPlayer = function AudioPlayer() {
+
+    /**
+     * Notifies this Guacamole.AudioPlayer that all audio up to the current
+     * point in time has been given via the underlying stream, and that any
+     * difference in time between queued audio data and the current time can be
+     * considered latency.
+     */
+    this.sync = function sync() {
+        // Default implementation - do nothing
+    };
+
+};
+
+/**
+ * Determines whether the given mimetype is supported by any built-in
+ * implementation of Guacamole.AudioPlayer, and thus will be properly handled
+ * by Guacamole.AudioPlayer.getInstance().
+ *
+ * @param {String} mimetype
+ *     The mimetype to check.
+ *
+ * @returns {Boolean}
+ *     true if the given mimetype is supported by any built-in
+ *     Guacamole.AudioPlayer, false otherwise.
+ */
+Guacamole.AudioPlayer.isSupportedType = function isSupportedType(mimetype) {
+
+    return Guacamole.RawAudioPlayer.isSupportedType(mimetype);
+
+};
+
+/**
+ * Returns a list of all mimetypes supported by any built-in
+ * Guacamole.AudioPlayer, in rough order of priority. Beware that only the core
+ * mimetypes themselves will be listed. Any mimetype parameters, even required
+ * ones, will not be included in the list. For example, "audio/L8" is a
+ * supported raw audio mimetype that is supported, but it is invalid without
+ * additional parameters. Something like "audio/L8;rate=44100" would be valid,
+ * however (see https://tools.ietf.org/html/rfc4856).
+ *
+ * @returns {String[]}
+ *     A list of all mimetypes supported by any built-in Guacamole.AudioPlayer,
+ *     excluding any parameters.
+ */
+Guacamole.AudioPlayer.getSupportedTypes = function getSupportedTypes() {
+
+    return Guacamole.RawAudioPlayer.getSupportedTypes();
+
+};
+
+/**
+ * Returns an instance of Guacamole.AudioPlayer providing support for the given
+ * audio format. If support for the given audio format is not available, null
+ * is returned.
+ *
+ * @param {Guacamole.InputStream} stream
+ *     The Guacamole.InputStream to read audio data from.
+ *
+ * @param {String} mimetype
+ *     The mimetype of the audio data in the provided stream.
+ *
+ * @return {Guacamole.AudioPlayer}
+ *     A Guacamole.AudioPlayer instance supporting the given mimetype and
+ *     reading from the given stream, or null if support for the given mimetype
+ *     is absent.
+ */
+Guacamole.AudioPlayer.getInstance = function getInstance(stream, mimetype) {
+
+    // Use raw audio player if possible
+    if (Guacamole.RawAudioPlayer.isSupportedType(mimetype))
+        return new Guacamole.RawAudioPlayer(stream, mimetype);
+
+    // No support for given mimetype
+    return null;
+
+};
+
+/**
+ * Implementation of Guacamole.AudioPlayer providing support for raw PCM format
+ * audio. This player relies only on the Web Audio API and does not require any
+ * browser-level support for its audio formats.
+ *
+ * @constructor
+ * @augments Guacamole.AudioPlayer
+ * @param {Guacamole.InputStream} stream
+ *     The Guacamole.InputStream to read audio data from.
+ *
+ * @param {String} mimetype
+ *     The mimetype of the audio data in the provided stream, which must be a
+ *     "audio/L8" or "audio/L16" mimetype with necessary parameters, such as:
+ *     "audio/L16;rate=44100,channels=2".
+ */
+Guacamole.RawAudioPlayer = function RawAudioPlayer(stream, mimetype) {
+
+    /**
+     * The format of audio this player will decode.
+     *
+     * @private
+     * @type {Guacamole.RawAudioPlayer._Format}
+     */
+    var format = Guacamole.RawAudioPlayer._Format.parse(mimetype);
+
+    /**
+     * An instance of a Web Audio API AudioContext object, or null if the
+     * Web Audio API is not supported.
+     *
+     * @private
+     * @type {AudioContext}
+     */
+    var context = (function getAudioContext() {
+
+        // Fallback to Webkit-specific AudioContext implementation
+        var AudioContext = window.AudioContext || window.webkitAudioContext;
+
+        // Get new AudioContext instance if Web Audio API is supported
+        if (AudioContext) {
+            try {
+                return new AudioContext();
+            }
+            catch (e) {
+                // Do not use Web Audio API if not allowed by browser
+            }
+        }
+
+        // Web Audio API not supported
+        return null;
+
+    })();
+
+    /**
+     * The earliest possible time that the next packet could play without
+     * overlapping an already-playing packet, in seconds. Note that while this
+     * value is in seconds, it is not an integer value and has microsecond
+     * resolution.
+     *
+     * @private
+     * @type {Number}
+     */
+    var nextPacketTime = context.currentTime;
+
+    /**
+     * Guacamole.ArrayBufferReader wrapped around the audio input stream
+     * provided with this Guacamole.RawAudioPlayer was created.
+     *
+     * @private
+     * @type {Guacamole.ArrayBufferReader}
+     */
+    var reader = new Guacamole.ArrayBufferReader(stream);
+
+    /**
+     * The minimum size of an audio packet split by splitAudioPacket(), in
+     * seconds. Audio packets smaller than this will not be split, nor will the
+     * split result of a larger packet ever be smaller in size than this
+     * minimum.
+     *
+     * @private
+     * @constant
+     * @type {Number}
+     */
+    var MIN_SPLIT_SIZE = 0.02;
+
+    /**
+     * The maximum amount of latency to allow between the buffered data stream
+     * and the playback position, in seconds. Initially, this is set to
+     * roughly one third of a second.
+     *
+     * @private
+     * @type {Number}
+     */
+    var maxLatency = 0.3;
+
+    /**
+     * The type of typed array that will be used to represent each audio packet
+     * internally. This will be either Int8Array or Int16Array, depending on
+     * whether the raw audio format is 8-bit or 16-bit.
+     *
+     * @private
+     * @constructor
+     */
+    var SampleArray = (format.bytesPerSample === 1) ? window.Int8Array : window.Int16Array;
+
+    /**
+     * The maximum absolute value of any sample within a raw audio packet
+     * received by this audio player. This depends only on the size of each
+     * sample, and will be 128 for 8-bit audio and 32768 for 16-bit audio.
+     *
+     * @private
+     * @type {Number}
+     */
+    var maxSampleValue = (format.bytesPerSample === 1) ? 128 : 32768;
+
+    /**
+     * The queue of all pending audio packets, as an array of sample arrays.
+     * Audio packets which are pending playback will be added to this queue for
+     * further manipulation prior to scheduling via the Web Audio API. Once an
+     * audio packet leaves this queue and is scheduled via the Web Audio API,
+     * no further modifications can be made to that packet.
+     *
+     * @private
+     * @type {SampleArray[]}
+     */
+    var packetQueue = [];
+
+    /**
+     * Given an array of audio packets, returns a single audio packet
+     * containing the concatenation of those packets.
+     *
+     * @private
+     * @param {SampleArray[]} packets
+     *     The array of audio packets to concatenate.
+     *
+     * @returns {SampleArray}
+     *     A single audio packet containing the concatenation of all given
+     *     audio packets. If no packets are provided, this will be undefined.
+     */
+    var joinAudioPackets = function joinAudioPackets(packets) {
+
+        // Do not bother joining if one or fewer packets are in the queue
+        if (packets.length <= 1)
+            return packets[0];
+
+        // Determine total sample length of the entire queue
+        var totalLength = 0;
+        packets.forEach(function addPacketLengths(packet) {
+            totalLength += packet.length;
+        });
+
+        // Append each packet within queue
+        var offset = 0;
+        var joined = new SampleArray(totalLength);
+        packets.forEach(function appendPacket(packet) {
+            joined.set(packet, offset);
+            offset += packet.length;
+        });
+
+        return joined;
+
+    };
+
+    /**
+     * Given a single packet of audio data, splits off an arbitrary length of
+     * audio data from the beginning of that packet, returning the split result
+     * as an array of two packets. The split location is determined through an
+     * algorithm intended to minimize the liklihood of audible clicking between
+     * packets. If no such split location is possible, an array containing only
+     * the originally-provided audio packet is returned.
+     *
+     * @private
+     * @param {SampleArray} data
+     *     The audio packet to split.
+     *
+     * @returns {SampleArray[]}
+     *     An array of audio packets containing the result of splitting the
+     *     provided audio packet. If splitting is possible, this array will
+     *     contain two packets. If splitting is not possible, this array will
+     *     contain only the originally-provided packet.
+     */
+    var splitAudioPacket = function splitAudioPacket(data) {
+
+        var minValue = Number.MAX_VALUE;
+        var optimalSplitLength = data.length;
+
+        // Calculate number of whole samples in the provided audio packet AND
+        // in the minimum possible split packet
+        var samples = Math.floor(data.length / format.channels);
+        var minSplitSamples = Math.floor(format.rate * MIN_SPLIT_SIZE);
+
+        // Calculate the beginning of the "end" of the audio packet
+        var start = Math.max(
+            format.channels * minSplitSamples,
+            format.channels * (samples - minSplitSamples)
+        );
+
+        // For all samples at the end of the given packet, find a point where
+        // the perceptible volume across all channels is lowest (and thus is
+        // the optimal point to split)
+        for (var offset = start; offset < data.length; offset += format.channels) {
+
+            // Calculate the sum of all values across all channels (the result
+            // will be proportional to the average volume of a sample)
+            var totalValue = 0;
+            for (var channel = 0; channel < format.channels; channel++) {
+                totalValue += Math.abs(data[offset + channel]);
+            }
+
+            // If this is the smallest average value thus far, set the split
+            // length such that the first packet ends with the current sample
+            if (totalValue <= minValue) {
+                optimalSplitLength = offset + format.channels;
+                minValue = totalValue;
+            }
+
+        }
+
+        // If packet is not split, return the supplied packet untouched
+        if (optimalSplitLength === data.length)
+            return [data];
+
+        // Otherwise, split the packet into two new packets according to the
+        // calculated optimal split length
+        return [
+            new SampleArray(data.buffer.slice(0, optimalSplitLength * format.bytesPerSample)),
+            new SampleArray(data.buffer.slice(optimalSplitLength * format.bytesPerSample))
+        ];
+
+    };
+
+    /**
+     * Pushes the given packet of audio data onto the playback queue. Unlike
+     * other private functions within Guacamole.RawAudioPlayer, the type of the
+     * ArrayBuffer packet of audio data here need not be specific to the type
+     * of audio (as with SampleArray). The ArrayBuffer type provided by a
+     * Guacamole.ArrayBufferReader, for example, is sufficient. Any necessary
+     * conversions will be performed automatically internally.
+     *
+     * @private
+     * @param {ArrayBuffer} data
+     *     A raw packet of audio data that should be pushed onto the audio
+     *     playback queue.
+     */
+    var pushAudioPacket = function pushAudioPacket(data) {
+        packetQueue.push(new SampleArray(data));
+    };
+
+    /**
+     * Shifts off and returns a packet of audio data from the beginning of the
+     * playback queue. The length of this audio packet is determined
+     * dynamically according to the click-reduction algorithm implemented by
+     * splitAudioPacket().
+     *
+     * @private
+     * @returns {SampleArray}
+     *     A packet of audio data pulled from the beginning of the playback
+     *     queue.
+     */
+    var shiftAudioPacket = function shiftAudioPacket() {
+
+        // Flatten data in packet queue
+        var data = joinAudioPackets(packetQueue);
+        if (!data)
+            return null;
+
+        // Pull an appropriate amount of data from the front of the queue
+        packetQueue = splitAudioPacket(data);
+        data = packetQueue.shift();
+
+        return data;
+
+    };
+
+    /**
+     * Converts the given audio packet into an AudioBuffer, ready for playback
+     * by the Web Audio API. Unlike the raw audio packets received by this
+     * audio player, AudioBuffers require floating point samples and are split
+     * into isolated planes of channel-specific data.
+     *
+     * @private
+     * @param {SampleArray} data
+     *     The raw audio packet that should be converted into a Web Audio API
+     *     AudioBuffer.
+     *
+     * @returns {AudioBuffer}
+     *     A new Web Audio API AudioBuffer containing the provided audio data,
+     *     converted to the format used by the Web Audio API.
+     */
+    var toAudioBuffer = function toAudioBuffer(data) {
+
+        // Calculate total number of samples
+        var samples = data.length / format.channels;
+
+        // Determine exactly when packet CAN play
+        var packetTime = context.currentTime;
+        if (nextPacketTime < packetTime)
+            nextPacketTime = packetTime;
+
+        // Get audio buffer for specified format
+        var audioBuffer = context.createBuffer(format.channels, samples, format.rate);
+
+        // Convert each channel
+        for (var channel = 0; channel < format.channels; channel++) {
+
+            var audioData = audioBuffer.getChannelData(channel);
+
+            // Fill audio buffer with data for channel
+            var offset = channel;
+            for (var i = 0; i < samples; i++) {
+                audioData[i] = data[offset] / maxSampleValue;
+                offset += format.channels;
+            }
+
+        }
+
+        return audioBuffer;
+
+    };
+
+    // Defer playback of received audio packets slightly
+    reader.ondata = function playReceivedAudio(data) {
+
+        // Push received samples onto queue
+        pushAudioPacket(new SampleArray(data));
+
+        // Shift off an arbitrary packet of audio data from the queue (this may
+        // be different in size from the packet just pushed)
+        var packet = shiftAudioPacket();
+        if (!packet)
+            return;
+
+        // Determine exactly when packet CAN play
+        var packetTime = context.currentTime;
+        if (nextPacketTime < packetTime)
+            nextPacketTime = packetTime;
+
+        // Set up buffer source
+        var source = context.createBufferSource();
+        source.connect(context.destination);
+
+        // Use noteOn() instead of start() if necessary
+        if (!source.start)
+            source.start = source.noteOn;
+
+        // Schedule packet
+        source.buffer = toAudioBuffer(packet);
+        source.start(nextPacketTime);
+
+        // Update timeline by duration of scheduled packet
+        nextPacketTime += packet.length / format.channels / format.rate;
+
+    };
+
+    /** @override */
+    this.sync = function sync() {
+
+        // Calculate elapsed time since last sync
+        var now = context.currentTime;
+
+        // Reschedule future playback time such that playback latency is
+        // bounded within a reasonable latency threshold
+        nextPacketTime = Math.min(nextPacketTime, now + maxLatency);
+
+    };
+
+};
+
+Guacamole.RawAudioPlayer.prototype = new Guacamole.AudioPlayer();
+
+/**
+ * A description of the format of raw PCM audio received by a
+ * Guacamole.RawAudioPlayer. This object describes the number of bytes per
+ * sample, the number of channels, and the overall sample rate.
+ *
+ * @private
+ * @constructor
+ * @param {Guacamole.RawAudioPlayer._Format|Object} template
+ *     The object whose properties should be copied into the corresponding
+ *     properties of the new Guacamole.RawAudioPlayer._Format.
+ */
+Guacamole.RawAudioPlayer._Format = function _Format(template) {
+
+    /**
+     * The number of bytes in each sample of audio data. This value is
+     * independent of the number of channels.
+     *
+     * @type {Number}
+     */
+    this.bytesPerSample = template.bytesPerSample;
+
+    /**
+     * The number of audio channels (ie: 1 for mono, 2 for stereo).
+     *
+     * @type {Number}
+     */
+    this.channels = template.channels;
+
+    /**
+     * The number of samples per second, per channel.
+     *
+     * @type {Number}
+     */
+    this.rate = template.rate;
+
+};
+
+/**
+ * Parses the given mimetype, returning a new Guacamole.RawAudioPlayer._Format
+ * which describes the type of raw audio data represented by that mimetype. If
+ * the mimetype is not supported by Guacamole.RawAudioPlayer, null is returned.
+ *
+ * @private
+ * @param {String} mimetype
+ *     The audio mimetype to parse.
+ *
+ * @returns {Guacamole.RawAudioPlayer._Format}
+ *     A new Guacamole.RawAudioPlayer._Format which describes the type of raw
+ *     audio data represented by the given mimetype, or null if the given
+ *     mimetype is not supported.
+ */
+Guacamole.RawAudioPlayer._Format.parse = function parseFormat(mimetype) {
+
+    var bytesPerSample;
+
+    // Rate is absolutely required - if null is still present later, the
+    // mimetype must not be supported
+    var rate = null;
+
+    // Default for both "audio/L8" and "audio/L16" is one channel
+    var channels = 1;
+
+    // "audio/L8" has one byte per sample
+    if (mimetype.substring(0, 9) === 'audio/L8;') {
+        mimetype = mimetype.substring(9);
+        bytesPerSample = 1;
+    }
+
+    // "audio/L16" has two bytes per sample
+    else if (mimetype.substring(0, 10) === 'audio/L16;') {
+        mimetype = mimetype.substring(10);
+        bytesPerSample = 2;
+    }
+
+    // All other types are unsupported
+    else
+        return null;
+
+    // Parse all parameters
+    var parameters = mimetype.split(',');
+    for (var i = 0; i < parameters.length; i++) {
+
+        var parameter = parameters[i];
+
+        // All parameters must have an equals sign separating name from value
+        var equals = parameter.indexOf('=');
+        if (equals === -1)
+            return null;
+
+        // Parse name and value from parameter string
+        var name  = parameter.substring(0, equals);
+        var value = parameter.substring(equals+1);
+
+        // Handle each supported parameter
+        switch (name) {
+
+            // Number of audio channels
+            case 'channels':
+                channels = parseInt(value);
+                break;
+
+            // Sample rate
+            case 'rate':
+                rate = parseInt(value);
+                break;
+
+            // All other parameters are unsupported
+            default:
+                return null;
+
+        }
+
+    };
+
+    // The rate parameter is required
+    if (rate === null)
+        return null;
+
+    // Return parsed format details
+    return new Guacamole.RawAudioPlayer._Format({
+        bytesPerSample : bytesPerSample,
+        channels       : channels,
+        rate           : rate
+    });
+
+};
+
+/**
+ * Determines whether the given mimetype is supported by
+ * Guacamole.RawAudioPlayer.
+ *
+ * @param {String} mimetype
+ *     The mimetype to check.
+ *
+ * @returns {Boolean}
+ *     true if the given mimetype is supported by Guacamole.RawAudioPlayer,
+ *     false otherwise.
+ */
+Guacamole.RawAudioPlayer.isSupportedType = function isSupportedType(mimetype) {
+
+    // No supported types if no Web Audio API
+    if (!window.AudioContext && !window.webkitAudioContext)
+        return false;
+
+    return Guacamole.RawAudioPlayer._Format.parse(mimetype) !== null;
+
+};
+
+/**
+ * Returns a list of all mimetypes supported by Guacamole.RawAudioPlayer. Only
+ * the core mimetypes themselves will be listed. Any mimetype parameters, even
+ * required ones, will not be included in the list. For example, "audio/L8" is
+ * a raw audio mimetype that may be supported, but it is invalid without
+ * additional parameters. Something like "audio/L8;rate=44100" would be valid,
+ * however (see https://tools.ietf.org/html/rfc4856).
+ *
+ * @returns {String[]}
+ *     A list of all mimetypes supported by Guacamole.RawAudioPlayer, excluding
+ *     any parameters. If the necessary JavaScript APIs for playing raw audio
+ *     are absent, this list will be empty.
+ */
+Guacamole.RawAudioPlayer.getSupportedTypes = function getSupportedTypes() {
+
+    // No supported types if no Web Audio API
+    if (!window.AudioContext && !window.webkitAudioContext)
+        return [];
+
+    // We support 8-bit and 16-bit raw PCM
+    return [
+        'audio/L8',
+        'audio/L16'
+    ];
+
+};
diff --git a/guacamole-common-js/src/main/webapp/modules/BlobReader.js b/guacamole-common-js/src/main/webapp/modules/BlobReader.js
new file mode 100644
index 0000000..55896f2
--- /dev/null
+++ b/guacamole-common-js/src/main/webapp/modules/BlobReader.js
@@ -0,0 +1,131 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+var Guacamole = Guacamole || {};
+
+/**
+ * A reader which automatically handles the given input stream, assembling all
+ * received blobs into a single blob by appending them to each other in order.
+ * Note that this object will overwrite any installed event handlers on the
+ * given Guacamole.InputStream.
+ * 
+ * @constructor
+ * @param {Guacamole.InputStream} stream The stream that data will be read
+ *                                       from.
+ * @param {String} mimetype The mimetype of the blob being built.
+ */
+Guacamole.BlobReader = function(stream, mimetype) {
+
+    /**
+     * Reference to this Guacamole.InputStream.
+     * @private
+     */
+    var guac_reader = this;
+
+    /**
+     * The length of this Guacamole.InputStream in bytes.
+     * @private
+     */
+    var length = 0;
+
+    // Get blob builder
+    var blob_builder;
+    if      (window.BlobBuilder)       blob_builder = new BlobBuilder();
+    else if (window.WebKitBlobBuilder) blob_builder = new WebKitBlobBuilder();
+    else if (window.MozBlobBuilder)    blob_builder = new MozBlobBuilder();
+    else
+        blob_builder = new (function() {
+
+            var blobs = [];
+
+            /** @ignore */
+            this.append = function(data) {
+                blobs.push(new Blob([data], {"type": mimetype}));
+            };
+
+            /** @ignore */
+            this.getBlob = function() {
+                return new Blob(blobs, {"type": mimetype});
+            };
+
+        })();
+
+    // Append received blobs
+    stream.onblob = function(data) {
+
+        // Convert to ArrayBuffer
+        var binary = window.atob(data);
+        var arrayBuffer = new ArrayBuffer(binary.length);
+        var bufferView = new Uint8Array(arrayBuffer);
+
+        for (var i=0; i<binary.length; i++)
+            bufferView[i] = binary.charCodeAt(i);
+
+        blob_builder.append(arrayBuffer);
+        length += arrayBuffer.byteLength;
+
+        // Call handler, if present
+        if (guac_reader.onprogress)
+            guac_reader.onprogress(arrayBuffer.byteLength);
+
+        // Send success response
+        stream.sendAck("OK", 0x0000);
+
+    };
+
+    // Simply call onend when end received
+    stream.onend = function() {
+        if (guac_reader.onend)
+            guac_reader.onend();
+    };
+
+    /**
+     * Returns the current length of this Guacamole.InputStream, in bytes.
+     * @return {Number} The current length of this Guacamole.InputStream.
+     */
+    this.getLength = function() {
+        return length;
+    };
+
+    /**
+     * Returns the contents of this Guacamole.BlobReader as a Blob.
+     * @return {Blob} The contents of this Guacamole.BlobReader.
+     */
+    this.getBlob = function() {
+        return blob_builder.getBlob();
+    };
+
+    /**
+     * Fired once for every blob of data received.
+     * 
+     * @event
+     * @param {Number} length The number of bytes received.
+     */
+    this.onprogress = null;
+
+    /**
+     * Fired once this stream is finished and no further data will be written.
+     * @event
+     */
+    this.onend = null;
+
+};
\ No newline at end of file
diff --git a/guacamole-common-js/src/main/webapp/modules/Client.js b/guacamole-common-js/src/main/webapp/modules/Client.js
new file mode 100644
index 0000000..dc9c9a0
--- /dev/null
+++ b/guacamole-common-js/src/main/webapp/modules/Client.js
@@ -0,0 +1,1450 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+var Guacamole = Guacamole || {};
+
+/**
+ * Guacamole protocol client. Given a {@link Guacamole.Tunnel},
+ * automatically handles incoming and outgoing Guacamole instructions via the
+ * provided tunnel, updating its display using one or more canvas elements.
+ * 
+ * @constructor
+ * @param {Guacamole.Tunnel} tunnel The tunnel to use to send and receive
+ *                                  Guacamole instructions.
+ */
+Guacamole.Client = function(tunnel) {
+
+    var guac_client = this;
+
+    var STATE_IDLE          = 0;
+    var STATE_CONNECTING    = 1;
+    var STATE_WAITING       = 2;
+    var STATE_CONNECTED     = 3;
+    var STATE_DISCONNECTING = 4;
+    var STATE_DISCONNECTED  = 5;
+
+    var currentState = STATE_IDLE;
+    
+    var currentTimestamp = 0;
+    var pingInterval = null;
+
+    /**
+     * Translation from Guacamole protocol line caps to Layer line caps.
+     * @private
+     */
+    var lineCap = {
+        0: "butt",
+        1: "round",
+        2: "square"
+    };
+
+    /**
+     * Translation from Guacamole protocol line caps to Layer line caps.
+     * @private
+     */
+    var lineJoin = {
+        0: "bevel",
+        1: "miter",
+        2: "round"
+    };
+
+    /**
+     * The underlying Guacamole display.
+     *
+     * @private
+     * @type {Guacamole.Display}
+     */
+    var display = new Guacamole.Display();
+
+    /**
+     * All available layers and buffers
+     *
+     * @private
+     * @type {Object.<Number, (Guacamole.Display.VisibleLayer|Guacamole.Layer)>}
+     */
+    var layers = {};
+    
+    /**
+     * All audio players currently in use by the client. Initially, this will
+     * be empty, but audio players may be allocated by the server upon request.
+     *
+     * @private
+     * @type {Object.<Number, Guacamole.AudioPlayer>}
+     */
+    var audioPlayers = {};
+
+    /**
+     * All video players currently in use by the client. Initially, this will
+     * be empty, but video players may be allocated by the server upon request.
+     *
+     * @private
+     * @type {Object.<Number, Guacamole.VideoPlayer>}
+     */
+    var videoPlayers = {};
+
+    // No initial parsers
+    var parsers = [];
+
+    // No initial streams 
+    var streams = [];
+
+    /**
+     * All current objects. The index of each object is dictated by the
+     * Guacamole server.
+     *
+     * @private
+     * @type {Guacamole.Object[]}
+     */
+    var objects = [];
+
+    // Pool of available stream indices
+    var stream_indices = new Guacamole.IntegerPool();
+
+    // Array of allocated output streams by index
+    var output_streams = [];
+
+    function setState(state) {
+        if (state != currentState) {
+            currentState = state;
+            if (guac_client.onstatechange)
+                guac_client.onstatechange(currentState);
+        }
+    }
+
+    function isConnected() {
+        return currentState == STATE_CONNECTED
+            || currentState == STATE_WAITING;
+    }
+
+    /**
+     * Returns the underlying display of this Guacamole.Client. The display
+     * contains an Element which can be added to the DOM, causing the
+     * display to become visible.
+     * 
+     * @return {Guacamole.Display} The underlying display of this
+     *                             Guacamole.Client.
+     */
+    this.getDisplay = function() {
+        return display;
+    };
+
+    /**
+     * Sends the current size of the screen.
+     * 
+     * @param {Number} width The width of the screen.
+     * @param {Number} height The height of the screen.
+     */
+    this.sendSize = function(width, height) {
+
+        // Do not send requests if not connected
+        if (!isConnected())
+            return;
+
+        tunnel.sendMessage("size", width, height);
+
+    };
+
+    /**
+     * Sends a key event having the given properties as if the user
+     * pressed or released a key.
+     * 
+     * @param {Boolean} pressed Whether the key is pressed (true) or released
+     *                          (false).
+     * @param {Number} keysym The keysym of the key being pressed or released.
+     */
+    this.sendKeyEvent = function(pressed, keysym) {
+        // Do not send requests if not connected
+        if (!isConnected())
+            return;
+
+        tunnel.sendMessage("key", keysym, pressed);
+    };
+
+    /**
+     * Sends a mouse event having the properties provided by the given mouse
+     * state.
+     * 
+     * @param {Guacamole.Mouse.State} mouseState The state of the mouse to send
+     *                                           in the mouse event.
+     */
+    this.sendMouseState = function(mouseState) {
+
+        // Do not send requests if not connected
+        if (!isConnected())
+            return;
+
+        // Update client-side cursor
+        display.moveCursor(
+            Math.floor(mouseState.x),
+            Math.floor(mouseState.y)
+        );
+
+        // Build mask
+        var buttonMask = 0;
+        if (mouseState.left)   buttonMask |= 1;
+        if (mouseState.middle) buttonMask |= 2;
+        if (mouseState.right)  buttonMask |= 4;
+        if (mouseState.up)     buttonMask |= 8;
+        if (mouseState.down)   buttonMask |= 16;
+
+        // Send message
+        tunnel.sendMessage("mouse", Math.floor(mouseState.x), Math.floor(mouseState.y), buttonMask);
+    };
+
+    /**
+     * Sets the clipboard of the remote client to the given text data.
+     *
+     * @deprecated Use createClipboardStream() instead. 
+     * @param {String} data The data to send as the clipboard contents.
+     */
+    this.setClipboard = function(data) {
+
+        // Do not send requests if not connected
+        if (!isConnected())
+            return;
+
+        // Open stream
+        var stream = guac_client.createClipboardStream("text/plain");
+        var writer = new Guacamole.StringWriter(stream);
+
+        // Send text chunks
+        for (var i=0; i<data.length; i += 4096)
+            writer.sendText(data.substring(i, i+4096));
+
+        // Close stream
+        writer.sendEnd();
+
+    };
+
+    /**
+     * Opens a new file for writing, having the given index, mimetype and
+     * filename.
+     * 
+     * @param {String} mimetype The mimetype of the file being sent.
+     * @param {String} filename The filename of the file being sent.
+     * @return {Guacamole.OutputStream} The created file stream.
+     */
+    this.createFileStream = function(mimetype, filename) {
+
+        // Allocate index
+        var index = stream_indices.next();
+
+        // Create new stream
+        tunnel.sendMessage("file", index, mimetype, filename);
+        var stream = output_streams[index] = new Guacamole.OutputStream(guac_client, index);
+
+        // Override sendEnd() of stream to automatically free index
+        var old_end = stream.sendEnd;
+        stream.sendEnd = function() {
+            old_end();
+            stream_indices.free(index);
+            delete output_streams[index];
+        };
+
+        // Return new, overridden stream
+        return stream;
+
+    };
+
+    /**
+     * Opens a new pipe for writing, having the given name and mimetype. 
+     * 
+     * @param {String} mimetype The mimetype of the data being sent.
+     * @param {String} name The name of the pipe.
+     * @return {Guacamole.OutputStream} The created file stream.
+     */
+    this.createPipeStream = function(mimetype, name) {
+
+        // Allocate index
+        var index = stream_indices.next();
+
+        // Create new stream
+        tunnel.sendMessage("pipe", index, mimetype, name);
+        var stream = output_streams[index] = new Guacamole.OutputStream(guac_client, index);
+
+        // Override sendEnd() of stream to automatically free index
+        var old_end = stream.sendEnd;
+        stream.sendEnd = function() {
+            old_end();
+            stream_indices.free(index);
+            delete output_streams[index];
+        };
+
+        // Return new, overridden stream
+        return stream;
+
+    };
+
+    /**
+     * Opens a new clipboard object for writing, having the given mimetype.
+     * 
+     * @param {String} mimetype The mimetype of the data being sent.
+     * @param {String} name The name of the pipe.
+     * @return {Guacamole.OutputStream} The created file stream.
+     */
+    this.createClipboardStream = function(mimetype) {
+
+        // Allocate index
+        var index = stream_indices.next();
+
+        // Create new stream
+        tunnel.sendMessage("clipboard", index, mimetype);
+        var stream = output_streams[index] = new Guacamole.OutputStream(guac_client, index);
+
+        // Override sendEnd() of stream to automatically free index
+        var old_end = stream.sendEnd;
+        stream.sendEnd = function() {
+            old_end();
+            stream_indices.free(index);
+            delete output_streams[index];
+        };
+
+        // Return new, overridden stream
+        return stream;
+
+    };
+
+    /**
+     * Creates a new output stream associated with the given object and having
+     * the given mimetype and name. The legality of a mimetype and name is
+     * dictated by the object itself.
+     *
+     * @param {Number} index
+     *     The index of the object for which the output stream is being
+     *     created.
+     *
+     * @param {String} mimetype
+     *     The mimetype of the data which will be sent to the output stream.
+     *
+     * @param {String} name
+     *     The defined name of an output stream within the given object.
+     *
+     * @returns {Guacamole.OutputStream}
+     *     An output stream which will write blobs to the named output stream
+     *     of the given object.
+     */
+    this.createObjectOutputStream = function createObjectOutputStream(index, mimetype, name) {
+
+        // Allocate index
+        var streamIndex = stream_indices.next();
+
+        // Create new stream
+        tunnel.sendMessage("put", index, streamIndex, mimetype, name);
+        var stream = output_streams[streamIndex] = new Guacamole.OutputStream(guac_client, streamIndex);
+
+        // Override sendEnd() of stream to automatically free index
+        var oldEnd = stream.sendEnd;
+        stream.sendEnd = function freeStreamIndex() {
+            oldEnd();
+            stream_indices.free(streamIndex);
+            delete output_streams[streamIndex];
+        };
+
+        // Return new, overridden stream
+        return stream;
+
+    };
+
+    /**
+     * Requests read access to the input stream having the given name. If
+     * successful, a new input stream will be created.
+     *
+     * @param {Number} index
+     *     The index of the object from which the input stream is being
+     *     requested.
+     *
+     * @param {String} name
+     *     The name of the input stream to request.
+     */
+    this.requestObjectInputStream = function requestObjectInputStream(index, name) {
+
+        // Do not send requests if not connected
+        if (!isConnected())
+            return;
+
+        tunnel.sendMessage("get", index, name);
+    };
+
+    /**
+     * Acknowledge receipt of a blob on the stream with the given index.
+     * 
+     * @param {Number} index The index of the stream associated with the
+     *                       received blob.
+     * @param {String} message A human-readable message describing the error
+     *                         or status.
+     * @param {Number} code The error code, if any, or 0 for success.
+     */
+    this.sendAck = function(index, message, code) {
+
+        // Do not send requests if not connected
+        if (!isConnected())
+            return;
+
+        tunnel.sendMessage("ack", index, message, code);
+    };
+
+    /**
+     * Given the index of a file, writes a blob of data to that file.
+     * 
+     * @param {Number} index The index of the file to write to.
+     * @param {String} data Base64-encoded data to write to the file.
+     */
+    this.sendBlob = function(index, data) {
+
+        // Do not send requests if not connected
+        if (!isConnected())
+            return;
+
+        tunnel.sendMessage("blob", index, data);
+    };
+
+    /**
+     * Marks a currently-open stream as complete.
+     * 
+     * @param {Number} index The index of the stream to end.
+     */
+    this.endStream = function(index) {
+
+        // Do not send requests if not connected
+        if (!isConnected())
+            return;
+
+        tunnel.sendMessage("end", index);
+    };
+
+    /**
+     * Fired whenever the state of this Guacamole.Client changes.
+     * 
+     * @event
+     * @param {Number} state The new state of the client.
+     */
+    this.onstatechange = null;
+
+    /**
+     * Fired when the remote client sends a name update.
+     * 
+     * @event
+     * @param {String} name The new name of this client.
+     */
+    this.onname = null;
+
+    /**
+     * Fired when an error is reported by the remote client, and the connection
+     * is being closed.
+     * 
+     * @event
+     * @param {Guacamole.Status} status A status object which describes the
+     *                                  error.
+     */
+    this.onerror = null;
+
+    /**
+     * Fired when a audio stream is created. The stream provided to this event
+     * handler will contain its own event handlers for received data.
+     *
+     * @event
+     * @param {Guacamole.InputStream} stream
+     *     The stream that will receive audio data from the server.
+     *
+     * @param {String} mimetype
+     *     The mimetype of the audio data which will be received.
+     *
+     * @return {Guacamole.AudioPlayer}
+     *     An object which implements the Guacamole.AudioPlayer interface and
+     *     has been initialied to play the data in the provided stream, or null
+     *     if the built-in audio players of the Guacamole client should be
+     *     used.
+     */
+    this.onaudio = null;
+
+    /**
+     * Fired when a video stream is created. The stream provided to this event
+     * handler will contain its own event handlers for received data.
+     *
+     * @event
+     * @param {Guacamole.InputStream} stream
+     *     The stream that will receive video data from the server.
+     *
+     * @param {Guacamole.Display.VisibleLayer} layer
+     *     The destination layer on which the received video data should be
+     *     played. It is the responsibility of the Guacamole.VideoPlayer
+     *     implementation to play the received data within this layer.
+     *
+     * @param {String} mimetype
+     *     The mimetype of the video data which will be received.
+     *
+     * @return {Guacamole.VideoPlayer}
+     *     An object which implements the Guacamole.VideoPlayer interface and
+     *     has been initialied to play the data in the provided stream, or null
+     *     if the built-in video players of the Guacamole client should be
+     *     used.
+     */
+    this.onvideo = null;
+
+    /**
+     * Fired when the clipboard of the remote client is changing.
+     * 
+     * @event
+     * @param {Guacamole.InputStream} stream The stream that will receive
+     *                                       clipboard data from the server.
+     * @param {String} mimetype The mimetype of the data which will be received.
+     */
+    this.onclipboard = null;
+
+    /**
+     * Fired when a file stream is created. The stream provided to this event
+     * handler will contain its own event handlers for received data.
+     * 
+     * @event
+     * @param {Guacamole.InputStream} stream The stream that will receive data
+     *                                       from the server.
+     * @param {String} mimetype The mimetype of the file received.
+     * @param {String} filename The name of the file received.
+     */
+    this.onfile = null;
+
+    /**
+     * Fired when a filesystem object is created. The object provided to this
+     * event handler will contain its own event handlers and functions for
+     * requesting and handling data.
+     *
+     * @event
+     * @param {Guacamole.Object} object
+     *     The created filesystem object.
+     *
+     * @param {String} name
+     *     The name of the filesystem.
+     */
+    this.onfilesystem = null;
+
+    /**
+     * Fired when a pipe stream is created. The stream provided to this event
+     * handler will contain its own event handlers for received data;
+     * 
+     * @event
+     * @param {Guacamole.InputStream} stream The stream that will receive data
+     *                                       from the server.
+     * @param {String} mimetype The mimetype of the data which will be received.
+     * @param {String} name The name of the pipe.
+     */
+    this.onpipe = null;
+
+    /**
+     * Fired whenever a sync instruction is received from the server, indicating
+     * that the server is finished processing any input from the client and
+     * has sent any results.
+     * 
+     * @event
+     * @param {Number} timestamp The timestamp associated with the sync
+     *                           instruction.
+     */
+    this.onsync = null;
+
+    /**
+     * Returns the layer with the given index, creating it if necessary.
+     * Positive indices refer to visible layers, an index of zero refers to
+     * the default layer, and negative indices refer to buffers.
+     *
+     * @private
+     * @param {Number} index
+     *     The index of the layer to retrieve.
+     *
+     * @return {Guacamole.Display.VisibleLayer|Guacamole.Layer}
+     *     The layer having the given index.
+     */
+    var getLayer = function getLayer(index) {
+
+        // Get layer, create if necessary
+        var layer = layers[index];
+        if (!layer) {
+
+            // Create layer based on index
+            if (index === 0)
+                layer = display.getDefaultLayer();
+            else if (index > 0)
+                layer = display.createLayer();
+            else
+                layer = display.createBuffer();
+                
+            // Add new layer
+            layers[index] = layer;
+
+        }
+
+        return layer;
+
+    };
+
+    function getParser(index) {
+
+        var parser = parsers[index];
+
+        // If parser not yet created, create it, and tie to the
+        // oninstruction handler of the tunnel.
+        if (parser == null) {
+            parser = parsers[index] = new Guacamole.Parser();
+            parser.oninstruction = tunnel.oninstruction;
+        }
+
+        return parser;
+
+    }
+
+    /**
+     * Handlers for all defined layer properties.
+     * @private
+     */
+    var layerPropertyHandlers = {
+
+        "miter-limit": function(layer, value) {
+            display.setMiterLimit(layer, parseFloat(value));
+        }
+
+    };
+    
+    /**
+     * Handlers for all instruction opcodes receivable by a Guacamole protocol
+     * client.
+     * @private
+     */
+    var instructionHandlers = {
+
+        "ack": function(parameters) {
+
+            var stream_index = parseInt(parameters[0]);
+            var reason = parameters[1];
+            var code = parseInt(parameters[2]);
+
+            // Get stream
+            var stream = output_streams[stream_index];
+            if (stream) {
+
+                // Signal ack if handler defined
+                if (stream.onack)
+                    stream.onack(new Guacamole.Status(code, reason));
+
+                // If code is an error, invalidate stream
+                if (code >= 0x0100) {
+                    stream_indices.free(stream_index);
+                    delete output_streams[stream_index];
+                }
+
+            }
+
+        },
+
+        "arc": function(parameters) {
+
+            var layer = getLayer(parseInt(parameters[0]));
+            var x = parseInt(parameters[1]);
+            var y = parseInt(parameters[2]);
+            var radius = parseInt(parameters[3]);
+            var startAngle = parseFloat(parameters[4]);
+            var endAngle = parseFloat(parameters[5]);
+            var negative = parseInt(parameters[6]);
+
+            display.arc(layer, x, y, radius, startAngle, endAngle, negative != 0);
+
+        },
+
+        "audio": function(parameters) {
+
+            var stream_index = parseInt(parameters[0]);
+            var mimetype = parameters[1];
+
+            // Create stream 
+            var stream = streams[stream_index] =
+                    new Guacamole.InputStream(guac_client, stream_index);
+
+            // Get player instance via callback
+            var audioPlayer = null;
+            if (guac_client.onaudio)
+                audioPlayer = guac_client.onaudio(stream, mimetype);
+
+            // If unsuccessful, try to use a default implementation
+            if (!audioPlayer)
+                audioPlayer = Guacamole.AudioPlayer.getInstance(stream, mimetype);
+
+            // If we have successfully retrieved an audio player, send success response
+            if (audioPlayer) {
+                audioPlayers[stream_index] = audioPlayer;
+                guac_client.sendAck(stream_index, "OK", 0x0000);
+            }
+
+            // Otherwise, mimetype must be unsupported
+            else
+                guac_client.sendAck(stream_index, "BAD TYPE", 0x030F);
+
+        },
+
+        "blob": function(parameters) {
+
+            // Get stream 
+            var stream_index = parseInt(parameters[0]);
+            var data = parameters[1];
+            var stream = streams[stream_index];
+
+            // Write data
+            if (stream && stream.onblob)
+                stream.onblob(data);
+
+        },
+
+        "body" : function handleBody(parameters) {
+
+            // Get object
+            var objectIndex = parseInt(parameters[0]);
+            var object = objects[objectIndex];
+
+            var streamIndex = parseInt(parameters[1]);
+            var mimetype = parameters[2];
+            var name = parameters[3];
+
+            // Create stream if handler defined
+            if (object && object.onbody) {
+                var stream = streams[streamIndex] = new Guacamole.InputStream(guac_client, streamIndex);
+                object.onbody(stream, mimetype, name);
+            }
+
+            // Otherwise, unsupported
+            else
+                guac_client.sendAck(streamIndex, "Receipt of body unsupported", 0x0100);
+
+        },
+
+        "cfill": function(parameters) {
+
+            var channelMask = parseInt(parameters[0]);
+            var layer = getLayer(parseInt(parameters[1]));
+            var r = parseInt(parameters[2]);
+            var g = parseInt(parameters[3]);
+            var b = parseInt(parameters[4]);
+            var a = parseInt(parameters[5]);
+
+            display.setChannelMask(layer, channelMask);
+            display.fillColor(layer, r, g, b, a);
+
+        },
+
+        "clip": function(parameters) {
+
+            var layer = getLayer(parseInt(parameters[0]));
+
+            display.clip(layer);
+
+        },
+
+        "clipboard": function(parameters) {
+
+            var stream_index = parseInt(parameters[0]);
+            var mimetype = parameters[1];
+
+            // Create stream 
+            if (guac_client.onclipboard) {
+                var stream = streams[stream_index] = new Guacamole.InputStream(guac_client, stream_index);
+                guac_client.onclipboard(stream, mimetype);
+            }
+
+            // Otherwise, unsupported
+            else
+                guac_client.sendAck(stream_index, "Clipboard unsupported", 0x0100);
+
+        },
+
+        "close": function(parameters) {
+
+            var layer = getLayer(parseInt(parameters[0]));
+
+            display.close(layer);
+
+        },
+
+        "copy": function(parameters) {
+
+            var srcL = getLayer(parseInt(parameters[0]));
+            var srcX = parseInt(parameters[1]);
+            var srcY = parseInt(parameters[2]);
+            var srcWidth = parseInt(parameters[3]);
+            var srcHeight = parseInt(parameters[4]);
+            var channelMask = parseInt(parameters[5]);
+            var dstL = getLayer(parseInt(parameters[6]));
+            var dstX = parseInt(parameters[7]);
+            var dstY = parseInt(parameters[8]);
+
+            display.setChannelMask(dstL, channelMask);
+            display.copy(srcL, srcX, srcY, srcWidth, srcHeight, 
+                         dstL, dstX, dstY);
+
+        },
+
+        "cstroke": function(parameters) {
+
+            var channelMask = parseInt(parameters[0]);
+            var layer = getLayer(parseInt(parameters[1]));
+            var cap = lineCap[parseInt(parameters[2])];
+            var join = lineJoin[parseInt(parameters[3])];
+            var thickness = parseInt(parameters[4]);
+            var r = parseInt(parameters[5]);
+            var g = parseInt(parameters[6]);
+            var b = parseInt(parameters[7]);
+            var a = parseInt(parameters[8]);
+
+            display.setChannelMask(layer, channelMask);
+            display.strokeColor(layer, cap, join, thickness, r, g, b, a);
+
+        },
+
+        "cursor": function(parameters) {
+
+            var cursorHotspotX = parseInt(parameters[0]);
+            var cursorHotspotY = parseInt(parameters[1]);
+            var srcL = getLayer(parseInt(parameters[2]));
+            var srcX = parseInt(parameters[3]);
+            var srcY = parseInt(parameters[4]);
+            var srcWidth = parseInt(parameters[5]);
+            var srcHeight = parseInt(parameters[6]);
+
+            display.setCursor(cursorHotspotX, cursorHotspotY,
+                              srcL, srcX, srcY, srcWidth, srcHeight);
+
+        },
+
+        "curve": function(parameters) {
+
+            var layer = getLayer(parseInt(parameters[0]));
+            var cp1x = parseInt(parameters[1]);
+            var cp1y = parseInt(parameters[2]);
+            var cp2x = parseInt(parameters[3]);
+            var cp2y = parseInt(parameters[4]);
+            var x = parseInt(parameters[5]);
+            var y = parseInt(parameters[6]);
+
+            display.curveTo(layer, cp1x, cp1y, cp2x, cp2y, x, y);
+
+        },
+
+        "dispose": function(parameters) {
+            
+            var layer_index = parseInt(parameters[0]);
+
+            // If visible layer, remove from parent
+            if (layer_index > 0) {
+
+                // Remove from parent
+                var layer = getLayer(layer_index);
+                layer.dispose();
+
+                // Delete reference
+                delete layers[layer_index];
+
+            }
+
+            // If buffer, just delete reference
+            else if (layer_index < 0)
+                delete layers[layer_index];
+
+            // Attempting to dispose the root layer currently has no effect.
+
+        },
+
+        "distort": function(parameters) {
+
+            var layer_index = parseInt(parameters[0]);
+            var a = parseFloat(parameters[1]);
+            var b = parseFloat(parameters[2]);
+            var c = parseFloat(parameters[3]);
+            var d = parseFloat(parameters[4]);
+            var e = parseFloat(parameters[5]);
+            var f = parseFloat(parameters[6]);
+
+            // Only valid for visible layers (not buffers)
+            if (layer_index >= 0) {
+                var layer = getLayer(layer_index);
+                layer.distort(a, b, c, d, e, f);
+            }
+
+        },
+ 
+        "error": function(parameters) {
+
+            var reason = parameters[0];
+            var code = parseInt(parameters[1]);
+
+            // Call handler if defined
+            if (guac_client.onerror)
+                guac_client.onerror(new Guacamole.Status(code, reason));
+
+            guac_client.disconnect();
+
+        },
+
+        "end": function(parameters) {
+
+            // Get stream
+            var stream_index = parseInt(parameters[0]);
+            var stream = streams[stream_index];
+
+            // Signal end of stream
+            if (stream && stream.onend)
+                stream.onend();
+
+        },
+
+        "file": function(parameters) {
+
+            var stream_index = parseInt(parameters[0]);
+            var mimetype = parameters[1];
+            var filename = parameters[2];
+
+            // Create stream 
+            if (guac_client.onfile) {
+                var stream = streams[stream_index] = new Guacamole.InputStream(guac_client, stream_index);
+                guac_client.onfile(stream, mimetype, filename);
+            }
+
+            // Otherwise, unsupported
+            else
+                guac_client.sendAck(stream_index, "File transfer unsupported", 0x0100);
+
+        },
+
+        "filesystem" : function handleFilesystem(parameters) {
+
+            var objectIndex = parseInt(parameters[0]);
+            var name = parameters[1];
+
+            // Create object, if supported
+            if (guac_client.onfilesystem) {
+                var object = objects[objectIndex] = new Guacamole.Object(guac_client, objectIndex);
+                guac_client.onfilesystem(object, name);
+            }
+
+            // If unsupported, simply ignore the availability of the filesystem
+
+        },
+
+        "identity": function(parameters) {
+
+            var layer = getLayer(parseInt(parameters[0]));
+
+            display.setTransform(layer, 1, 0, 0, 1, 0, 0);
+
+        },
+
+        "img": function(parameters) {
+
+            var stream_index = parseInt(parameters[0]);
+            var channelMask = parseInt(parameters[1]);
+            var layer = getLayer(parseInt(parameters[2]));
+            var mimetype = parameters[3];
+            var x = parseInt(parameters[4]);
+            var y = parseInt(parameters[5]);
+
+            // Create stream
+            var stream = streams[stream_index] = new Guacamole.InputStream(guac_client, stream_index);
+            var reader = new Guacamole.DataURIReader(stream, mimetype);
+
+            // Draw image when stream is complete
+            reader.onend = function drawImageBlob() {
+                display.setChannelMask(layer, channelMask);
+                display.draw(layer, x, y, reader.getURI());
+            };
+
+        },
+
+        "jpeg": function(parameters) {
+
+            var channelMask = parseInt(parameters[0]);
+            var layer = getLayer(parseInt(parameters[1]));
+            var x = parseInt(parameters[2]);
+            var y = parseInt(parameters[3]);
+            var data = parameters[4];
+
+            display.setChannelMask(layer, channelMask);
+            display.draw(layer, x, y, "data:image/jpeg;base64," + data);
+
+        },
+
+        "lfill": function(parameters) {
+
+            var channelMask = parseInt(parameters[0]);
+            var layer = getLayer(parseInt(parameters[1]));
+            var srcLayer = getLayer(parseInt(parameters[2]));
+
+            display.setChannelMask(layer, channelMask);
+            display.fillLayer(layer, srcLayer);
+
+        },
+
+        "line": function(parameters) {
+
+            var layer = getLayer(parseInt(parameters[0]));
+            var x = parseInt(parameters[1]);
+            var y = parseInt(parameters[2]);
+
+            display.lineTo(layer, x, y);
+
+        },
+
+        "lstroke": function(parameters) {
+
+            var channelMask = parseInt(parameters[0]);
+            var layer = getLayer(parseInt(parameters[1]));
+            var srcLayer = getLayer(parseInt(parameters[2]));
+
+            display.setChannelMask(layer, channelMask);
+            display.strokeLayer(layer, srcLayer);
+
+        },
+
+        "move": function(parameters) {
+            
+            var layer_index = parseInt(parameters[0]);
+            var parent_index = parseInt(parameters[1]);
+            var x = parseInt(parameters[2]);
+            var y = parseInt(parameters[3]);
+            var z = parseInt(parameters[4]);
+
+            // Only valid for non-default layers
+            if (layer_index > 0 && parent_index >= 0) {
+                var layer = getLayer(layer_index);
+                var parent = getLayer(parent_index);
+                layer.move(parent, x, y, z);
+            }
+
+        },
+
+        "name": function(parameters) {
+            if (guac_client.onname) guac_client.onname(parameters[0]);
+        },
+
+        "nest": function(parameters) {
+            var parser = getParser(parseInt(parameters[0]));
+            parser.receive(parameters[1]);
+        },
+
+        "pipe": function(parameters) {
+
+            var stream_index = parseInt(parameters[0]);
+            var mimetype = parameters[1];
+            var name = parameters[2];
+
+            // Create stream 
+            if (guac_client.onpipe) {
+                var stream = streams[stream_index] = new Guacamole.InputStream(guac_client, stream_index);
+                guac_client.onpipe(stream, mimetype, name);
+            }
+
+            // Otherwise, unsupported
+            else
+                guac_client.sendAck(stream_index, "Named pipes unsupported", 0x0100);
+
+        },
+
+        "png": function(parameters) {
+
+            var channelMask = parseInt(parameters[0]);
+            var layer = getLayer(parseInt(parameters[1]));
+            var x = parseInt(parameters[2]);
+            var y = parseInt(parameters[3]);
+            var data = parameters[4];
+
+            display.setChannelMask(layer, channelMask);
+            display.draw(layer, x, y, "data:image/png;base64," + data);
+
+        },
+
+        "pop": function(parameters) {
+
+            var layer = getLayer(parseInt(parameters[0]));
+
+            display.pop(layer);
+
+        },
+
+        "push": function(parameters) {
+
+            var layer = getLayer(parseInt(parameters[0]));
+
+            display.push(layer);
+
+        },
+ 
+        "rect": function(parameters) {
+
+            var layer = getLayer(parseInt(parameters[0]));
+            var x = parseInt(parameters[1]);
+            var y = parseInt(parameters[2]);
+            var w = parseInt(parameters[3]);
+            var h = parseInt(parameters[4]);
+
+            display.rect(layer, x, y, w, h);
+
+        },
+        
+        "reset": function(parameters) {
+
+            var layer = getLayer(parseInt(parameters[0]));
+
+            display.reset(layer);
+
+        },
+        
+        "set": function(parameters) {
+
+            var layer = getLayer(parseInt(parameters[0]));
+            var name = parameters[1];
+            var value = parameters[2];
+
+            // Call property handler if defined
+            var handler = layerPropertyHandlers[name];
+            if (handler)
+                handler(layer, value);
+
+        },
+
+        "shade": function(parameters) {
+            
+            var layer_index = parseInt(parameters[0]);
+            var a = parseInt(parameters[1]);
+
+            // Only valid for visible layers (not buffers)
+            if (layer_index >= 0) {
+                var layer = getLayer(layer_index);
+                layer.shade(a);
+            }
+
+        },
+
+        "size": function(parameters) {
+
+            var layer_index = parseInt(parameters[0]);
+            var layer = getLayer(layer_index);
+            var width = parseInt(parameters[1]);
+            var height = parseInt(parameters[2]);
+
+            display.resize(layer, width, height);
+
+        },
+        
+        "start": function(parameters) {
+
+            var layer = getLayer(parseInt(parameters[0]));
+            var x = parseInt(parameters[1]);
+            var y = parseInt(parameters[2]);
+
+            display.moveTo(layer, x, y);
+
+        },
+
+        "sync": function(parameters) {
+
+            var timestamp = parseInt(parameters[0]);
+
+            // Flush display, send sync when done
+            display.flush(function displaySyncComplete() {
+
+                // Synchronize all audio players
+                for (var index in audioPlayers) {
+                    var audioPlayer = audioPlayers[index];
+                    if (audioPlayer)
+                        audioPlayer.sync();
+                }
+
+                // Send sync response to server
+                if (timestamp !== currentTimestamp) {
+                    tunnel.sendMessage("sync", timestamp);
+                    currentTimestamp = timestamp;
+                }
+
+            });
+
+            // If received first update, no longer waiting.
+            if (currentState === STATE_WAITING)
+                setState(STATE_CONNECTED);
+
+            // Call sync handler if defined
+            if (guac_client.onsync)
+                guac_client.onsync(timestamp);
+
+        },
+
+        "transfer": function(parameters) {
+
+            var srcL = getLayer(parseInt(parameters[0]));
+            var srcX = parseInt(parameters[1]);
+            var srcY = parseInt(parameters[2]);
+            var srcWidth = parseInt(parameters[3]);
+            var srcHeight = parseInt(parameters[4]);
+            var function_index = parseInt(parameters[5]);
+            var dstL = getLayer(parseInt(parameters[6]));
+            var dstX = parseInt(parameters[7]);
+            var dstY = parseInt(parameters[8]);
+
+            /* SRC */
+            if (function_index === 0x3)
+                display.put(srcL, srcX, srcY, srcWidth, srcHeight, 
+                    dstL, dstX, dstY);
+
+            /* Anything else that isn't a NO-OP */
+            else if (function_index !== 0x5)
+                display.transfer(srcL, srcX, srcY, srcWidth, srcHeight, 
+                    dstL, dstX, dstY, Guacamole.Client.DefaultTransferFunction[function_index]);
+
+        },
+
+        "transform": function(parameters) {
+
+            var layer = getLayer(parseInt(parameters[0]));
+            var a = parseFloat(parameters[1]);
+            var b = parseFloat(parameters[2]);
+            var c = parseFloat(parameters[3]);
+            var d = parseFloat(parameters[4]);
+            var e = parseFloat(parameters[5]);
+            var f = parseFloat(parameters[6]);
+
+            display.transform(layer, a, b, c, d, e, f);
+
+        },
+
+        "undefine" : function handleUndefine(parameters) {
+
+            // Get object
+            var objectIndex = parseInt(parameters[0]);
+            var object = objects[objectIndex];
+
+            // Signal end of object definition
+            if (object && object.onundefine)
+                object.onundefine();
+
+        },
+
+        "video": function(parameters) {
+
+            var stream_index = parseInt(parameters[0]);
+            var layer = getLayer(parseInt(parameters[1]));
+            var mimetype = parameters[2];
+
+            // Create stream
+            var stream = streams[stream_index] =
+                    new Guacamole.InputStream(guac_client, stream_index);
+
+            // Get player instance via callback
+            var videoPlayer = null;
+            if (guac_client.onvideo)
+                videoPlayer = guac_client.onvideo(stream, layer, mimetype);
+
+            // If unsuccessful, try to use a default implementation
+            if (!videoPlayer)
+                videoPlayer = Guacamole.VideoPlayer.getInstance(stream, layer, mimetype);
+
+            // If we have successfully retrieved an video player, send success response
+            if (videoPlayer) {
+                videoPlayers[stream_index] = videoPlayer;
+                guac_client.sendAck(stream_index, "OK", 0x0000);
+            }
+
+            // Otherwise, mimetype must be unsupported
+            else
+                guac_client.sendAck(stream_index, "BAD TYPE", 0x030F);
+
+        }
+
+    };
+
+    tunnel.oninstruction = function(opcode, parameters) {
+
+        var handler = instructionHandlers[opcode];
+        if (handler)
+            handler(parameters);
+
+    };
+
+    /**
+     * Sends a disconnect instruction to the server and closes the tunnel.
+     */
+    this.disconnect = function() {
+
+        // Only attempt disconnection not disconnected.
+        if (currentState != STATE_DISCONNECTED
+                && currentState != STATE_DISCONNECTING) {
+
+            setState(STATE_DISCONNECTING);
+
+            // Stop ping
+            if (pingInterval)
+                window.clearInterval(pingInterval);
+
+            // Send disconnect message and disconnect
+            tunnel.sendMessage("disconnect");
+            tunnel.disconnect();
+            setState(STATE_DISCONNECTED);
+
+        }
+
+    };
+    
+    /**
+     * Connects the underlying tunnel of this Guacamole.Client, passing the
+     * given arbitrary data to the tunnel during the connection process.
+     *
+     * @param data Arbitrary connection data to be sent to the underlying
+     *             tunnel during the connection process.
+     * @throws {Guacamole.Status} If an error occurs during connection.
+     */
+    this.connect = function(data) {
+
+        setState(STATE_CONNECTING);
+
+        try {
+            tunnel.connect(data);
+        }
+        catch (status) {
+            setState(STATE_IDLE);
+            throw status;
+        }
+
+        // Ping every 5 seconds (ensure connection alive)
+        pingInterval = window.setInterval(function() {
+            tunnel.sendMessage("sync", currentTimestamp);
+        }, 5000);
+
+        setState(STATE_WAITING);
+    };
+
+};
+
+/**
+ * Map of all Guacamole binary raster operations to transfer functions.
+ * @private
+ */
+Guacamole.Client.DefaultTransferFunction = {
+
+    /* BLACK */
+    0x0: function (src, dst) {
+        dst.red = dst.green = dst.blue = 0x00;
+    },
+
+    /* WHITE */
+    0xF: function (src, dst) {
+        dst.red = dst.green = dst.blue = 0xFF;
+    },
+
+    /* SRC */
+    0x3: function (src, dst) {
+        dst.red   = src.red;
+        dst.green = src.green;
+        dst.blue  = src.blue;
+        dst.alpha = src.alpha;
+    },
+
+    /* DEST (no-op) */
+    0x5: function (src, dst) {
+        // Do nothing
+    },
+
+    /* Invert SRC */
+    0xC: function (src, dst) {
+        dst.red   = 0xFF & ~src.red;
+        dst.green = 0xFF & ~src.green;
+        dst.blue  = 0xFF & ~src.blue;
+        dst.alpha =  src.alpha;
+    },
+    
+    /* Invert DEST */
+    0xA: function (src, dst) {
+        dst.red   = 0xFF & ~dst.red;
+        dst.green = 0xFF & ~dst.green;
+        dst.blue  = 0xFF & ~dst.blue;
+    },
+
+    /* AND */
+    0x1: function (src, dst) {
+        dst.red   =  ( src.red   &  dst.red);
+        dst.green =  ( src.green &  dst.green);
+        dst.blue  =  ( src.blue  &  dst.blue);
+    },
+
+    /* NAND */
+    0xE: function (src, dst) {
+        dst.red   = 0xFF & ~( src.red   &  dst.red);
+        dst.green = 0xFF & ~( src.green &  dst.green);
+        dst.blue  = 0xFF & ~( src.blue  &  dst.blue);
+    },
+
+    /* OR */
+    0x7: function (src, dst) {
+        dst.red   =  ( src.red   |  dst.red);
+        dst.green =  ( src.green |  dst.green);
+        dst.blue  =  ( src.blue  |  dst.blue);
+    },
+
+    /* NOR */
+    0x8: function (src, dst) {
+        dst.red   = 0xFF & ~( src.red   |  dst.red);
+        dst.green = 0xFF & ~( src.green |  dst.green);
+        dst.blue  = 0xFF & ~( src.blue  |  dst.blue);
+    },
+
+    /* XOR */
+    0x6: function (src, dst) {
+        dst.red   =  ( src.red   ^  dst.red);
+        dst.green =  ( src.green ^  dst.green);
+        dst.blue  =  ( src.blue  ^  dst.blue);
+    },
+
+    /* XNOR */
+    0x9: function (src, dst) {
+        dst.red   = 0xFF & ~( src.red   ^  dst.red);
+        dst.green = 0xFF & ~( src.green ^  dst.green);
+        dst.blue  = 0xFF & ~( src.blue  ^  dst.blue);
+    },
+
+    /* AND inverted source */
+    0x4: function (src, dst) {
+        dst.red   =  0xFF & (~src.red   &  dst.red);
+        dst.green =  0xFF & (~src.green &  dst.green);
+        dst.blue  =  0xFF & (~src.blue  &  dst.blue);
+    },
+
+    /* OR inverted source */
+    0xD: function (src, dst) {
+        dst.red   =  0xFF & (~src.red   |  dst.red);
+        dst.green =  0xFF & (~src.green |  dst.green);
+        dst.blue  =  0xFF & (~src.blue  |  dst.blue);
+    },
+
+    /* AND inverted destination */
+    0x2: function (src, dst) {
+        dst.red   =  0xFF & ( src.red   & ~dst.red);
+        dst.green =  0xFF & ( src.green & ~dst.green);
+        dst.blue  =  0xFF & ( src.blue  & ~dst.blue);
+    },
+
+    /* OR inverted destination */
+    0xB: function (src, dst) {
+        dst.red   =  0xFF & ( src.red   | ~dst.red);
+        dst.green =  0xFF & ( src.green | ~dst.green);
+        dst.blue  =  0xFF & ( src.blue  | ~dst.blue);
+    }
+
+};
diff --git a/guacamole-common-js/src/main/webapp/modules/DataURIReader.js b/guacamole-common-js/src/main/webapp/modules/DataURIReader.js
new file mode 100644
index 0000000..cbce5e7
--- /dev/null
+++ b/guacamole-common-js/src/main/webapp/modules/DataURIReader.js
@@ -0,0 +1,87 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+var Guacamole = Guacamole || {};
+
+/**
+ * A reader which automatically handles the given input stream, returning
+ * received blobs as a single data URI built over the course of the stream.
+ * Note that this object will overwrite any installed event handlers on the
+ * given Guacamole.InputStream.
+ * 
+ * @constructor
+ * @param {Guacamole.InputStream} stream
+ *     The stream that data will be read from.
+ */
+Guacamole.DataURIReader = function(stream, mimetype) {
+
+    /**
+     * Reference to this Guacamole.DataURIReader.
+     * @private
+     */
+    var guac_reader = this;
+
+    /**
+     * Current data URI.
+     *
+     * @private
+     * @type {String}
+     */
+    var uri = 'data:' + mimetype + ';base64,';
+
+    // Receive blobs as array buffers
+    stream.onblob = function dataURIReaderBlob(data) {
+
+        // Currently assuming data will ALWAYS be safe to simply append. This
+        // will not be true if the received base64 data encodes a number of
+        // bytes that isn't a multiple of three (as base64 expands in a ratio
+        // of exactly 3:4).
+        uri += data;
+
+    };
+
+    // Simply call onend when end received
+    stream.onend = function dataURIReaderEnd() {
+        if (guac_reader.onend)
+            guac_reader.onend();
+    };
+
+    /**
+     * Returns the data URI of all data received through the underlying stream
+     * thus far.
+     *
+     * @returns {String}
+     *     The data URI of all data received through the underlying stream thus
+     *     far.
+     */
+    this.getURI = function getURI() {
+        return uri;
+    };
+
+    /**
+     * Fired once this stream is finished and no further data will be written.
+     *
+     * @event
+     */
+    this.onend = null;
+
+};
\ No newline at end of file
diff --git a/guacamole-common-js/src/main/webapp/modules/Display.js b/guacamole-common-js/src/main/webapp/modules/Display.js
new file mode 100644
index 0000000..1556838
--- /dev/null
+++ b/guacamole-common-js/src/main/webapp/modules/Display.js
@@ -0,0 +1,1387 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+var Guacamole = Guacamole || {};
+
+/**
+ * The Guacamole display. The display does not deal with the Guacamole
+ * protocol, and instead implements a set of graphical operations which
+ * embody the set of operations present in the protocol. The order operations
+ * are executed is guaranteed to be in the same order as their corresponding
+ * functions are called.
+ * 
+ * @constructor
+ */
+Guacamole.Display = function() {
+
+    /**
+     * Reference to this Guacamole.Display.
+     * @private
+     */
+    var guac_display = this;
+
+    var displayWidth = 0;
+    var displayHeight = 0;
+    var displayScale = 1;
+
+    // Create display
+    var display = document.createElement("div");
+    display.style.position = "relative";
+    display.style.width = displayWidth + "px";
+    display.style.height = displayHeight + "px";
+
+    // Ensure transformations on display originate at 0,0
+    display.style.transformOrigin =
+    display.style.webkitTransformOrigin =
+    display.style.MozTransformOrigin =
+    display.style.OTransformOrigin =
+    display.style.msTransformOrigin =
+        "0 0";
+
+    // Create default layer
+    var default_layer = new Guacamole.Display.VisibleLayer(displayWidth, displayHeight);
+
+    // Create cursor layer
+    var cursor = new Guacamole.Display.VisibleLayer(0, 0);
+    cursor.setChannelMask(Guacamole.Layer.SRC);
+
+    // Add default layer and cursor to display
+    display.appendChild(default_layer.getElement());
+    display.appendChild(cursor.getElement());
+
+    // Create bounding div 
+    var bounds = document.createElement("div");
+    bounds.style.position = "relative";
+    bounds.style.width = (displayWidth*displayScale) + "px";
+    bounds.style.height = (displayHeight*displayScale) + "px";
+
+    // Add display to bounds
+    bounds.appendChild(display);
+
+    /**
+     * The X coordinate of the hotspot of the mouse cursor. The hotspot is
+     * the relative location within the image of the mouse cursor at which
+     * each click occurs.
+     * 
+     * @type {Number}
+     */
+    this.cursorHotspotX = 0;
+
+    /**
+     * The Y coordinate of the hotspot of the mouse cursor. The hotspot is
+     * the relative location within the image of the mouse cursor at which
+     * each click occurs.
+     * 
+     * @type {Number}
+     */
+    this.cursorHotspotY = 0;
+
+    /**
+     * The current X coordinate of the local mouse cursor. This is not
+     * necessarily the location of the actual mouse - it refers only to
+     * the location of the cursor image within the Guacamole display, as
+     * last set by moveCursor().
+     * 
+     * @type {Number}
+     */
+    this.cursorX = 0;
+
+    /**
+     * The current X coordinate of the local mouse cursor. This is not
+     * necessarily the location of the actual mouse - it refers only to
+     * the location of the cursor image within the Guacamole display, as
+     * last set by moveCursor().
+     * 
+     * @type {Number}
+     */
+    this.cursorY = 0;
+
+    /**
+     * Fired when the default layer (and thus the entire Guacamole display)
+     * is resized.
+     * 
+     * @event
+     * @param {Number} width The new width of the Guacamole display.
+     * @param {Number} height The new height of the Guacamole display.
+     */
+    this.onresize = null;
+
+    /**
+     * Fired whenever the local cursor image is changed. This can be used to
+     * implement special handling of the client-side cursor, or to override
+     * the default use of a software cursor layer.
+     * 
+     * @event
+     * @param {HTMLCanvasElement} canvas The cursor image.
+     * @param {Number} x The X-coordinate of the cursor hotspot.
+     * @param {Number} y The Y-coordinate of the cursor hotspot.
+     */
+    this.oncursor = null;
+
+    /**
+     * The queue of all pending Tasks. Tasks will be run in order, with new
+     * tasks added at the end of the queue and old tasks removed from the
+     * front of the queue (FIFO). These tasks will eventually be grouped
+     * into a Frame.
+     * @private
+     * @type {Task[]}
+     */
+    var tasks = [];
+
+    /**
+     * The queue of all frames. Each frame is a pairing of an array of tasks
+     * and a callback which must be called when the frame is rendered.
+     * @private
+     * @type {Frame[]}
+     */
+    var frames = [];
+
+    /**
+     * Flushes all pending frames.
+     * @private
+     */
+    function __flush_frames() {
+
+        var rendered_frames = 0;
+
+        // Draw all pending frames, if ready
+        while (rendered_frames < frames.length) {
+
+            var frame = frames[rendered_frames];
+            if (!frame.isReady())
+                break;
+
+            frame.flush();
+            rendered_frames++;
+
+        } 
+
+        // Remove rendered frames from array
+        frames.splice(0, rendered_frames);
+
+    }
+
+    /**
+     * An ordered list of tasks which must be executed atomically. Once
+     * executed, an associated (and optional) callback will be called.
+     *
+     * @private
+     * @constructor
+     * @param {function} callback The function to call when this frame is
+     *                            rendered.
+     * @param {Task[]} tasks The set of tasks which must be executed to render
+     *                       this frame.
+     */
+    function Frame(callback, tasks) {
+
+        /**
+         * Returns whether this frame is ready to be rendered. This function
+         * returns true if and only if ALL underlying tasks are unblocked.
+         * 
+         * @returns {Boolean} true if all underlying tasks are unblocked,
+         *                    false otherwise.
+         */
+        this.isReady = function() {
+
+            // Search for blocked tasks
+            for (var i=0; i < tasks.length; i++) {
+                if (tasks[i].blocked)
+                    return false;
+            }
+
+            // If no blocked tasks, the frame is ready
+            return true;
+
+        };
+
+        /**
+         * Renders this frame, calling the associated callback, if any, after
+         * the frame is complete. This function MUST only be called when no
+         * blocked tasks exist. Calling this function with blocked tasks
+         * will result in undefined behavior.
+         */
+        this.flush = function() {
+
+            // Draw all pending tasks.
+            for (var i=0; i < tasks.length; i++)
+                tasks[i].execute();
+
+            // Call callback
+            if (callback) callback();
+
+        };
+
+    }
+
+    /**
+     * A container for an task handler. Each operation which must be ordered
+     * is associated with a Task that goes into a task queue. Tasks in this
+     * queue are executed in order once their handlers are set, while Tasks 
+     * without handlers block themselves and any following Tasks from running.
+     *
+     * @constructor
+     * @private
+     * @param {function} taskHandler The function to call when this task 
+     *                               runs, if any.
+     * @param {boolean} blocked Whether this task should start blocked.
+     */
+    function Task(taskHandler, blocked) {
+       
+        var task = this;
+       
+        /**
+         * Whether this Task is blocked.
+         * 
+         * @type {boolean}
+         */
+        this.blocked = blocked;
+
+        /**
+         * Unblocks this Task, allowing it to run.
+         */
+        this.unblock = function() {
+            if (task.blocked) {
+                task.blocked = false;
+                __flush_frames();
+            }
+        };
+
+        /**
+         * Calls the handler associated with this task IMMEDIATELY. This
+         * function does not track whether this task is marked as blocked.
+         * Enforcing the blocked status of tasks is up to the caller.
+         */
+        this.execute = function() {
+            if (taskHandler) taskHandler();
+        };
+
+    }
+
+    /**
+     * Schedules a task for future execution. The given handler will execute
+     * immediately after all previous tasks upon frame flush, unless this
+     * task is blocked. If any tasks is blocked, the entire frame will not
+     * render (and no tasks within will execute) until all tasks are unblocked.
+     * 
+     * @private
+     * @param {function} handler The function to call when possible, if any.
+     * @param {boolean} blocked Whether the task should start blocked.
+     * @returns {Task} The Task created and added to the queue for future
+     *                 running.
+     */
+    function scheduleTask(handler, blocked) {
+        var task = new Task(handler, blocked);
+        tasks.push(task);
+        return task;
+    }
+
+    /**
+     * Returns the element which contains the Guacamole display.
+     * 
+     * @return {Element} The element containing the Guacamole display.
+     */
+    this.getElement = function() {
+        return bounds;
+    };
+
+    /**
+     * Returns the width of this display.
+     * 
+     * @return {Number} The width of this display;
+     */
+    this.getWidth = function() {
+        return displayWidth;
+    };
+
+    /**
+     * Returns the height of this display.
+     * 
+     * @return {Number} The height of this display;
+     */
+    this.getHeight = function() {
+        return displayHeight;
+    };
+
+    /**
+     * Returns the default layer of this display. Each Guacamole display always
+     * has at least one layer. Other layers can optionally be created within
+     * this layer, but the default layer cannot be removed and is the absolute
+     * ancestor of all other layers.
+     * 
+     * @return {Guacamole.Display.VisibleLayer} The default layer.
+     */
+    this.getDefaultLayer = function() {
+        return default_layer;
+    };
+
+    /**
+     * Returns the cursor layer of this display. Each Guacamole display contains
+     * a layer for the image of the mouse cursor. This layer is a special case
+     * and exists above all other layers, similar to the hardware mouse cursor.
+     * 
+     * @return {Guacamole.Display.VisibleLayer} The cursor layer.
+     */
+    this.getCursorLayer = function() {
+        return cursor;
+    };
+
+    /**
+     * Creates a new layer. The new layer will be a direct child of the default
+     * layer, but can be moved to be a child of any other layer. Layers returned
+     * by this function are visible.
+     * 
+     * @return {Guacamole.Display.VisibleLayer} The newly-created layer.
+     */
+    this.createLayer = function() {
+        var layer = new Guacamole.Display.VisibleLayer(displayWidth, displayHeight);
+        layer.move(default_layer, 0, 0, 0);
+        return layer;
+    };
+
+    /**
+     * Creates a new buffer. Buffers are invisible, off-screen surfaces. They
+     * are implemented in the same manner as layers, but do not provide the
+     * same nesting semantics.
+     * 
+     * @return {Guacamole.Layer} The newly-created buffer.
+     */
+    this.createBuffer = function() {
+        var buffer = new Guacamole.Layer(0, 0);
+        buffer.autosize = 1;
+        return buffer;
+    };
+
+    /**
+     * Flush all pending draw tasks, if possible, as a new frame. If the entire
+     * frame is not ready, the flush will wait until all required tasks are
+     * unblocked.
+     * 
+     * @param {function} callback The function to call when this frame is
+     *                            flushed. This may happen immediately, or
+     *                            later when blocked tasks become unblocked.
+     */
+    this.flush = function(callback) {
+
+        // Add frame, reset tasks
+        frames.push(new Frame(callback, tasks));
+        tasks = [];
+
+        // Attempt flush
+        __flush_frames();
+
+    };
+
+    /**
+     * Sets the hotspot and image of the mouse cursor displayed within the
+     * Guacamole display.
+     * 
+     * @param {Number} hotspotX The X coordinate of the cursor hotspot.
+     * @param {Number} hotspotY The Y coordinate of the cursor hotspot.
+     * @param {Guacamole.Layer} layer The source layer containing the data which
+     *                                should be used as the mouse cursor image.
+     * @param {Number} srcx The X coordinate of the upper-left corner of the
+     *                      rectangle within the source layer's coordinate
+     *                      space to copy data from.
+     * @param {Number} srcy The Y coordinate of the upper-left corner of the
+     *                      rectangle within the source layer's coordinate
+     *                      space to copy data from.
+     * @param {Number} srcw The width of the rectangle within the source layer's
+     *                      coordinate space to copy data from.
+     * @param {Number} srch The height of the rectangle within the source
+     *                      layer's coordinate space to copy data from.
+
+     */
+    this.setCursor = function(hotspotX, hotspotY, layer, srcx, srcy, srcw, srch) {
+        scheduleTask(function __display_set_cursor() {
+
+            // Set hotspot
+            guac_display.cursorHotspotX = hotspotX;
+            guac_display.cursorHotspotY = hotspotY;
+
+            // Reset cursor size
+            cursor.resize(srcw, srch);
+
+            // Draw cursor to cursor layer
+            cursor.copy(layer, srcx, srcy, srcw, srch, 0, 0);
+            guac_display.moveCursor(guac_display.cursorX, guac_display.cursorY);
+
+            // Fire cursor change event
+            if (guac_display.oncursor)
+                guac_display.oncursor(cursor.getCanvas(), hotspotX, hotspotY);
+
+        });
+    };
+
+    /**
+     * Sets whether the software-rendered cursor is shown. This cursor differs
+     * from the hardware cursor in that it is built into the Guacamole.Display,
+     * and relies on its own Guacamole layer to render.
+     *
+     * @param {Boolean} [shown=true] Whether to show the software cursor.
+     */
+    this.showCursor = function(shown) {
+
+        var element = cursor.getElement();
+        var parent = element.parentNode;
+
+        // Remove from DOM if hidden
+        if (shown === false) {
+            if (parent)
+                parent.removeChild(element);
+        }
+
+        // Otherwise, ensure cursor is child of display
+        else if (parent !== display)
+            display.appendChild(element);
+
+    };
+
+    /**
+     * Sets the location of the local cursor to the given coordinates. For the
+     * sake of responsiveness, this function performs its action immediately.
+     * Cursor motion is not maintained within atomic frames.
+     * 
+     * @param {Number} x The X coordinate to move the cursor to.
+     * @param {Number} y The Y coordinate to move the cursor to.
+     */
+    this.moveCursor = function(x, y) {
+
+        // Move cursor layer
+        cursor.translate(x - guac_display.cursorHotspotX,
+                         y - guac_display.cursorHotspotY);
+
+        // Update stored position
+        guac_display.cursorX = x;
+        guac_display.cursorY = y;
+
+    };
+
+    /**
+     * Changes the size of the given Layer to the given width and height.
+     * Resizing is only attempted if the new size provided is actually different
+     * from the current size.
+     * 
+     * @param {Guacamole.Layer} layer The layer to resize.
+     * @param {Number} width The new width.
+     * @param {Number} height The new height.
+     */
+    this.resize = function(layer, width, height) {
+        scheduleTask(function __display_resize() {
+
+            layer.resize(width, height);
+
+            // Resize display if default layer is resized
+            if (layer === default_layer) {
+
+                // Update (set) display size
+                displayWidth = width;
+                displayHeight = height;
+                display.style.width = displayWidth + "px";
+                display.style.height = displayHeight + "px";
+
+                // Update bounds size
+                bounds.style.width = (displayWidth*displayScale) + "px";
+                bounds.style.height = (displayHeight*displayScale) + "px";
+
+                // Notify of resize
+                if (guac_display.onresize)
+                    guac_display.onresize(width, height);
+
+            }
+
+        });
+    };
+
+    /**
+     * Draws the specified image at the given coordinates. The image specified
+     * must already be loaded.
+     * 
+     * @param {Guacamole.Layer} layer The layer to draw upon.
+     * @param {Number} x The destination X coordinate.
+     * @param {Number} y The destination Y coordinate.
+     * @param {Image} image The image to draw. Note that this is an Image
+     *                      object - not a URL.
+     */
+    this.drawImage = function(layer, x, y, image) {
+        scheduleTask(function __display_drawImage() {
+            layer.drawImage(x, y, image);
+        });
+    };
+
+    /**
+     * Draws the image contained within the specified Blob at the given
+     * coordinates. The Blob specified must already be populated with image
+     * data.
+     *
+     * @param {Guacamole.Layer} layer
+     *     The layer to draw upon.
+     *
+     * @param {Number} x
+     *     The destination X coordinate.
+     *
+     * @param {Number} y
+     *     The destination Y coordinate.
+     *
+     * @param {Blob} blob
+     *     The Blob containing the image data to draw.
+     */
+    this.drawBlob = function(layer, x, y, blob) {
+
+        // Create URL for blob
+        var url = URL.createObjectURL(blob);
+
+        // Draw and free blob URL when ready
+        var task = scheduleTask(function __display_drawBlob() {
+            layer.drawImage(x, y, image);
+            URL.revokeObjectURL(url);
+        }, true);
+
+        // Load image from URL
+        var image = new Image();
+        image.onload = task.unblock;
+        image.src = url;
+
+    };
+
+    /**
+     * Draws the image at the specified URL at the given coordinates. The image
+     * will be loaded automatically, and this and any future operations will
+     * wait for the image to finish loading.
+     * 
+     * @param {Guacamole.Layer} layer The layer to draw upon.
+     * @param {Number} x The destination X coordinate.
+     * @param {Number} y The destination Y coordinate.
+     * @param {String} url The URL of the image to draw.
+     */
+    this.draw = function(layer, x, y, url) {
+
+        var task = scheduleTask(function __display_draw() {
+            layer.drawImage(x, y, image);
+        }, true);
+
+        var image = new Image();
+        image.onload = task.unblock;
+        image.src = url;
+
+    };
+
+    /**
+     * Plays the video at the specified URL within this layer. The video
+     * will be loaded automatically, and this and any future operations will
+     * wait for the video to finish loading. Future operations will not be
+     * executed until the video finishes playing.
+     * 
+     * @param {Guacamole.Layer} layer The layer to draw upon.
+     * @param {String} mimetype The mimetype of the video to play.
+     * @param {Number} duration The duration of the video in milliseconds.
+     * @param {String} url The URL of the video to play.
+     */
+    this.play = function(layer, mimetype, duration, url) {
+
+        // Start loading the video
+        var video = document.createElement("video");
+        video.type = mimetype;
+        video.src = url;
+
+        // Start copying frames when playing
+        video.addEventListener("play", function() {
+            
+            function render_callback() {
+                layer.drawImage(0, 0, video);
+                if (!video.ended)
+                    window.setTimeout(render_callback, 20);
+            }
+            
+            render_callback();
+            
+        }, false);
+
+        scheduleTask(video.play);
+
+    };
+
+    /**
+     * Transfer a rectangle of image data from one Layer to this Layer using the
+     * specified transfer function.
+     * 
+     * @param {Guacamole.Layer} srcLayer The Layer to copy image data from.
+     * @param {Number} srcx The X coordinate of the upper-left corner of the
+     *                      rectangle within the source Layer's coordinate
+     *                      space to copy data from.
+     * @param {Number} srcy The Y coordinate of the upper-left corner of the
+     *                      rectangle within the source Layer's coordinate
+     *                      space to copy data from.
+     * @param {Number} srcw The width of the rectangle within the source Layer's
+     *                      coordinate space to copy data from.
+     * @param {Number} srch The height of the rectangle within the source
+     *                      Layer's coordinate space to copy data from.
+     * @param {Guacamole.Layer} dstLayer The layer to draw upon.
+     * @param {Number} x The destination X coordinate.
+     * @param {Number} y The destination Y coordinate.
+     * @param {Function} transferFunction The transfer function to use to
+     *                                    transfer data from source to
+     *                                    destination.
+     */
+    this.transfer = function(srcLayer, srcx, srcy, srcw, srch, dstLayer, x, y, transferFunction) {
+        scheduleTask(function __display_transfer() {
+            dstLayer.transfer(srcLayer, srcx, srcy, srcw, srch, x, y, transferFunction);
+        });
+    };
+
+    /**
+     * Put a rectangle of image data from one Layer to this Layer directly
+     * without performing any alpha blending. Simply copy the data.
+     * 
+     * @param {Guacamole.Layer} srcLayer The Layer to copy image data from.
+     * @param {Number} srcx The X coordinate of the upper-left corner of the
+     *                      rectangle within the source Layer's coordinate
+     *                      space to copy data from.
+     * @param {Number} srcy The Y coordinate of the upper-left corner of the
+     *                      rectangle within the source Layer's coordinate
+     *                      space to copy data from.
+     * @param {Number} srcw The width of the rectangle within the source Layer's
+     *                      coordinate space to copy data from.
+     * @param {Number} srch The height of the rectangle within the source
+     *                      Layer's coordinate space to copy data from.
+     * @param {Guacamole.Layer} dstLayer The layer to draw upon.
+     * @param {Number} x The destination X coordinate.
+     * @param {Number} y The destination Y coordinate.
+     */
+    this.put = function(srcLayer, srcx, srcy, srcw, srch, dstLayer, x, y) {
+        scheduleTask(function __display_put() {
+            dstLayer.put(srcLayer, srcx, srcy, srcw, srch, x, y);
+        });
+    };
+
+    /**
+     * Copy a rectangle of image data from one Layer to this Layer. This
+     * operation will copy exactly the image data that will be drawn once all
+     * operations of the source Layer that were pending at the time this
+     * function was called are complete. This operation will not alter the
+     * size of the source Layer even if its autosize property is set to true.
+     * 
+     * @param {Guacamole.Layer} srcLayer The Layer to copy image data from.
+     * @param {Number} srcx The X coordinate of the upper-left corner of the
+     *                      rectangle within the source Layer's coordinate
+     *                      space to copy data from.
+     * @param {Number} srcy The Y coordinate of the upper-left corner of the
+     *                      rectangle within the source Layer's coordinate
+     *                      space to copy data from.
+     * @param {Number} srcw The width of the rectangle within the source Layer's
+     *                      coordinate space to copy data from.
+     * @param {Number} srch The height of the rectangle within the source
+     *                      Layer's coordinate space to copy data from.
+     * @param {Guacamole.Layer} dstLayer The layer to draw upon.
+     * @param {Number} x The destination X coordinate.
+     * @param {Number} y The destination Y coordinate.
+     */
+    this.copy = function(srcLayer, srcx, srcy, srcw, srch, dstLayer, x, y) {
+        scheduleTask(function __display_copy() {
+            dstLayer.copy(srcLayer, srcx, srcy, srcw, srch, x, y);
+        });
+    };
+
+    /**
+     * Starts a new path at the specified point.
+     * 
+     * @param {Guacamole.Layer} layer The layer to draw upon.
+     * @param {Number} x The X coordinate of the point to draw.
+     * @param {Number} y The Y coordinate of the point to draw.
+     */
+    this.moveTo = function(layer, x, y) {
+        scheduleTask(function __display_moveTo() {
+            layer.moveTo(x, y);
+        });
+    };
+
+    /**
+     * Add the specified line to the current path.
+     * 
+     * @param {Guacamole.Layer} layer The layer to draw upon.
+     * @param {Number} x The X coordinate of the endpoint of the line to draw.
+     * @param {Number} y The Y coordinate of the endpoint of the line to draw.
+     */
+    this.lineTo = function(layer, x, y) {
+        scheduleTask(function __display_lineTo() {
+            layer.lineTo(x, y);
+        });
+    };
+
+    /**
+     * Add the specified arc to the current path.
+     * 
+     * @param {Guacamole.Layer} layer The layer to draw upon.
+     * @param {Number} x The X coordinate of the center of the circle which
+     *                   will contain the arc.
+     * @param {Number} y The Y coordinate of the center of the circle which
+     *                   will contain the arc.
+     * @param {Number} radius The radius of the circle.
+     * @param {Number} startAngle The starting angle of the arc, in radians.
+     * @param {Number} endAngle The ending angle of the arc, in radians.
+     * @param {Boolean} negative Whether the arc should be drawn in order of
+     *                           decreasing angle.
+     */
+    this.arc = function(layer, x, y, radius, startAngle, endAngle, negative) {
+        scheduleTask(function __display_arc() {
+            layer.arc(x, y, radius, startAngle, endAngle, negative);
+        });
+    };
+
+    /**
+     * Starts a new path at the specified point.
+     * 
+     * @param {Guacamole.Layer} layer The layer to draw upon.
+     * @param {Number} cp1x The X coordinate of the first control point.
+     * @param {Number} cp1y The Y coordinate of the first control point.
+     * @param {Number} cp2x The X coordinate of the second control point.
+     * @param {Number} cp2y The Y coordinate of the second control point.
+     * @param {Number} x The X coordinate of the endpoint of the curve.
+     * @param {Number} y The Y coordinate of the endpoint of the curve.
+     */
+    this.curveTo = function(layer, cp1x, cp1y, cp2x, cp2y, x, y) {
+        scheduleTask(function __display_curveTo() {
+            layer.curveTo(cp1x, cp1y, cp2x, cp2y, x, y);
+        });
+    };
+
+    /**
+     * Closes the current path by connecting the end point with the start
+     * point (if any) with a straight line.
+     * 
+     * @param {Guacamole.Layer} layer The layer to draw upon.
+     */
+    this.close = function(layer) {
+        scheduleTask(function __display_close() {
+            layer.close();
+        });
+    };
+
+    /**
+     * Add the specified rectangle to the current path.
+     * 
+     * @param {Guacamole.Layer} layer The layer to draw upon.
+     * @param {Number} x The X coordinate of the upper-left corner of the
+     *                   rectangle to draw.
+     * @param {Number} y The Y coordinate of the upper-left corner of the
+     *                   rectangle to draw.
+     * @param {Number} w The width of the rectangle to draw.
+     * @param {Number} h The height of the rectangle to draw.
+     */
+    this.rect = function(layer, x, y, w, h) {
+        scheduleTask(function __display_rect() {
+            layer.rect(x, y, w, h);
+        });
+    };
+
+    /**
+     * Clip all future drawing operations by the current path. The current path
+     * is implicitly closed. The current path can continue to be reused
+     * for other operations (such as fillColor()) but a new path will be started
+     * once a path drawing operation (path() or rect()) is used.
+     * 
+     * @param {Guacamole.Layer} layer The layer to affect.
+     */
+    this.clip = function(layer) {
+        scheduleTask(function __display_clip() {
+            layer.clip();
+        });
+    };
+
+    /**
+     * Stroke the current path with the specified color. The current path
+     * is implicitly closed. The current path can continue to be reused
+     * for other operations (such as clip()) but a new path will be started
+     * once a path drawing operation (path() or rect()) is used.
+     * 
+     * @param {Guacamole.Layer} layer The layer to draw upon.
+     * @param {String} cap The line cap style. Can be "round", "square",
+     *                     or "butt".
+     * @param {String} join The line join style. Can be "round", "bevel",
+     *                      or "miter".
+     * @param {Number} thickness The line thickness in pixels.
+     * @param {Number} r The red component of the color to fill.
+     * @param {Number} g The green component of the color to fill.
+     * @param {Number} b The blue component of the color to fill.
+     * @param {Number} a The alpha component of the color to fill.
+     */
+    this.strokeColor = function(layer, cap, join, thickness, r, g, b, a) {
+        scheduleTask(function __display_strokeColor() {
+            layer.strokeColor(cap, join, thickness, r, g, b, a);
+        });
+    };
+
+    /**
+     * Fills the current path with the specified color. The current path
+     * is implicitly closed. The current path can continue to be reused
+     * for other operations (such as clip()) but a new path will be started
+     * once a path drawing operation (path() or rect()) is used.
+     * 
+     * @param {Guacamole.Layer} layer The layer to draw upon.
+     * @param {Number} r The red component of the color to fill.
+     * @param {Number} g The green component of the color to fill.
+     * @param {Number} b The blue component of the color to fill.
+     * @param {Number} a The alpha component of the color to fill.
+     */
+    this.fillColor = function(layer, r, g, b, a) {
+        scheduleTask(function __display_fillColor() {
+            layer.fillColor(r, g, b, a);
+        });
+    };
+
+    /**
+     * Stroke the current path with the image within the specified layer. The
+     * image data will be tiled infinitely within the stroke. The current path
+     * is implicitly closed. The current path can continue to be reused
+     * for other operations (such as clip()) but a new path will be started
+     * once a path drawing operation (path() or rect()) is used.
+     * 
+     * @param {Guacamole.Layer} layer The layer to draw upon.
+     * @param {String} cap The line cap style. Can be "round", "square",
+     *                     or "butt".
+     * @param {String} join The line join style. Can be "round", "bevel",
+     *                      or "miter".
+     * @param {Number} thickness The line thickness in pixels.
+     * @param {Guacamole.Layer} srcLayer The layer to use as a repeating pattern
+     *                                   within the stroke.
+     */
+    this.strokeLayer = function(layer, cap, join, thickness, srcLayer) {
+        scheduleTask(function __display_strokeLayer() {
+            layer.strokeLayer(cap, join, thickness, srcLayer);
+        });
+    };
+
+    /**
+     * Fills the current path with the image within the specified layer. The
+     * image data will be tiled infinitely within the stroke. The current path
+     * is implicitly closed. The current path can continue to be reused
+     * for other operations (such as clip()) but a new path will be started
+     * once a path drawing operation (path() or rect()) is used.
+     * 
+     * @param {Guacamole.Layer} layer The layer to draw upon.
+     * @param {Guacamole.Layer} srcLayer The layer to use as a repeating pattern
+     *                                   within the fill.
+     */
+    this.fillLayer = function(layer, srcLayer) {
+        scheduleTask(function __display_fillLayer() {
+            layer.fillLayer(srcLayer);
+        });
+    };
+
+    /**
+     * Push current layer state onto stack.
+     * 
+     * @param {Guacamole.Layer} layer The layer to draw upon.
+     */
+    this.push = function(layer) {
+        scheduleTask(function __display_push() {
+            layer.push();
+        });
+    };
+
+    /**
+     * Pop layer state off stack.
+     * 
+     * @param {Guacamole.Layer} layer The layer to draw upon.
+     */
+    this.pop = function(layer) {
+        scheduleTask(function __display_pop() {
+            layer.pop();
+        });
+    };
+
+    /**
+     * Reset the layer, clearing the stack, the current path, and any transform
+     * matrix.
+     * 
+     * @param {Guacamole.Layer} layer The layer to draw upon.
+     */
+    this.reset = function(layer) {
+        scheduleTask(function __display_reset() {
+            layer.reset();
+        });
+    };
+
+    /**
+     * Sets the given affine transform (defined with six values from the
+     * transform's matrix).
+     * 
+     * @param {Guacamole.Layer} layer The layer to modify.
+     * @param {Number} a The first value in the affine transform's matrix.
+     * @param {Number} b The second value in the affine transform's matrix.
+     * @param {Number} c The third value in the affine transform's matrix.
+     * @param {Number} d The fourth value in the affine transform's matrix.
+     * @param {Number} e The fifth value in the affine transform's matrix.
+     * @param {Number} f The sixth value in the affine transform's matrix.
+     */
+    this.setTransform = function(layer, a, b, c, d, e, f) {
+        scheduleTask(function __display_setTransform() {
+            layer.setTransform(a, b, c, d, e, f);
+        });
+    };
+
+    /**
+     * Applies the given affine transform (defined with six values from the
+     * transform's matrix).
+     * 
+     * @param {Guacamole.Layer} layer The layer to modify.
+     * @param {Number} a The first value in the affine transform's matrix.
+     * @param {Number} b The second value in the affine transform's matrix.
+     * @param {Number} c The third value in the affine transform's matrix.
+     * @param {Number} d The fourth value in the affine transform's matrix.
+     * @param {Number} e The fifth value in the affine transform's matrix.
+     * @param {Number} f The sixth value in the affine transform's matrix.
+     */
+    this.transform = function(layer, a, b, c, d, e, f) {
+        scheduleTask(function __display_transform() {
+            layer.transform(a, b, c, d, e, f);
+        });
+    };
+
+    /**
+     * Sets the channel mask for future operations on this Layer.
+     * 
+     * The channel mask is a Guacamole-specific compositing operation identifier
+     * with a single bit representing each of four channels (in order): source
+     * image where destination transparent, source where destination opaque,
+     * destination where source transparent, and destination where source
+     * opaque.
+     * 
+     * @param {Guacamole.Layer} layer The layer to modify.
+     * @param {Number} mask The channel mask for future operations on this
+     *                      Layer.
+     */
+    this.setChannelMask = function(layer, mask) {
+        scheduleTask(function __display_setChannelMask() {
+            layer.setChannelMask(mask);
+        });
+    };
+
+    /**
+     * Sets the miter limit for stroke operations using the miter join. This
+     * limit is the maximum ratio of the size of the miter join to the stroke
+     * width. If this ratio is exceeded, the miter will not be drawn for that
+     * joint of the path.
+     * 
+     * @param {Guacamole.Layer} layer The layer to modify.
+     * @param {Number} limit The miter limit for stroke operations using the
+     *                       miter join.
+     */
+    this.setMiterLimit = function(layer, limit) {
+        scheduleTask(function __display_setMiterLimit() {
+            layer.setMiterLimit(limit);
+        });
+    };
+
+    /**
+     * Sets the scale of the client display element such that it renders at
+     * a relatively smaller or larger size, without affecting the true
+     * resolution of the display.
+     *
+     * @param {Number} scale The scale to resize to, where 1.0 is normal
+     *                       size (1:1 scale).
+     */
+    this.scale = function(scale) {
+
+        display.style.transform =
+        display.style.WebkitTransform =
+        display.style.MozTransform =
+        display.style.OTransform =
+        display.style.msTransform =
+
+            "scale(" + scale + "," + scale + ")";
+
+        displayScale = scale;
+
+        // Update bounds size
+        bounds.style.width = (displayWidth*displayScale) + "px";
+        bounds.style.height = (displayHeight*displayScale) + "px";
+
+    };
+
+    /**
+     * Returns the scale of the display.
+     *
+     * @return {Number} The scale of the display.
+     */
+    this.getScale = function() {
+        return displayScale;
+    };
+
+    /**
+     * Returns a canvas element containing the entire display, with all child
+     * layers composited within.
+     *
+     * @return {HTMLCanvasElement} A new canvas element containing a copy of
+     *                             the display.
+     */
+    this.flatten = function() {
+       
+        // Get destination canvas
+        var canvas = document.createElement("canvas");
+        canvas.width = default_layer.width;
+        canvas.height = default_layer.height;
+
+        var context = canvas.getContext("2d");
+
+        // Returns sorted array of children
+        function get_children(layer) {
+
+            // Build array of children
+            var children = [];
+            for (var index in layer.children)
+                children.push(layer.children[index]);
+
+            // Sort
+            children.sort(function children_comparator(a, b) {
+
+                // Compare based on Z order
+                var diff = a.z - b.z;
+                if (diff !== 0)
+                    return diff;
+
+                // If Z order identical, use document order
+                var a_element = a.getElement();
+                var b_element = b.getElement();
+                var position = b_element.compareDocumentPosition(a_element);
+
+                if (position & Node.DOCUMENT_POSITION_PRECEDING) return -1;
+                if (position & Node.DOCUMENT_POSITION_FOLLOWING) return  1;
+
+                // Otherwise, assume same
+                return 0;
+
+            });
+
+            // Done
+            return children;
+
+        }
+
+        // Draws the contents of the given layer at the given coordinates
+        function draw_layer(layer, x, y) {
+
+            // Draw layer
+            if (layer.width > 0 && layer.height > 0) {
+
+                // Save and update alpha
+                var initial_alpha = context.globalAlpha;
+                context.globalAlpha *= layer.alpha / 255.0;
+
+                // Copy data
+                context.drawImage(layer.getCanvas(), x, y);
+
+                // Draw all children
+                var children = get_children(layer);
+                for (var i=0; i<children.length; i++) {
+                    var child = children[i];
+                    draw_layer(child, x + child.x, y + child.y);
+                }
+
+                // Restore alpha
+                context.globalAlpha = initial_alpha;
+
+            }
+
+        }
+
+        // Draw default layer and all children
+        draw_layer(default_layer, 0, 0);
+
+        // Return new canvas copy
+        return canvas;
+        
+    };
+
+};
+
+/**
+ * Simple container for Guacamole.Layer, allowing layers to be easily
+ * repositioned and nested. This allows certain operations to be accelerated
+ * through DOM manipulation, rather than raster operations.
+ * 
+ * @constructor
+ * @augments Guacamole.Layer
+ * @param {Number} width The width of the Layer, in pixels. The canvas element
+ *                       backing this Layer will be given this width.
+ * @param {Number} height The height of the Layer, in pixels. The canvas element
+ *                        backing this Layer will be given this height.
+ */
+Guacamole.Display.VisibleLayer = function(width, height) {
+
+    Guacamole.Layer.apply(this, [width, height]);
+
+    /**
+     * Reference to this layer.
+     * @private
+     */
+    var layer = this;
+
+    /**
+     * Identifier which uniquely identifies this layer. This is COMPLETELY
+     * UNRELATED to the index of the underlying layer, which is specific
+     * to the Guacamole protocol, and not relevant at this level.
+     * 
+     * @private
+     * @type {Number}
+     */
+    this.__unique_id = Guacamole.Display.VisibleLayer.__next_id++;
+
+    /**
+     * The opacity of the layer container, where 255 is fully opaque and 0 is
+     * fully transparent.
+     */
+    this.alpha = 0xFF;
+
+    /**
+     * X coordinate of the upper-left corner of this layer container within
+     * its parent, in pixels.
+     * @type {Number}
+     */
+    this.x = 0;
+
+    /**
+     * Y coordinate of the upper-left corner of this layer container within
+     * its parent, in pixels.
+     * @type {Number}
+     */
+    this.y = 0;
+
+    /**
+     * Z stacking order of this layer relative to other sibling layers.
+     * @type {Number}
+     */
+    this.z = 0;
+
+    /**
+     * The affine transformation applied to this layer container. Each element
+     * corresponds to a value from the transformation matrix, with the first
+     * three values being the first row, and the last three values being the
+     * second row. There are six values total.
+     * 
+     * @type {Number[]}
+     */
+    this.matrix = [1, 0, 0, 1, 0, 0];
+
+    /**
+     * The parent layer container of this layer, if any.
+     * @type {Guacamole.Display.VisibleLayer}
+     */
+    this.parent = null;
+
+    /**
+     * Set of all children of this layer, indexed by layer index. This object
+     * will have one property per child.
+     */
+    this.children = {};
+
+    // Set layer position
+    var canvas = layer.getCanvas();
+    canvas.style.position = "absolute";
+    canvas.style.left = "0px";
+    canvas.style.top = "0px";
+
+    // Create div with given size
+    var div = document.createElement("div");
+    div.appendChild(canvas);
+    div.style.width = width + "px";
+    div.style.height = height + "px";
+    div.style.position = "absolute";
+    div.style.left = "0px";
+    div.style.top = "0px";
+    div.style.overflow = "hidden";
+
+    /**
+     * Superclass resize() function.
+     * @private
+     */
+    var __super_resize = this.resize;
+
+    this.resize = function(width, height) {
+
+        // Resize containing div
+        div.style.width = width + "px";
+        div.style.height = height + "px";
+
+        __super_resize(width, height);
+
+    };
+  
+    /**
+     * Returns the element containing the canvas and any other elements
+     * associated with this layer.
+     * @returns {Element} The element containing this layer's canvas.
+     */
+    this.getElement = function() {
+        return div;
+    };
+
+    /**
+     * The translation component of this layer's transform.
+     * @private
+     */
+    var translate = "translate(0px, 0px)"; // (0, 0)
+
+    /**
+     * The arbitrary matrix component of this layer's transform.
+     * @private
+     */
+    var matrix = "matrix(1, 0, 0, 1, 0, 0)"; // Identity
+
+    /**
+     * Moves the upper-left corner of this layer to the given X and Y
+     * coordinate.
+     * 
+     * @param {Number} x The X coordinate to move to.
+     * @param {Number} y The Y coordinate to move to.
+     */
+    this.translate = function(x, y) {
+
+        layer.x = x;
+        layer.y = y;
+
+        // Generate translation
+        translate = "translate("
+                        + x + "px,"
+                        + y + "px)";
+
+        // Set layer transform 
+        div.style.transform =
+        div.style.WebkitTransform =
+        div.style.MozTransform =
+        div.style.OTransform =
+        div.style.msTransform =
+
+            translate + " " + matrix;
+
+    };
+
+    /**
+     * Moves the upper-left corner of this VisibleLayer to the given X and Y
+     * coordinate, sets the Z stacking order, and reparents this VisibleLayer
+     * to the given VisibleLayer.
+     * 
+     * @param {Guacamole.Display.VisibleLayer} parent The parent to set.
+     * @param {Number} x The X coordinate to move to.
+     * @param {Number} y The Y coordinate to move to.
+     * @param {Number} z The Z coordinate to move to.
+     */
+    this.move = function(parent, x, y, z) {
+
+        // Set parent if necessary
+        if (layer.parent !== parent) {
+
+            // Maintain relationship
+            if (layer.parent)
+                delete layer.parent.children[layer.__unique_id];
+            layer.parent = parent;
+            parent.children[layer.__unique_id] = layer;
+
+            // Reparent element
+            var parent_element = parent.getElement();
+            parent_element.appendChild(div);
+
+        }
+
+        // Set location
+        layer.translate(x, y);
+        layer.z = z;
+        div.style.zIndex = z;
+
+    };
+
+    /**
+     * Sets the opacity of this layer to the given value, where 255 is fully
+     * opaque and 0 is fully transparent.
+     * 
+     * @param {Number} a The opacity to set.
+     */
+    this.shade = function(a) {
+        layer.alpha = a;
+        div.style.opacity = a/255.0;
+    };
+
+    /**
+     * Removes this layer container entirely, such that it is no longer
+     * contained within its parent layer, if any.
+     */
+    this.dispose = function() {
+
+        // Remove from parent container
+        if (layer.parent) {
+            delete layer.parent.children[layer.__unique_id];
+            layer.parent = null;
+        }
+
+        // Remove from parent element
+        if (div.parentNode)
+            div.parentNode.removeChild(div);
+        
+    };
+
+    /**
+     * Applies the given affine transform (defined with six values from the
+     * transform's matrix).
+     * 
+     * @param {Number} a The first value in the affine transform's matrix.
+     * @param {Number} b The second value in the affine transform's matrix.
+     * @param {Number} c The third value in the affine transform's matrix.
+     * @param {Number} d The fourth value in the affine transform's matrix.
+     * @param {Number} e The fifth value in the affine transform's matrix.
+     * @param {Number} f The sixth value in the affine transform's matrix.
+     */
+    this.distort = function(a, b, c, d, e, f) {
+
+        // Store matrix
+        layer.matrix = [a, b, c, d, e, f];
+
+        // Generate matrix transformation
+        matrix =
+
+            /* a c e
+             * b d f
+             * 0 0 1
+             */
+    
+            "matrix(" + a + "," + b + "," + c + "," + d + "," + e + "," + f + ")";
+
+        // Set layer transform 
+        div.style.transform =
+        div.style.WebkitTransform =
+        div.style.MozTransform =
+        div.style.OTransform =
+        div.style.msTransform =
+
+            translate + " " + matrix;
+
+    };
+
+};
+
+/**
+ * The next identifier to be assigned to the layer container. This identifier
+ * uniquely identifies each VisibleLayer, but is unrelated to the index of
+ * the layer, which exists at the protocol/client level only.
+ * 
+ * @private
+ * @type {Number}
+ */
+Guacamole.Display.VisibleLayer.__next_id = 0;
diff --git a/guacamole-common-js/src/main/webapp/modules/InputStream.js b/guacamole-common-js/src/main/webapp/modules/InputStream.js
new file mode 100644
index 0000000..f60c469
--- /dev/null
+++ b/guacamole-common-js/src/main/webapp/modules/InputStream.js
@@ -0,0 +1,73 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+var Guacamole = Guacamole || {};
+
+/**
+ * An input stream abstraction used by the Guacamole client to facilitate
+ * transfer of files or other binary data.
+ * 
+ * @constructor
+ * @param {Guacamole.Client} client The client owning this stream.
+ * @param {Number} index The index of this stream.
+ */
+Guacamole.InputStream = function(client, index) {
+
+    /**
+     * Reference to this stream.
+     * @private
+     */
+    var guac_stream = this;
+
+    /**
+     * The index of this stream.
+     * @type {Number}
+     */
+    this.index = index;
+
+    /**
+     * Called when a blob of data is received.
+     * 
+     * @event
+     * @param {String} data The received base64 data.
+     */
+    this.onblob = null;
+
+    /**
+     * Called when this stream is closed.
+     * 
+     * @event
+     */
+    this.onend = null;
+
+    /**
+     * Acknowledges the receipt of a blob.
+     * 
+     * @param {String} message A human-readable message describing the error
+     *                         or status.
+     * @param {Number} code The error code, if any, or 0 for success.
+     */
+    this.sendAck = function(message, code) {
+        client.sendAck(guac_stream.index, message, code);
+    };
+
+};
diff --git a/guacamole-common-js/src/main/webapp/modules/IntegerPool.js b/guacamole-common-js/src/main/webapp/modules/IntegerPool.js
new file mode 100644
index 0000000..a790e41
--- /dev/null
+++ b/guacamole-common-js/src/main/webapp/modules/IntegerPool.js
@@ -0,0 +1,79 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+var Guacamole = Guacamole || {};
+
+/**
+ * Integer pool which returns consistently increasing integers while integers
+ * are in use, and previously-used integers when possible.
+ * @constructor 
+ */
+Guacamole.IntegerPool = function() {
+
+    /**
+     * Reference to this integer pool.
+     *
+     * @private
+     */
+    var guac_pool = this;
+
+    /**
+     * Array of available integers.
+     *
+     * @private
+     * @type {Number[]}
+     */
+    var pool = [];
+
+    /**
+     * The next integer to return if no more integers remain.
+     * @type {Number}
+     */
+    this.next_int = 0;
+
+    /**
+     * Returns the next available integer in the pool. If possible, a previously
+     * used integer will be returned.
+     * 
+     * @return {Number} The next available integer.
+     */
+    this.next = function() {
+
+        // If free'd integers exist, return one of those
+        if (pool.length > 0)
+            return pool.shift();
+
+        // Otherwise, return a new integer
+        return guac_pool.next_int++;
+
+    };
+
+    /**
+     * Frees the given integer, allowing it to be reused.
+     * 
+     * @param {Number} integer The integer to free.
+     */
+    this.free = function(integer) {
+        pool.push(integer);
+    };
+
+};
diff --git a/guacamole-common-js/src/main/webapp/modules/JSONReader.js b/guacamole-common-js/src/main/webapp/modules/JSONReader.js
new file mode 100644
index 0000000..702f45e
--- /dev/null
+++ b/guacamole-common-js/src/main/webapp/modules/JSONReader.js
@@ -0,0 +1,117 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+var Guacamole = Guacamole || {};
+
+/**
+ * A reader which automatically handles the given input stream, assembling all
+ * received blobs into a JavaScript object by appending them to each other, in
+ * order, and decoding the result as JSON. Note that this object will overwrite
+ * any installed event handlers on the given Guacamole.InputStream.
+ * 
+ * @constructor
+ * @param {Guacamole.InputStream} stream
+ *     The stream that JSON will be read from.
+ */
+Guacamole.JSONReader = function guacamoleJSONReader(stream) {
+
+    /**
+     * Reference to this Guacamole.JSONReader.
+     *
+     * @private
+     * @type {Guacamole.JSONReader}
+     */
+    var guacReader = this;
+
+    /**
+     * Wrapped Guacamole.StringReader.
+     *
+     * @private
+     * @type {Guacamole.StringReader}
+     */
+    var stringReader = new Guacamole.StringReader(stream);
+
+    /**
+     * All JSON read thus far.
+     *
+     * @private
+     * @type {String}
+     */
+    var json = '';
+
+    /**
+     * Returns the current length of this Guacamole.JSONReader, in characters.
+     *
+     * @return {Number}
+     *     The current length of this Guacamole.JSONReader.
+     */
+    this.getLength = function getLength() {
+        return json.length;
+    };
+
+    /**
+     * Returns the contents of this Guacamole.JSONReader as a JavaScript
+     * object.
+     *
+     * @return {Object}
+     *     The contents of this Guacamole.JSONReader, as parsed from the JSON
+     *     contents of the input stream.
+     */
+    this.getJSON = function getJSON() {
+        return JSON.parse(json);
+    };
+
+    // Append all received text
+    stringReader.ontext = function ontext(text) {
+
+        // Append received text
+        json += text;
+
+        // Call handler, if present
+        if (guacReader.onprogress)
+            guacReader.onprogress(text.length);
+
+    };
+
+    // Simply call onend when end received
+    stringReader.onend = function onend() {
+        if (guacReader.onend)
+            guacReader.onend();
+    };
+
+    /**
+     * Fired once for every blob of data received.
+     * 
+     * @event
+     * @param {Number} length
+     *     The number of characters received.
+     */
+    this.onprogress = null;
+
+    /**
+     * Fired once this stream is finished and no further data will be written.
+     *
+     * @event
+     */
+    this.onend = null;
+
+};
diff --git a/guacamole-common-js/src/main/webapp/modules/Keyboard.js b/guacamole-common-js/src/main/webapp/modules/Keyboard.js
new file mode 100644
index 0000000..ce9f016
--- /dev/null
+++ b/guacamole-common-js/src/main/webapp/modules/Keyboard.js
@@ -0,0 +1,1162 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+var Guacamole = Guacamole || {};
+
+/**
+ * Provides cross-browser and cross-keyboard keyboard for a specific element.
+ * Browser and keyboard layout variation is abstracted away, providing events
+ * which represent keys as their corresponding X11 keysym.
+ * 
+ * @constructor
+ * @param {Element} element The Element to use to provide keyboard events.
+ */
+Guacamole.Keyboard = function(element) {
+
+    /**
+     * Reference to this Guacamole.Keyboard.
+     * @private
+     */
+    var guac_keyboard = this;
+
+    /**
+     * Fired whenever the user presses a key with the element associated
+     * with this Guacamole.Keyboard in focus.
+     * 
+     * @event
+     * @param {Number} keysym The keysym of the key being pressed.
+     * @return {Boolean} true if the key event should be allowed through to the
+     *                   browser, false otherwise.
+     */
+    this.onkeydown = null;
+
+    /**
+     * Fired whenever the user releases a key with the element associated
+     * with this Guacamole.Keyboard in focus.
+     * 
+     * @event
+     * @param {Number} keysym The keysym of the key being released.
+     */
+    this.onkeyup = null;
+
+    /**
+     * A key event having a corresponding timestamp. This event is non-specific.
+     * Its subclasses should be used instead when recording specific key
+     * events.
+     *
+     * @private
+     * @constructor
+     */
+    var KeyEvent = function() {
+
+        /**
+         * Reference to this key event.
+         */
+        var key_event = this;
+
+        /**
+         * An arbitrary timestamp in milliseconds, indicating this event's
+         * position in time relative to other events.
+         *
+         * @type {Number}
+         */
+        this.timestamp = new Date().getTime();
+
+        /**
+         * Whether the default action of this key event should be prevented.
+         *
+         * @type {Boolean}
+         */
+        this.defaultPrevented = false;
+
+        /**
+         * The keysym of the key associated with this key event, as determined
+         * by a best-effort guess using available event properties and keyboard
+         * state.
+         *
+         * @type {Number}
+         */
+        this.keysym = null;
+
+        /**
+         * Whether the keysym value of this key event is known to be reliable.
+         * If false, the keysym may still be valid, but it's only a best guess,
+         * and future key events may be a better source of information.
+         *
+         * @type {Boolean}
+         */
+        this.reliable = false;
+
+        /**
+         * Returns the number of milliseconds elapsed since this event was
+         * received.
+         *
+         * @return {Number} The number of milliseconds elapsed since this
+         *                  event was received.
+         */
+        this.getAge = function() {
+            return new Date().getTime() - key_event.timestamp;
+        };
+
+    };
+
+    /**
+     * Information related to the pressing of a key, which need not be a key
+     * associated with a printable character. The presence or absence of any
+     * information within this object is browser-dependent.
+     *
+     * @private
+     * @constructor
+     * @augments Guacamole.Keyboard.KeyEvent
+     * @param {Number} keyCode The JavaScript key code of the key pressed.
+     * @param {String} keyIdentifier The legacy DOM3 "keyIdentifier" of the key
+     *                               pressed, as defined at:
+     *                               http://www.w3.org/TR/2009/WD-DOM-Level-3-Events-20090908/#events-Events-KeyboardEvent
+     * @param {String} key The standard name of the key pressed, as defined at:
+     *                     http://www.w3.org/TR/DOM-Level-3-Events/#events-KeyboardEvent
+     * @param {Number} location The location on the keyboard corresponding to
+     *                          the key pressed, as defined at:
+     *                          http://www.w3.org/TR/DOM-Level-3-Events/#events-KeyboardEvent
+     */
+    var KeydownEvent = function(keyCode, keyIdentifier, key, location) {
+
+        // We extend KeyEvent
+        KeyEvent.apply(this);
+
+        /**
+         * The JavaScript key code of the key pressed.
+         *
+         * @type {Number}
+         */
+        this.keyCode = keyCode;
+
+        /**
+         * The legacy DOM3 "keyIdentifier" of the key pressed, as defined at:
+         * http://www.w3.org/TR/2009/WD-DOM-Level-3-Events-20090908/#events-Events-KeyboardEvent
+         *
+         * @type {String}
+         */
+        this.keyIdentifier = keyIdentifier;
+
+        /**
+         * The standard name of the key pressed, as defined at:
+         * http://www.w3.org/TR/DOM-Level-3-Events/#events-KeyboardEvent
+         * 
+         * @type {String}
+         */
+        this.key = key;
+
+        /**
+         * The location on the keyboard corresponding to the key pressed, as
+         * defined at:
+         * http://www.w3.org/TR/DOM-Level-3-Events/#events-KeyboardEvent
+         * 
+         * @type {Number}
+         */
+        this.location = location;
+
+        // If key is known from keyCode or DOM3 alone, use that
+        this.keysym =  keysym_from_key_identifier(key, location)
+                    || keysym_from_keycode(keyCode, location);
+
+        // DOM3 and keyCode are reliable sources
+        if (this.keysym)
+            this.reliable = true;
+
+        // Use legacy keyIdentifier as a last resort, if it looks sane
+        if (!this.keysym && key_identifier_sane(keyCode, keyIdentifier))
+            this.keysym = keysym_from_key_identifier(keyIdentifier, location, guac_keyboard.modifiers.shift);
+
+        // Determine whether default action for Alt+combinations must be prevented
+        var prevent_alt =  !guac_keyboard.modifiers.ctrl
+                        && !(navigator && navigator.platform && navigator.platform.match(/^mac/i));
+
+        // Determine whether default action for Ctrl+combinations must be prevented
+        var prevent_ctrl = !guac_keyboard.modifiers.alt;
+
+        // We must rely on the (potentially buggy) keyIdentifier if preventing
+        // the default action is important
+        if ((prevent_ctrl && guac_keyboard.modifiers.ctrl)
+         || (prevent_alt  && guac_keyboard.modifiers.alt)
+         || guac_keyboard.modifiers.meta
+         || guac_keyboard.modifiers.hyper)
+            this.reliable = true;
+
+        // Record most recently known keysym by associated key code
+        recentKeysym[keyCode] = this.keysym;
+
+    };
+
+    KeydownEvent.prototype = new KeyEvent();
+
+    /**
+     * Information related to the pressing of a key, which MUST be
+     * associated with a printable character. The presence or absence of any
+     * information within this object is browser-dependent.
+     *
+     * @private
+     * @constructor
+     * @augments Guacamole.Keyboard.KeyEvent
+     * @param {Number} charCode The Unicode codepoint of the character that
+     *                          would be typed by the key pressed.
+     */
+    var KeypressEvent = function(charCode) {
+
+        // We extend KeyEvent
+        KeyEvent.apply(this);
+
+        /**
+         * The Unicode codepoint of the character that would be typed by the
+         * key pressed.
+         *
+         * @type {Number}
+         */
+        this.charCode = charCode;
+
+        // Pull keysym from char code
+        this.keysym = keysym_from_charcode(charCode);
+
+        // Keypress is always reliable
+        this.reliable = true;
+
+    };
+
+    KeypressEvent.prototype = new KeyEvent();
+
+    /**
+     * Information related to the pressing of a key, which need not be a key
+     * associated with a printable character. The presence or absence of any
+     * information within this object is browser-dependent.
+     *
+     * @private
+     * @constructor
+     * @augments Guacamole.Keyboard.KeyEvent
+     * @param {Number} keyCode The JavaScript key code of the key released.
+     * @param {String} keyIdentifier The legacy DOM3 "keyIdentifier" of the key
+     *                               released, as defined at:
+     *                               http://www.w3.org/TR/2009/WD-DOM-Level-3-Events-20090908/#events-Events-KeyboardEvent
+     * @param {String} key The standard name of the key released, as defined at:
+     *                     http://www.w3.org/TR/DOM-Level-3-Events/#events-KeyboardEvent
+     * @param {Number} location The location on the keyboard corresponding to
+     *                          the key released, as defined at:
+     *                          http://www.w3.org/TR/DOM-Level-3-Events/#events-KeyboardEvent
+     */
+    var KeyupEvent = function(keyCode, keyIdentifier, key, location) {
+
+        // We extend KeyEvent
+        KeyEvent.apply(this);
+
+        /**
+         * The JavaScript key code of the key released.
+         *
+         * @type {Number}
+         */
+        this.keyCode = keyCode;
+
+        /**
+         * The legacy DOM3 "keyIdentifier" of the key released, as defined at:
+         * http://www.w3.org/TR/2009/WD-DOM-Level-3-Events-20090908/#events-Events-KeyboardEvent
+         *
+         * @type {String}
+         */
+        this.keyIdentifier = keyIdentifier;
+
+        /**
+         * The standard name of the key released, as defined at:
+         * http://www.w3.org/TR/DOM-Level-3-Events/#events-KeyboardEvent
+         * 
+         * @type {String}
+         */
+        this.key = key;
+
+        /**
+         * The location on the keyboard corresponding to the key released, as
+         * defined at:
+         * http://www.w3.org/TR/DOM-Level-3-Events/#events-KeyboardEvent
+         * 
+         * @type {Number}
+         */
+        this.location = location;
+
+        // If key is known from keyCode or DOM3 alone, use that
+        this.keysym =  recentKeysym[keyCode]
+                    || keysym_from_keycode(keyCode, location)
+                    || keysym_from_key_identifier(key, location); // keyCode is still more reliable for keyup when dead keys are in use
+
+        // Keyup is as reliable as it will ever be
+        this.reliable = true;
+
+    };
+
+    KeyupEvent.prototype = new KeyEvent();
+
+    /**
+     * An array of recorded events, which can be instances of the private
+     * KeydownEvent, KeypressEvent, and KeyupEvent classes.
+     *
+     * @private
+     * @type {KeyEvent[]}
+     */
+    var eventLog = [];
+
+    /**
+     * Map of known JavaScript keycodes which do not map to typable characters
+     * to their X11 keysym equivalents.
+     * @private
+     */
+    var keycodeKeysyms = {
+        8:   [0xFF08], // backspace
+        9:   [0xFF09], // tab
+        12:  [0xFF0B, 0xFF0B, 0xFF0B, 0xFFB5], // clear       / KP 5
+        13:  [0xFF0D], // enter
+        16:  [0xFFE1, 0xFFE1, 0xFFE2], // shift
+        17:  [0xFFE3, 0xFFE3, 0xFFE4], // ctrl
+        18:  [0xFFE9, 0xFFE9, 0xFE03], // alt
+        19:  [0xFF13], // pause/break
+        20:  [0xFFE5], // caps lock
+        27:  [0xFF1B], // escape
+        32:  [0x0020], // space
+        33:  [0xFF55, 0xFF55, 0xFF55, 0xFFB9], // page up     / KP 9
+        34:  [0xFF56, 0xFF56, 0xFF56, 0xFFB3], // page down   / KP 3
+        35:  [0xFF57, 0xFF57, 0xFF57, 0xFFB1], // end         / KP 1
+        36:  [0xFF50, 0xFF50, 0xFF50, 0xFFB7], // home        / KP 7
+        37:  [0xFF51, 0xFF51, 0xFF51, 0xFFB4], // left arrow  / KP 4
+        38:  [0xFF52, 0xFF52, 0xFF52, 0xFFB8], // up arrow    / KP 8
+        39:  [0xFF53, 0xFF53, 0xFF53, 0xFFB6], // right arrow / KP 6
+        40:  [0xFF54, 0xFF54, 0xFF54, 0xFFB2], // down arrow  / KP 2
+        45:  [0xFF63, 0xFF63, 0xFF63, 0xFFB0], // insert      / KP 0
+        46:  [0xFFFF, 0xFFFF, 0xFFFF, 0xFFAE], // delete      / KP decimal
+        91:  [0xFFEB], // left window key (hyper_l)
+        92:  [0xFF67], // right window key (menu key?)
+        93:  null,     // select key
+        96:  [0xFFB0], // KP 0
+        97:  [0xFFB1], // KP 1
+        98:  [0xFFB2], // KP 2
+        99:  [0xFFB3], // KP 3
+        100: [0xFFB4], // KP 4
+        101: [0xFFB5], // KP 5
+        102: [0xFFB6], // KP 6
+        103: [0xFFB7], // KP 7
+        104: [0xFFB8], // KP 8
+        105: [0xFFB9], // KP 9
+        106: [0xFFAA], // KP multiply
+        107: [0xFFAB], // KP add
+        109: [0xFFAD], // KP subtract
+        110: [0xFFAE], // KP decimal
+        111: [0xFFAF], // KP divide
+        112: [0xFFBE], // f1
+        113: [0xFFBF], // f2
+        114: [0xFFC0], // f3
+        115: [0xFFC1], // f4
+        116: [0xFFC2], // f5
+        117: [0xFFC3], // f6
+        118: [0xFFC4], // f7
+        119: [0xFFC5], // f8
+        120: [0xFFC6], // f9
+        121: [0xFFC7], // f10
+        122: [0xFFC8], // f11
+        123: [0xFFC9], // f12
+        144: [0xFF7F], // num lock
+        145: [0xFF14], // scroll lock
+        225: [0xFE03]  // altgraph (iso_level3_shift)
+    };
+
+    /**
+     * Map of known JavaScript keyidentifiers which do not map to typable
+     * characters to their unshifted X11 keysym equivalents.
+     * @private
+     */
+    var keyidentifier_keysym = {
+        "Again": [0xFF66],
+        "AllCandidates": [0xFF3D],
+        "Alphanumeric": [0xFF30],
+        "Alt": [0xFFE9, 0xFFE9, 0xFE03],
+        "Attn": [0xFD0E],
+        "AltGraph": [0xFE03],
+        "ArrowDown": [0xFF54],
+        "ArrowLeft": [0xFF51],
+        "ArrowRight": [0xFF53],
+        "ArrowUp": [0xFF52],
+        "Backspace": [0xFF08],
+        "CapsLock": [0xFFE5],
+        "Cancel": [0xFF69],
+        "Clear": [0xFF0B],
+        "Convert": [0xFF21],
+        "Copy": [0xFD15],
+        "Crsel": [0xFD1C],
+        "CrSel": [0xFD1C],
+        "CodeInput": [0xFF37],
+        "Compose": [0xFF20],
+        "Control": [0xFFE3, 0xFFE3, 0xFFE4],
+        "ContextMenu": [0xFF67],
+        "DeadGrave": [0xFE50],
+        "DeadAcute": [0xFE51],
+        "DeadCircumflex": [0xFE52],
+        "DeadTilde": [0xFE53],
+        "DeadMacron": [0xFE54],
+        "DeadBreve": [0xFE55],
+        "DeadAboveDot": [0xFE56],
+        "DeadUmlaut": [0xFE57],
+        "DeadAboveRing": [0xFE58],
+        "DeadDoubleacute": [0xFE59],
+        "DeadCaron": [0xFE5A],
+        "DeadCedilla": [0xFE5B],
+        "DeadOgonek": [0xFE5C],
+        "DeadIota": [0xFE5D],
+        "DeadVoicedSound": [0xFE5E],
+        "DeadSemivoicedSound": [0xFE5F],
+        "Delete": [0xFFFF],
+        "Down": [0xFF54],
+        "End": [0xFF57],
+        "Enter": [0xFF0D],
+        "EraseEof": [0xFD06],
+        "Escape": [0xFF1B],
+        "Execute": [0xFF62],
+        "Exsel": [0xFD1D],
+        "ExSel": [0xFD1D],
+        "F1": [0xFFBE],
+        "F2": [0xFFBF],
+        "F3": [0xFFC0],
+        "F4": [0xFFC1],
+        "F5": [0xFFC2],
+        "F6": [0xFFC3],
+        "F7": [0xFFC4],
+        "F8": [0xFFC5],
+        "F9": [0xFFC6],
+        "F10": [0xFFC7],
+        "F11": [0xFFC8],
+        "F12": [0xFFC9],
+        "F13": [0xFFCA],
+        "F14": [0xFFCB],
+        "F15": [0xFFCC],
+        "F16": [0xFFCD],
+        "F17": [0xFFCE],
+        "F18": [0xFFCF],
+        "F19": [0xFFD0],
+        "F20": [0xFFD1],
+        "F21": [0xFFD2],
+        "F22": [0xFFD3],
+        "F23": [0xFFD4],
+        "F24": [0xFFD5],
+        "Find": [0xFF68],
+        "GroupFirst": [0xFE0C],
+        "GroupLast": [0xFE0E],
+        "GroupNext": [0xFE08],
+        "GroupPrevious": [0xFE0A],
+        "FullWidth": null,
+        "HalfWidth": null,
+        "HangulMode": [0xFF31],
+        "Hankaku": [0xFF29],
+        "HanjaMode": [0xFF34],
+        "Help": [0xFF6A],
+        "Hiragana": [0xFF25],
+        "HiraganaKatakana": [0xFF27],
+        "Home": [0xFF50],
+        "Hyper": [0xFFED, 0xFFED, 0xFFEE],
+        "Insert": [0xFF63],
+        "JapaneseHiragana": [0xFF25],
+        "JapaneseKatakana": [0xFF26],
+        "JapaneseRomaji": [0xFF24],
+        "JunjaMode": [0xFF38],
+        "KanaMode": [0xFF2D],
+        "KanjiMode": [0xFF21],
+        "Katakana": [0xFF26],
+        "Left": [0xFF51],
+        "Meta": [0xFFE7, 0xFFE7, 0xFFE8],
+        "ModeChange": [0xFF7E],
+        "NumLock": [0xFF7F],
+        "PageDown": [0xFF56],
+        "PageUp": [0xFF55],
+        "Pause": [0xFF13],
+        "Play": [0xFD16],
+        "PreviousCandidate": [0xFF3E],
+        "PrintScreen": [0xFD1D],
+        "Redo": [0xFF66],
+        "Right": [0xFF53],
+        "RomanCharacters": null,
+        "Scroll": [0xFF14],
+        "Select": [0xFF60],
+        "Separator": [0xFFAC],
+        "Shift": [0xFFE1, 0xFFE1, 0xFFE2],
+        "SingleCandidate": [0xFF3C],
+        "Super": [0xFFEB, 0xFFEB, 0xFFEC],
+        "Tab": [0xFF09],
+        "Up": [0xFF52],
+        "Undo": [0xFF65],
+        "Win": [0xFFEB],
+        "Zenkaku": [0xFF28],
+        "ZenkakuHankaku": [0xFF2A]
+    };
+
+    /**
+     * All keysyms which should not repeat when held down.
+     * @private
+     */
+    var no_repeat = {
+        0xFE03: true, // ISO Level 3 Shift (AltGr)
+        0xFFE1: true, // Left shift
+        0xFFE2: true, // Right shift
+        0xFFE3: true, // Left ctrl 
+        0xFFE4: true, // Right ctrl 
+        0xFFE7: true, // Left meta 
+        0xFFE8: true, // Right meta 
+        0xFFE9: true, // Left alt
+        0xFFEA: true, // Right alt
+        0xFFEB: true, // Left hyper
+        0xFFEC: true  // Right hyper
+    };
+
+    /**
+     * All modifiers and their states.
+     */
+    this.modifiers = new Guacamole.Keyboard.ModifierState();
+        
+    /**
+     * The state of every key, indexed by keysym. If a particular key is
+     * pressed, the value of pressed for that keysym will be true. If a key
+     * is not currently pressed, it will not be defined. 
+     */
+    this.pressed = {};
+
+    /**
+     * The last result of calling the onkeydown handler for each key, indexed
+     * by keysym. This is used to prevent/allow default actions for key events,
+     * even when the onkeydown handler cannot be called again because the key
+     * is (theoretically) still pressed.
+     *
+     * @private
+     */
+    var last_keydown_result = {};
+
+    /**
+     * The keysym most recently associated with a given keycode when keydown
+     * fired. This object maps keycodes to keysyms.
+     *
+     * @private
+     * @type {Object.<Number, Number>}
+     */
+    var recentKeysym = {};
+
+    /**
+     * Timeout before key repeat starts.
+     * @private
+     */
+    var key_repeat_timeout = null;
+
+    /**
+     * Interval which presses and releases the last key pressed while that
+     * key is still being held down.
+     * @private
+     */
+    var key_repeat_interval = null;
+
+    /**
+     * Given an array of keysyms indexed by location, returns the keysym
+     * for the given location, or the keysym for the standard location if
+     * undefined.
+     * 
+     * @private
+     * @param {Number[]} keysyms
+     *     An array of keysyms, where the index of the keysym in the array is
+     *     the location value.
+     *
+     * @param {Number} location
+     *     The location on the keyboard corresponding to the key pressed, as
+     *     defined at: http://www.w3.org/TR/DOM-Level-3-Events/#events-KeyboardEvent
+     */
+    var get_keysym = function get_keysym(keysyms, location) {
+
+        if (!keysyms)
+            return null;
+
+        return keysyms[location] || keysyms[0];
+    };
+
+    function keysym_from_key_identifier(identifier, location, shifted) {
+
+        if (!identifier)
+            return null;
+
+        var typedCharacter;
+
+        // If identifier is U+xxxx, decode Unicode character 
+        var unicodePrefixLocation = identifier.indexOf("U+");
+        if (unicodePrefixLocation >= 0) {
+            var hex = identifier.substring(unicodePrefixLocation+2);
+            typedCharacter = String.fromCharCode(parseInt(hex, 16));
+        }
+
+        // If single character and not keypad, use that as typed character
+        else if (identifier.length === 1 && location !== 3)
+            typedCharacter = identifier;
+
+        // Otherwise, look up corresponding keysym
+        else
+            return get_keysym(keyidentifier_keysym[identifier], location);
+
+        // Alter case if necessary
+        if (shifted === true)
+            typedCharacter = typedCharacter.toUpperCase();
+        else if (shifted === false)
+            typedCharacter = typedCharacter.toLowerCase();
+
+        // Get codepoint
+        var codepoint = typedCharacter.charCodeAt(0);
+        return keysym_from_charcode(codepoint);
+
+    }
+
+    function isControlCharacter(codepoint) {
+        return codepoint <= 0x1F || (codepoint >= 0x7F && codepoint <= 0x9F);
+    }
+
+    function keysym_from_charcode(codepoint) {
+
+        // Keysyms for control characters
+        if (isControlCharacter(codepoint)) return 0xFF00 | codepoint;
+
+        // Keysyms for ASCII chars
+        if (codepoint >= 0x0000 && codepoint <= 0x00FF)
+            return codepoint;
+
+        // Keysyms for Unicode
+        if (codepoint >= 0x0100 && codepoint <= 0x10FFFF)
+            return 0x01000000 | codepoint;
+
+        return null;
+
+    }
+
+    function keysym_from_keycode(keyCode, location) {
+        return get_keysym(keycodeKeysyms[keyCode], location);
+    }
+
+    /**
+     * Heuristically detects if the legacy keyIdentifier property of
+     * a keydown/keyup event looks incorrectly derived. Chrome, and
+     * presumably others, will produce the keyIdentifier by assuming
+     * the keyCode is the Unicode codepoint for that key. This is not
+     * correct in all cases.
+     *
+     * @private
+     * @param {Number} keyCode
+     *     The keyCode from a browser keydown/keyup event.
+     *
+     * @param {String} keyIdentifier
+     *     The legacy keyIdentifier from a browser keydown/keyup event.
+     *
+     * @returns {Boolean}
+     *     true if the keyIdentifier looks sane, false if the keyIdentifier
+     *     appears incorrectly derived or is missing entirely.
+     */
+    var key_identifier_sane = function key_identifier_sane(keyCode, keyIdentifier) {
+
+        // Missing identifier is not sane
+        if (!keyIdentifier)
+            return false;
+
+        // Assume non-Unicode keyIdentifier values are sane
+        var unicodePrefixLocation = keyIdentifier.indexOf("U+");
+        if (unicodePrefixLocation === -1)
+            return true;
+
+        // If the Unicode codepoint isn't identical to the keyCode,
+        // then the identifier is likely correct
+        var codepoint = parseInt(keyIdentifier.substring(unicodePrefixLocation+2), 16);
+        if (keyCode !== codepoint)
+            return true;
+
+        // The keyCodes for A-Z and 0-9 are actually identical to their
+        // Unicode codepoints
+        if ((keyCode >= 65 && keyCode <= 90) || (keyCode >= 48 && keyCode <= 57))
+            return true;
+
+        // The keyIdentifier does NOT appear sane
+        return false;
+
+    };
+
+    /**
+     * Marks a key as pressed, firing the keydown event if registered. Key
+     * repeat for the pressed key will start after a delay if that key is
+     * not a modifier. The return value of this function depends on the
+     * return value of the keydown event handler, if any.
+     * 
+     * @param {Number} keysym The keysym of the key to press.
+     * @return {Boolean} true if event should NOT be canceled, false otherwise.
+     */
+    this.press = function(keysym) {
+
+        // Don't bother with pressing the key if the key is unknown
+        if (keysym === null) return;
+
+        // Only press if released
+        if (!guac_keyboard.pressed[keysym]) {
+
+            // Mark key as pressed
+            guac_keyboard.pressed[keysym] = true;
+
+            // Send key event
+            if (guac_keyboard.onkeydown) {
+                var result = guac_keyboard.onkeydown(keysym);
+                last_keydown_result[keysym] = result;
+
+                // Stop any current repeat
+                window.clearTimeout(key_repeat_timeout);
+                window.clearInterval(key_repeat_interval);
+
+                // Repeat after a delay as long as pressed
+                if (!no_repeat[keysym])
+                    key_repeat_timeout = window.setTimeout(function() {
+                        key_repeat_interval = window.setInterval(function() {
+                            guac_keyboard.onkeyup(keysym);
+                            guac_keyboard.onkeydown(keysym);
+                        }, 50);
+                    }, 500);
+
+                return result;
+            }
+        }
+
+        // Return the last keydown result by default, resort to false if unknown
+        return last_keydown_result[keysym] || false;
+
+    };
+
+    /**
+     * Marks a key as released, firing the keyup event if registered.
+     * 
+     * @param {Number} keysym The keysym of the key to release.
+     */
+    this.release = function(keysym) {
+
+        // Only release if pressed
+        if (guac_keyboard.pressed[keysym]) {
+            
+            // Mark key as released
+            delete guac_keyboard.pressed[keysym];
+
+            // Stop repeat
+            window.clearTimeout(key_repeat_timeout);
+            window.clearInterval(key_repeat_interval);
+
+            // Send key event
+            if (keysym !== null && guac_keyboard.onkeyup)
+                guac_keyboard.onkeyup(keysym);
+
+        }
+
+    };
+
+    /**
+     * Resets the state of this keyboard, releasing all keys, and firing keyup
+     * events for each released key.
+     */
+    this.reset = function() {
+
+        // Release all pressed keys
+        for (var keysym in guac_keyboard.pressed)
+            guac_keyboard.release(parseInt(keysym));
+
+        // Clear event log
+        eventLog = [];
+
+    };
+
+    /**
+     * Given a keyboard event, updates the local modifier state and remote
+     * key state based on the modifier flags within the event. This function
+     * pays no attention to keycodes.
+     *
+     * @private
+     * @param {KeyboardEvent} e
+     *     The keyboard event containing the flags to update.
+     */
+    var update_modifier_state = function update_modifier_state(e) {
+
+        // Get state
+        var state = Guacamole.Keyboard.ModifierState.fromKeyboardEvent(e);
+
+        // Release alt if implicitly released
+        if (guac_keyboard.modifiers.alt && state.alt === false) {
+            guac_keyboard.release(0xFFE9); // Left alt
+            guac_keyboard.release(0xFFEA); // Right alt
+            guac_keyboard.release(0xFE03); // AltGr
+        }
+
+        // Release shift if implicitly released
+        if (guac_keyboard.modifiers.shift && state.shift === false) {
+            guac_keyboard.release(0xFFE1); // Left shift
+            guac_keyboard.release(0xFFE2); // Right shift
+        }
+
+        // Release ctrl if implicitly released
+        if (guac_keyboard.modifiers.ctrl && state.ctrl === false) {
+            guac_keyboard.release(0xFFE3); // Left ctrl 
+            guac_keyboard.release(0xFFE4); // Right ctrl 
+        }
+
+        // Release meta if implicitly released
+        if (guac_keyboard.modifiers.meta && state.meta === false) {
+            guac_keyboard.release(0xFFE7); // Left meta 
+            guac_keyboard.release(0xFFE8); // Right meta 
+        }
+
+        // Release hyper if implicitly released
+        if (guac_keyboard.modifiers.hyper && state.hyper === false) {
+            guac_keyboard.release(0xFFEB); // Left hyper
+            guac_keyboard.release(0xFFEC); // Right hyper
+        }
+
+        // Update state
+        guac_keyboard.modifiers = state;
+
+    };
+
+    /**
+     * Reads through the event log, removing events from the head of the log
+     * when the corresponding true key presses are known (or as known as they
+     * can be).
+     * 
+     * @private
+     * @return {Boolean} Whether the default action of the latest event should
+     *                   be prevented.
+     */
+    function interpret_events() {
+
+        // Do not prevent default if no event could be interpreted
+        var handled_event = interpret_event();
+        if (!handled_event)
+            return false;
+
+        // Interpret as much as possible
+        var last_event;
+        do {
+            last_event = handled_event;
+            handled_event = interpret_event();
+        } while (handled_event !== null);
+
+        return last_event.defaultPrevented;
+
+    }
+
+    /**
+     * Releases Ctrl+Alt, if both are currently pressed and the given keysym
+     * looks like a key that may require AltGr.
+     *
+     * @private
+     * @param {Number} keysym The key that was just pressed.
+     */
+    var release_simulated_altgr = function release_simulated_altgr(keysym) {
+
+        // Both Ctrl+Alt must be pressed if simulated AltGr is in use
+        if (!guac_keyboard.modifiers.ctrl || !guac_keyboard.modifiers.alt)
+            return;
+
+        // Assume [A-Z] never require AltGr
+        if (keysym >= 0x0041 && keysym <= 0x005A)
+            return;
+
+        // Assume [a-z] never require AltGr
+        if (keysym >= 0x0061 && keysym <= 0x007A)
+            return;
+
+        // Release Ctrl+Alt if the keysym is printable
+        if (keysym <= 0xFF || (keysym & 0xFF000000) === 0x01000000) {
+            guac_keyboard.release(0xFFE3); // Left ctrl 
+            guac_keyboard.release(0xFFE4); // Right ctrl 
+            guac_keyboard.release(0xFFE9); // Left alt
+            guac_keyboard.release(0xFFEA); // Right alt
+        }
+
+    };
+
+    /**
+     * Reads through the event log, interpreting the first event, if possible,
+     * and returning that event. If no events can be interpreted, due to a
+     * total lack of events or the need for more events, null is returned. Any
+     * interpreted events are automatically removed from the log.
+     * 
+     * @private
+     * @return {KeyEvent}
+     *     The first key event in the log, if it can be interpreted, or null
+     *     otherwise.
+     */
+    var interpret_event = function interpret_event() {
+
+        // Peek at first event in log
+        var first = eventLog[0];
+        if (!first)
+            return null;
+
+        // Keydown event
+        if (first instanceof KeydownEvent) {
+
+            var keysym = null;
+            var accepted_events = [];
+
+            // If event itself is reliable, no need to wait for other events
+            if (first.reliable) {
+                keysym = first.keysym;
+                accepted_events = eventLog.splice(0, 1);
+            }
+
+            // If keydown is immediately followed by a keypress, use the indicated character
+            else if (eventLog[1] instanceof KeypressEvent) {
+                keysym = eventLog[1].keysym;
+                accepted_events = eventLog.splice(0, 2);
+            }
+
+            // If keydown is immediately followed by anything else, then no
+            // keypress can possibly occur to clarify this event, and we must
+            // handle it now
+            else if (eventLog[1]) {
+                keysym = first.keysym;
+                accepted_events = eventLog.splice(0, 1);
+            }
+
+            // Fire a key press if valid events were found
+            if (accepted_events.length > 0) {
+
+                if (keysym) {
+
+                    // Fire event
+                    release_simulated_altgr(keysym);
+                    var defaultPrevented = !guac_keyboard.press(keysym);
+                    recentKeysym[first.keyCode] = keysym;
+
+                    // If a key is pressed while meta is held down, the keyup will
+                    // never be sent in Chrome, so send it now. (bug #108404)
+                    if (guac_keyboard.modifiers.meta && keysym !== 0xFFE7 && keysym !== 0xFFE8)
+                        guac_keyboard.release(keysym);
+
+                    // Record whether default was prevented
+                    for (var i=0; i<accepted_events.length; i++)
+                        accepted_events[i].defaultPrevented = defaultPrevented;
+
+                }
+
+                return first;
+
+            }
+
+        } // end if keydown
+
+        // Keyup event
+        else if (first instanceof KeyupEvent) {
+
+            // Release specific key if known
+            var keysym = first.keysym;
+            if (keysym) {
+                guac_keyboard.release(keysym);
+                first.defaultPrevented = true;
+            }
+
+            // Otherwise, fall back to releasing all keys
+            else {
+                guac_keyboard.reset();
+                return first;
+            }
+
+            return eventLog.shift();
+
+        } // end if keyup
+
+        // Ignore any other type of event (keypress by itself is invalid)
+        else
+            return eventLog.shift();
+
+        // No event interpreted
+        return null;
+
+    };
+
+    /**
+     * Returns the keyboard location of the key associated with the given
+     * keyboard event. The location differentiates key events which otherwise
+     * have the same keycode, such as left shift vs. right shift.
+     *
+     * @private
+     * @param {KeyboardEvent} e
+     *     A JavaScript keyboard event, as received through the DOM via a
+     *     "keydown", "keyup", or "keypress" handler.
+     *
+     * @returns {Number}
+     *     The location of the key event on the keyboard, as defined at:
+     *     http://www.w3.org/TR/DOM-Level-3-Events/#events-KeyboardEvent
+     */
+    var getEventLocation = function getEventLocation(e) {
+
+        // Use standard location, if possible
+        if ('location' in e)
+            return e.location;
+
+        // Failing that, attempt to use deprecated keyLocation
+        if ('keyLocation' in e)
+            return e.keyLocation;
+
+        // If no location is available, assume left side
+        return 0;
+
+    };
+
+    // When key pressed
+    element.addEventListener("keydown", function(e) {
+
+        // Only intercept if handler set
+        if (!guac_keyboard.onkeydown) return;
+
+        var keyCode;
+        if (window.event) keyCode = window.event.keyCode;
+        else if (e.which) keyCode = e.which;
+
+        // Fix modifier states
+        update_modifier_state(e);
+
+        // Ignore (but do not prevent) the "composition" keycode sent by some
+        // browsers when an IME is in use (see: http://lists.w3.org/Archives/Public/www-dom/2010JulSep/att-0182/keyCode-spec.html)
+        if (keyCode === 229)
+            return;
+
+        // Log event
+        var keydownEvent = new KeydownEvent(keyCode, e.keyIdentifier, e.key, getEventLocation(e));
+        eventLog.push(keydownEvent);
+
+        // Interpret as many events as possible, prevent default if indicated
+        if (interpret_events())
+            e.preventDefault();
+
+    }, true);
+
+    // When key pressed
+    element.addEventListener("keypress", function(e) {
+
+        // Only intercept if handler set
+        if (!guac_keyboard.onkeydown && !guac_keyboard.onkeyup) return;
+
+        var charCode;
+        if (window.event) charCode = window.event.keyCode;
+        else if (e.which) charCode = e.which;
+
+        // Fix modifier states
+        update_modifier_state(e);
+
+        // Log event
+        var keypressEvent = new KeypressEvent(charCode);
+        eventLog.push(keypressEvent);
+
+        // Interpret as many events as possible, prevent default if indicated
+        if (interpret_events())
+            e.preventDefault();
+
+    }, true);
+
+    // When key released
+    element.addEventListener("keyup", function(e) {
+
+        // Only intercept if handler set
+        if (!guac_keyboard.onkeyup) return;
+
+        e.preventDefault();
+
+        var keyCode;
+        if (window.event) keyCode = window.event.keyCode;
+        else if (e.which) keyCode = e.which;
+        
+        // Fix modifier states
+        update_modifier_state(e);
+
+        // Log event, call for interpretation
+        var keyupEvent = new KeyupEvent(keyCode, e.keyIdentifier, e.key, getEventLocation(e));
+        eventLog.push(keyupEvent);
+        interpret_events();
+
+    }, true);
+
+};
+
+/**
+ * The state of all supported keyboard modifiers.
+ * @constructor
+ */
+Guacamole.Keyboard.ModifierState = function() {
+    
+    /**
+     * Whether shift is currently pressed.
+     * @type {Boolean}
+     */
+    this.shift = false;
+    
+    /**
+     * Whether ctrl is currently pressed.
+     * @type {Boolean}
+     */
+    this.ctrl = false;
+    
+    /**
+     * Whether alt is currently pressed.
+     * @type {Boolean}
+     */
+    this.alt = false;
+    
+    /**
+     * Whether meta (apple key) is currently pressed.
+     * @type {Boolean}
+     */
+    this.meta = false;
+
+    /**
+     * Whether hyper (windows key) is currently pressed.
+     * @type {Boolean}
+     */
+    this.hyper = false;
+    
+};
+
+/**
+ * Returns the modifier state applicable to the keyboard event given.
+ * 
+ * @param {KeyboardEvent} e The keyboard event to read.
+ * @returns {Guacamole.Keyboard.ModifierState} The current state of keyboard
+ *                                             modifiers.
+ */
+Guacamole.Keyboard.ModifierState.fromKeyboardEvent = function(e) {
+    
+    var state = new Guacamole.Keyboard.ModifierState();
+
+    // Assign states from old flags
+    state.shift = e.shiftKey;
+    state.ctrl  = e.ctrlKey;
+    state.alt   = e.altKey;
+    state.meta  = e.metaKey;
+
+    // Use DOM3 getModifierState() for others
+    if (e.getModifierState) {
+        state.hyper = e.getModifierState("OS")
+                   || e.getModifierState("Super")
+                   || e.getModifierState("Hyper")
+                   || e.getModifierState("Win");
+    }
+
+    return state;
+    
+};
diff --git a/guacamole-common-js/src/main/webapp/modules/Layer.js b/guacamole-common-js/src/main/webapp/modules/Layer.js
new file mode 100644
index 0000000..d9cf7f2
--- /dev/null
+++ b/guacamole-common-js/src/main/webapp/modules/Layer.js
@@ -0,0 +1,904 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+var Guacamole = Guacamole || {};
+
+/**
+ * Abstract ordered drawing surface. Each Layer contains a canvas element and
+ * provides simple drawing instructions for drawing to that canvas element,
+ * however unlike the canvas element itself, drawing operations on a Layer are
+ * guaranteed to run in order, even if such an operation must wait for an image
+ * to load before completing.
+ * 
+ * @constructor
+ * 
+ * @param {Number} width The width of the Layer, in pixels. The canvas element
+ *                       backing this Layer will be given this width.
+ *                       
+ * @param {Number} height The height of the Layer, in pixels. The canvas element
+ *                        backing this Layer will be given this height.
+ */
+Guacamole.Layer = function(width, height) {
+
+    /**
+     * Reference to this Layer.
+     * @private
+     */
+    var layer = this;
+
+    /**
+     * The canvas element backing this Layer.
+     * @private
+     */
+    var canvas = document.createElement("canvas");
+
+    /**
+     * The 2D display context of the canvas element backing this Layer.
+     * @private
+     */
+    var context = canvas.getContext("2d");
+    context.save();
+
+    /**
+     * Whether a new path should be started with the next path drawing
+     * operations.
+     * @private
+     */
+    var pathClosed = true;
+
+    /**
+     * The number of states on the state stack.
+     * 
+     * Note that there will ALWAYS be one element on the stack, but that
+     * element is not exposed. It is only used to reset the layer to its
+     * initial state.
+     * 
+     * @private
+     */
+    var stackSize = 0;
+
+    /**
+     * Map of all Guacamole channel masks to HTML5 canvas composite operation
+     * names. Not all channel mask combinations are currently implemented.
+     * @private
+     */
+    var compositeOperation = {
+     /* 0x0 NOT IMPLEMENTED */
+        0x1: "destination-in",
+        0x2: "destination-out",
+     /* 0x3 NOT IMPLEMENTED */
+        0x4: "source-in",
+     /* 0x5 NOT IMPLEMENTED */
+        0x6: "source-atop",
+     /* 0x7 NOT IMPLEMENTED */
+        0x8: "source-out",
+        0x9: "destination-atop",
+        0xA: "xor",
+        0xB: "destination-over",
+        0xC: "copy",
+     /* 0xD NOT IMPLEMENTED */
+        0xE: "source-over",
+        0xF: "lighter"
+    };
+
+    /**
+     * Resizes the canvas element backing this Layer without testing the
+     * new size. This function should only be used internally.
+     * 
+     * @private
+     * @param {Number} newWidth The new width to assign to this Layer.
+     * @param {Number} newHeight The new height to assign to this Layer.
+     */
+    function resize(newWidth, newHeight) {
+
+        // Only preserve old data if width/height are both non-zero
+        var oldData = null;
+        if (layer.width !== 0 && layer.height !== 0) {
+
+            // Create canvas and context for holding old data
+            oldData = document.createElement("canvas");
+            oldData.width = layer.width;
+            oldData.height = layer.height;
+
+            var oldDataContext = oldData.getContext("2d");
+
+            // Copy image data from current
+            oldDataContext.drawImage(canvas,
+                    0, 0, layer.width, layer.height,
+                    0, 0, layer.width, layer.height);
+
+        }
+
+        // Preserve composite operation
+        var oldCompositeOperation = context.globalCompositeOperation;
+
+        // Resize canvas
+        canvas.width = newWidth;
+        canvas.height = newHeight;
+
+        // Redraw old data, if any
+        if (oldData)
+                context.drawImage(oldData, 
+                    0, 0, layer.width, layer.height,
+                    0, 0, layer.width, layer.height);
+
+        // Restore composite operation
+        context.globalCompositeOperation = oldCompositeOperation;
+
+        layer.width = newWidth;
+        layer.height = newHeight;
+
+        // Acknowledge reset of stack (happens on resize of canvas)
+        stackSize = 0;
+        context.save();
+
+    }
+
+    /**
+     * Given the X and Y coordinates of the upper-left corner of a rectangle
+     * and the rectangle's width and height, resize the backing canvas element
+     * as necessary to ensure that the rectangle fits within the canvas
+     * element's coordinate space. This function will only make the canvas
+     * larger. If the rectangle already fits within the canvas element's
+     * coordinate space, the canvas is left unchanged.
+     * 
+     * @private
+     * @param {Number} x The X coordinate of the upper-left corner of the
+     *                   rectangle to fit.
+     * @param {Number} y The Y coordinate of the upper-left corner of the
+     *                   rectangle to fit.
+     * @param {Number} w The width of the the rectangle to fit.
+     * @param {Number} h The height of the the rectangle to fit.
+     */
+    function fitRect(x, y, w, h) {
+        
+        // Calculate bounds
+        var opBoundX = w + x;
+        var opBoundY = h + y;
+        
+        // Determine max width
+        var resizeWidth;
+        if (opBoundX > layer.width)
+            resizeWidth = opBoundX;
+        else
+            resizeWidth = layer.width;
+
+        // Determine max height
+        var resizeHeight;
+        if (opBoundY > layer.height)
+            resizeHeight = opBoundY;
+        else
+            resizeHeight = layer.height;
+
+        // Resize if necessary
+        layer.resize(resizeWidth, resizeHeight);
+
+    }
+
+    /**
+     * Set to true if this Layer should resize itself to accomodate the
+     * dimensions of any drawing operation, and false (the default) otherwise.
+     * 
+     * Note that setting this property takes effect immediately, and thus may
+     * take effect on operations that were started in the past but have not
+     * yet completed. If you wish the setting of this flag to only modify
+     * future operations, you will need to make the setting of this flag an
+     * operation with sync().
+     * 
+     * @example
+     * // Set autosize to true for all future operations
+     * layer.sync(function() {
+     *     layer.autosize = true;
+     * });
+     * 
+     * @type {Boolean}
+     * @default false
+     */
+    this.autosize = false;
+
+    /**
+     * The current width of this layer.
+     * @type {Number}
+     */
+    this.width = width;
+
+    /**
+     * The current height of this layer.
+     * @type {Number}
+     */
+    this.height = height;
+
+    /**
+     * Returns the canvas element backing this Layer.
+     * @returns {Element} The canvas element backing this Layer.
+     */
+    this.getCanvas = function() {
+        return canvas;
+    };
+
+    /**
+     * Changes the size of this Layer to the given width and height. Resizing
+     * is only attempted if the new size provided is actually different from
+     * the current size.
+     * 
+     * @param {Number} newWidth The new width to assign to this Layer.
+     * @param {Number} newHeight The new height to assign to this Layer.
+     */
+    this.resize = function(newWidth, newHeight) {
+        if (newWidth !== layer.width || newHeight !== layer.height)
+            resize(newWidth, newHeight);
+    };
+
+    /**
+     * Draws the specified image at the given coordinates. The image specified
+     * must already be loaded.
+     * 
+     * @param {Number} x The destination X coordinate.
+     * @param {Number} y The destination Y coordinate.
+     * @param {Image} image The image to draw. Note that this is an Image
+     *                      object - not a URL.
+     */
+    this.drawImage = function(x, y, image) {
+        if (layer.autosize) fitRect(x, y, image.width, image.height);
+        context.drawImage(image, x, y);
+    };
+
+    /**
+     * Transfer a rectangle of image data from one Layer to this Layer using the
+     * specified transfer function.
+     * 
+     * @param {Guacamole.Layer} srcLayer The Layer to copy image data from.
+     * @param {Number} srcx The X coordinate of the upper-left corner of the
+     *                      rectangle within the source Layer's coordinate
+     *                      space to copy data from.
+     * @param {Number} srcy The Y coordinate of the upper-left corner of the
+     *                      rectangle within the source Layer's coordinate
+     *                      space to copy data from.
+     * @param {Number} srcw The width of the rectangle within the source Layer's
+     *                      coordinate space to copy data from.
+     * @param {Number} srch The height of the rectangle within the source
+     *                      Layer's coordinate space to copy data from.
+     * @param {Number} x The destination X coordinate.
+     * @param {Number} y The destination Y coordinate.
+     * @param {Function} transferFunction The transfer function to use to
+     *                                    transfer data from source to
+     *                                    destination.
+     */
+    this.transfer = function(srcLayer, srcx, srcy, srcw, srch, x, y, transferFunction) {
+
+        var srcCanvas = srcLayer.getCanvas();
+
+        // If entire rectangle outside source canvas, stop
+        if (srcx >= srcCanvas.width || srcy >= srcCanvas.height) return;
+
+        // Otherwise, clip rectangle to area
+        if (srcx + srcw > srcCanvas.width)
+            srcw = srcCanvas.width - srcx;
+
+        if (srcy + srch > srcCanvas.height)
+            srch = srcCanvas.height - srcy;
+
+        // Stop if nothing to draw.
+        if (srcw === 0 || srch === 0) return;
+
+        if (layer.autosize) fitRect(x, y, srcw, srch);
+
+        // Get image data from src and dst
+        var src = srcLayer.getCanvas().getContext("2d").getImageData(srcx, srcy, srcw, srch);
+        var dst = context.getImageData(x , y, srcw, srch);
+
+        // Apply transfer for each pixel
+        for (var i=0; i<srcw*srch*4; i+=4) {
+
+            // Get source pixel environment
+            var src_pixel = new Guacamole.Layer.Pixel(
+                src.data[i],
+                src.data[i+1],
+                src.data[i+2],
+                src.data[i+3]
+            );
+                
+            // Get destination pixel environment
+            var dst_pixel = new Guacamole.Layer.Pixel(
+                dst.data[i],
+                dst.data[i+1],
+                dst.data[i+2],
+                dst.data[i+3]
+            );
+
+            // Apply transfer function
+            transferFunction(src_pixel, dst_pixel);
+
+            // Save pixel data
+            dst.data[i  ] = dst_pixel.red;
+            dst.data[i+1] = dst_pixel.green;
+            dst.data[i+2] = dst_pixel.blue;
+            dst.data[i+3] = dst_pixel.alpha;
+
+        }
+
+        // Draw image data
+        context.putImageData(dst, x, y);
+
+    };
+
+    /**
+     * Put a rectangle of image data from one Layer to this Layer directly
+     * without performing any alpha blending. Simply copy the data.
+     * 
+     * @param {Guacamole.Layer} srcLayer The Layer to copy image data from.
+     * @param {Number} srcx The X coordinate of the upper-left corner of the
+     *                      rectangle within the source Layer's coordinate
+     *                      space to copy data from.
+     * @param {Number} srcy The Y coordinate of the upper-left corner of the
+     *                      rectangle within the source Layer's coordinate
+     *                      space to copy data from.
+     * @param {Number} srcw The width of the rectangle within the source Layer's
+     *                      coordinate space to copy data from.
+     * @param {Number} srch The height of the rectangle within the source
+     *                      Layer's coordinate space to copy data from.
+     * @param {Number} x The destination X coordinate.
+     * @param {Number} y The destination Y coordinate.
+     */
+    this.put = function(srcLayer, srcx, srcy, srcw, srch, x, y) {
+
+        var srcCanvas = srcLayer.getCanvas();
+
+        // If entire rectangle outside source canvas, stop
+        if (srcx >= srcCanvas.width || srcy >= srcCanvas.height) return;
+
+        // Otherwise, clip rectangle to area
+        if (srcx + srcw > srcCanvas.width)
+            srcw = srcCanvas.width - srcx;
+
+        if (srcy + srch > srcCanvas.height)
+            srch = srcCanvas.height - srcy;
+
+        // Stop if nothing to draw.
+        if (srcw === 0 || srch === 0) return;
+
+        if (layer.autosize) fitRect(x, y, srcw, srch);
+
+        // Get image data from src and dst
+        var src = srcLayer.getCanvas().getContext("2d").getImageData(srcx, srcy, srcw, srch);
+        context.putImageData(src, x, y);
+
+    };
+
+    /**
+     * Copy a rectangle of image data from one Layer to this Layer. This
+     * operation will copy exactly the image data that will be drawn once all
+     * operations of the source Layer that were pending at the time this
+     * function was called are complete. This operation will not alter the
+     * size of the source Layer even if its autosize property is set to true.
+     * 
+     * @param {Guacamole.Layer} srcLayer The Layer to copy image data from.
+     * @param {Number} srcx The X coordinate of the upper-left corner of the
+     *                      rectangle within the source Layer's coordinate
+     *                      space to copy data from.
+     * @param {Number} srcy The Y coordinate of the upper-left corner of the
+     *                      rectangle within the source Layer's coordinate
+     *                      space to copy data from.
+     * @param {Number} srcw The width of the rectangle within the source Layer's
+     *                      coordinate space to copy data from.
+     * @param {Number} srch The height of the rectangle within the source
+     *                      Layer's coordinate space to copy data from.
+     * @param {Number} x The destination X coordinate.
+     * @param {Number} y The destination Y coordinate.
+     */
+    this.copy = function(srcLayer, srcx, srcy, srcw, srch, x, y) {
+
+        var srcCanvas = srcLayer.getCanvas();
+
+        // If entire rectangle outside source canvas, stop
+        if (srcx >= srcCanvas.width || srcy >= srcCanvas.height) return;
+
+        // Otherwise, clip rectangle to area
+        if (srcx + srcw > srcCanvas.width)
+            srcw = srcCanvas.width - srcx;
+
+        if (srcy + srch > srcCanvas.height)
+            srch = srcCanvas.height - srcy;
+
+        // Stop if nothing to draw.
+        if (srcw === 0 || srch === 0) return;
+
+        if (layer.autosize) fitRect(x, y, srcw, srch);
+        context.drawImage(srcCanvas, srcx, srcy, srcw, srch, x, y, srcw, srch);
+
+    };
+
+    /**
+     * Starts a new path at the specified point.
+     * 
+     * @param {Number} x The X coordinate of the point to draw.
+     * @param {Number} y The Y coordinate of the point to draw.
+     */
+    this.moveTo = function(x, y) {
+        
+        // Start a new path if current path is closed
+        if (pathClosed) {
+            context.beginPath();
+            pathClosed = false;
+        }
+        
+        if (layer.autosize) fitRect(x, y, 0, 0);
+        context.moveTo(x, y);
+
+    };
+
+    /**
+     * Add the specified line to the current path.
+     * 
+     * @param {Number} x The X coordinate of the endpoint of the line to draw.
+     * @param {Number} y The Y coordinate of the endpoint of the line to draw.
+     */
+    this.lineTo = function(x, y) {
+        
+        // Start a new path if current path is closed
+        if (pathClosed) {
+            context.beginPath();
+            pathClosed = false;
+        }
+        
+        if (layer.autosize) fitRect(x, y, 0, 0);
+        context.lineTo(x, y);
+        
+    };
+
+    /**
+     * Add the specified arc to the current path.
+     * 
+     * @param {Number} x The X coordinate of the center of the circle which
+     *                   will contain the arc.
+     * @param {Number} y The Y coordinate of the center of the circle which
+     *                   will contain the arc.
+     * @param {Number} radius The radius of the circle.
+     * @param {Number} startAngle The starting angle of the arc, in radians.
+     * @param {Number} endAngle The ending angle of the arc, in radians.
+     * @param {Boolean} negative Whether the arc should be drawn in order of
+     *                           decreasing angle.
+     */
+    this.arc = function(x, y, radius, startAngle, endAngle, negative) {
+        
+        // Start a new path if current path is closed
+        if (pathClosed) {
+            context.beginPath();
+            pathClosed = false;
+        }
+        
+        if (layer.autosize) fitRect(x, y, 0, 0);
+        context.arc(x, y, radius, startAngle, endAngle, negative);
+        
+    };
+
+    /**
+     * Starts a new path at the specified point.
+     * 
+     * @param {Number} cp1x The X coordinate of the first control point.
+     * @param {Number} cp1y The Y coordinate of the first control point.
+     * @param {Number} cp2x The X coordinate of the second control point.
+     * @param {Number} cp2y The Y coordinate of the second control point.
+     * @param {Number} x The X coordinate of the endpoint of the curve.
+     * @param {Number} y The Y coordinate of the endpoint of the curve.
+     */
+    this.curveTo = function(cp1x, cp1y, cp2x, cp2y, x, y) {
+        
+        // Start a new path if current path is closed
+        if (pathClosed) {
+            context.beginPath();
+            pathClosed = false;
+        }
+        
+        if (layer.autosize) fitRect(x, y, 0, 0);
+        context.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y);
+        
+    };
+
+    /**
+     * Closes the current path by connecting the end point with the start
+     * point (if any) with a straight line.
+     */
+    this.close = function() {
+        context.closePath();
+        pathClosed = true;
+    };
+
+    /**
+     * Add the specified rectangle to the current path.
+     * 
+     * @param {Number} x The X coordinate of the upper-left corner of the
+     *                   rectangle to draw.
+     * @param {Number} y The Y coordinate of the upper-left corner of the
+     *                   rectangle to draw.
+     * @param {Number} w The width of the rectangle to draw.
+     * @param {Number} h The height of the rectangle to draw.
+     */
+    this.rect = function(x, y, w, h) {
+            
+        // Start a new path if current path is closed
+        if (pathClosed) {
+            context.beginPath();
+            pathClosed = false;
+        }
+        
+        if (layer.autosize) fitRect(x, y, w, h);
+        context.rect(x, y, w, h);
+        
+    };
+
+    /**
+     * Clip all future drawing operations by the current path. The current path
+     * is implicitly closed. The current path can continue to be reused
+     * for other operations (such as fillColor()) but a new path will be started
+     * once a path drawing operation (path() or rect()) is used.
+     */
+    this.clip = function() {
+
+        // Set new clipping region
+        context.clip();
+
+        // Path now implicitly closed
+        pathClosed = true;
+
+    };
+
+    /**
+     * Stroke the current path with the specified color. The current path
+     * is implicitly closed. The current path can continue to be reused
+     * for other operations (such as clip()) but a new path will be started
+     * once a path drawing operation (path() or rect()) is used.
+     * 
+     * @param {String} cap The line cap style. Can be "round", "square",
+     *                     or "butt".
+     * @param {String} join The line join style. Can be "round", "bevel",
+     *                      or "miter".
+     * @param {Number} thickness The line thickness in pixels.
+     * @param {Number} r The red component of the color to fill.
+     * @param {Number} g The green component of the color to fill.
+     * @param {Number} b The blue component of the color to fill.
+     * @param {Number} a The alpha component of the color to fill.
+     */
+    this.strokeColor = function(cap, join, thickness, r, g, b, a) {
+
+        // Stroke with color
+        context.lineCap = cap;
+        context.lineJoin = join;
+        context.lineWidth = thickness;
+        context.strokeStyle = "rgba(" + r + "," + g + "," + b + "," + a/255.0 + ")";
+        context.stroke();
+
+        // Path now implicitly closed
+        pathClosed = true;
+
+    };
+
+    /**
+     * Fills the current path with the specified color. The current path
+     * is implicitly closed. The current path can continue to be reused
+     * for other operations (such as clip()) but a new path will be started
+     * once a path drawing operation (path() or rect()) is used.
+     * 
+     * @param {Number} r The red component of the color to fill.
+     * @param {Number} g The green component of the color to fill.
+     * @param {Number} b The blue component of the color to fill.
+     * @param {Number} a The alpha component of the color to fill.
+     */
+    this.fillColor = function(r, g, b, a) {
+
+        // Fill with color
+        context.fillStyle = "rgba(" + r + "," + g + "," + b + "," + a/255.0 + ")";
+        context.fill();
+
+        // Path now implicitly closed
+        pathClosed = true;
+
+    };
+
+    /**
+     * Stroke the current path with the image within the specified layer. The
+     * image data will be tiled infinitely within the stroke. The current path
+     * is implicitly closed. The current path can continue to be reused
+     * for other operations (such as clip()) but a new path will be started
+     * once a path drawing operation (path() or rect()) is used.
+     * 
+     * @param {String} cap The line cap style. Can be "round", "square",
+     *                     or "butt".
+     * @param {String} join The line join style. Can be "round", "bevel",
+     *                      or "miter".
+     * @param {Number} thickness The line thickness in pixels.
+     * @param {Guacamole.Layer} srcLayer The layer to use as a repeating pattern
+     *                                   within the stroke.
+     */
+    this.strokeLayer = function(cap, join, thickness, srcLayer) {
+
+        // Stroke with image data
+        context.lineCap = cap;
+        context.lineJoin = join;
+        context.lineWidth = thickness;
+        context.strokeStyle = context.createPattern(
+            srcLayer.getCanvas(),
+            "repeat"
+        );
+        context.stroke();
+
+        // Path now implicitly closed
+        pathClosed = true;
+
+    };
+
+    /**
+     * Fills the current path with the image within the specified layer. The
+     * image data will be tiled infinitely within the stroke. The current path
+     * is implicitly closed. The current path can continue to be reused
+     * for other operations (such as clip()) but a new path will be started
+     * once a path drawing operation (path() or rect()) is used.
+     * 
+     * @param {Guacamole.Layer} srcLayer The layer to use as a repeating pattern
+     *                                   within the fill.
+     */
+    this.fillLayer = function(srcLayer) {
+
+        // Fill with image data 
+        context.fillStyle = context.createPattern(
+            srcLayer.getCanvas(),
+            "repeat"
+        );
+        context.fill();
+
+        // Path now implicitly closed
+        pathClosed = true;
+
+    };
+
+    /**
+     * Push current layer state onto stack.
+     */
+    this.push = function() {
+
+        // Save current state onto stack
+        context.save();
+        stackSize++;
+
+    };
+
+    /**
+     * Pop layer state off stack.
+     */
+    this.pop = function() {
+
+        // Restore current state from stack
+        if (stackSize > 0) {
+            context.restore();
+            stackSize--;
+        }
+
+    };
+
+    /**
+     * Reset the layer, clearing the stack, the current path, and any transform
+     * matrix.
+     */
+    this.reset = function() {
+
+        // Clear stack
+        while (stackSize > 0) {
+            context.restore();
+            stackSize--;
+        }
+
+        // Restore to initial state
+        context.restore();
+        context.save();
+
+        // Clear path
+        context.beginPath();
+        pathClosed = false;
+
+    };
+
+    /**
+     * Sets the given affine transform (defined with six values from the
+     * transform's matrix).
+     * 
+     * @param {Number} a The first value in the affine transform's matrix.
+     * @param {Number} b The second value in the affine transform's matrix.
+     * @param {Number} c The third value in the affine transform's matrix.
+     * @param {Number} d The fourth value in the affine transform's matrix.
+     * @param {Number} e The fifth value in the affine transform's matrix.
+     * @param {Number} f The sixth value in the affine transform's matrix.
+     */
+    this.setTransform = function(a, b, c, d, e, f) {
+        context.setTransform(
+            a, b, c,
+            d, e, f
+          /*0, 0, 1*/
+        );
+    };
+
+    /**
+     * Applies the given affine transform (defined with six values from the
+     * transform's matrix).
+     * 
+     * @param {Number} a The first value in the affine transform's matrix.
+     * @param {Number} b The second value in the affine transform's matrix.
+     * @param {Number} c The third value in the affine transform's matrix.
+     * @param {Number} d The fourth value in the affine transform's matrix.
+     * @param {Number} e The fifth value in the affine transform's matrix.
+     * @param {Number} f The sixth value in the affine transform's matrix.
+     */
+    this.transform = function(a, b, c, d, e, f) {
+        context.transform(
+            a, b, c,
+            d, e, f
+          /*0, 0, 1*/
+        );
+    };
+
+    /**
+     * Sets the channel mask for future operations on this Layer.
+     * 
+     * The channel mask is a Guacamole-specific compositing operation identifier
+     * with a single bit representing each of four channels (in order): source
+     * image where destination transparent, source where destination opaque,
+     * destination where source transparent, and destination where source
+     * opaque.
+     * 
+     * @param {Number} mask The channel mask for future operations on this
+     *                      Layer.
+     */
+    this.setChannelMask = function(mask) {
+        context.globalCompositeOperation = compositeOperation[mask];
+    };
+
+    /**
+     * Sets the miter limit for stroke operations using the miter join. This
+     * limit is the maximum ratio of the size of the miter join to the stroke
+     * width. If this ratio is exceeded, the miter will not be drawn for that
+     * joint of the path.
+     * 
+     * @param {Number} limit The miter limit for stroke operations using the
+     *                       miter join.
+     */
+    this.setMiterLimit = function(limit) {
+        context.miterLimit = limit;
+    };
+
+    // Initialize canvas dimensions
+    canvas.width = width;
+    canvas.height = height;
+
+    // Explicitly render canvas below other elements in the layer (such as
+    // child layers). Chrome and others may fail to render layers properly
+    // without this.
+    canvas.style.zIndex = -1;
+
+};
+
+/**
+ * Channel mask for the composite operation "rout".
+ */
+Guacamole.Layer.ROUT  = 0x2;
+
+/**
+ * Channel mask for the composite operation "atop".
+ */
+Guacamole.Layer.ATOP  = 0x6;
+
+/**
+ * Channel mask for the composite operation "xor".
+ */
+Guacamole.Layer.XOR   = 0xA;
+
+/**
+ * Channel mask for the composite operation "rover".
+ */
+Guacamole.Layer.ROVER = 0xB;
+
+/**
+ * Channel mask for the composite operation "over".
+ */
+Guacamole.Layer.OVER  = 0xE;
+
+/**
+ * Channel mask for the composite operation "plus".
+ */
+Guacamole.Layer.PLUS  = 0xF;
+
+/**
+ * Channel mask for the composite operation "rin".
+ * Beware that WebKit-based browsers may leave the contents of the destionation
+ * layer where the source layer is transparent, despite the definition of this
+ * operation.
+ */
+Guacamole.Layer.RIN   = 0x1;
+
+/**
+ * Channel mask for the composite operation "in".
+ * Beware that WebKit-based browsers may leave the contents of the destionation
+ * layer where the source layer is transparent, despite the definition of this
+ * operation.
+ */
+Guacamole.Layer.IN    = 0x4;
+
+/**
+ * Channel mask for the composite operation "out".
+ * Beware that WebKit-based browsers may leave the contents of the destionation
+ * layer where the source layer is transparent, despite the definition of this
+ * operation.
+ */
+Guacamole.Layer.OUT   = 0x8;
+
+/**
+ * Channel mask for the composite operation "ratop".
+ * Beware that WebKit-based browsers may leave the contents of the destionation
+ * layer where the source layer is transparent, despite the definition of this
+ * operation.
+ */
+Guacamole.Layer.RATOP = 0x9;
+
+/**
+ * Channel mask for the composite operation "src".
+ * Beware that WebKit-based browsers may leave the contents of the destionation
+ * layer where the source layer is transparent, despite the definition of this
+ * operation.
+ */
+Guacamole.Layer.SRC   = 0xC;
+
+/**
+ * Represents a single pixel of image data. All components have a minimum value
+ * of 0 and a maximum value of 255.
+ * 
+ * @constructor
+ * 
+ * @param {Number} r The red component of this pixel.
+ * @param {Number} g The green component of this pixel.
+ * @param {Number} b The blue component of this pixel.
+ * @param {Number} a The alpha component of this pixel.
+ */
+Guacamole.Layer.Pixel = function(r, g, b, a) {
+
+    /**
+     * The red component of this pixel, where 0 is the minimum value,
+     * and 255 is the maximum.
+     */
+    this.red   = r;
+
+    /**
+     * The green component of this pixel, where 0 is the minimum value,
+     * and 255 is the maximum.
+     */
+    this.green = g;
+
+    /**
+     * The blue component of this pixel, where 0 is the minimum value,
+     * and 255 is the maximum.
+     */
+    this.blue  = b;
+
+    /**
+     * The alpha component of this pixel, where 0 is the minimum value,
+     * and 255 is the maximum.
+     */
+    this.alpha = a;
+
+};
diff --git a/guacamole-common-js/src/main/webapp/modules/Mouse.js b/guacamole-common-js/src/main/webapp/modules/Mouse.js
new file mode 100644
index 0000000..cdd1ae9
--- /dev/null
+++ b/guacamole-common-js/src/main/webapp/modules/Mouse.js
@@ -0,0 +1,1090 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+var Guacamole = Guacamole || {};
+
+/**
+ * Provides cross-browser mouse events for a given element. The events of
+ * the given element are automatically populated with handlers that translate
+ * mouse events into a non-browser-specific event provided by the
+ * Guacamole.Mouse instance.
+ * 
+ * @constructor
+ * @param {Element} element The Element to use to provide mouse events.
+ */
+Guacamole.Mouse = function(element) {
+
+    /**
+     * Reference to this Guacamole.Mouse.
+     * @private
+     */
+    var guac_mouse = this;
+
+    /**
+     * The number of mousemove events to require before re-enabling mouse
+     * event handling after receiving a touch event.
+     */
+    this.touchMouseThreshold = 3;
+
+    /**
+     * The minimum amount of pixels scrolled required for a single scroll button
+     * click.
+     */
+    this.scrollThreshold = 53;
+
+    /**
+     * The number of pixels to scroll per line.
+     */
+    this.PIXELS_PER_LINE = 18;
+
+    /**
+     * The number of pixels to scroll per page.
+     */
+    this.PIXELS_PER_PAGE = this.PIXELS_PER_LINE * 16;
+
+    /**
+     * The current mouse state. The properties of this state are updated when
+     * mouse events fire. This state object is also passed in as a parameter to
+     * the handler of any mouse events.
+     * 
+     * @type {Guacamole.Mouse.State}
+     */
+    this.currentState = new Guacamole.Mouse.State(
+        0, 0, 
+        false, false, false, false, false
+    );
+
+    /**
+     * Fired whenever the user presses a mouse button down over the element
+     * associated with this Guacamole.Mouse.
+     * 
+     * @event
+     * @param {Guacamole.Mouse.State} state The current mouse state.
+     */
+	this.onmousedown = null;
+
+    /**
+     * Fired whenever the user releases a mouse button down over the element
+     * associated with this Guacamole.Mouse.
+     * 
+     * @event
+     * @param {Guacamole.Mouse.State} state The current mouse state.
+     */
+	this.onmouseup = null;
+
+    /**
+     * Fired whenever the user moves the mouse over the element associated with
+     * this Guacamole.Mouse.
+     * 
+     * @event
+     * @param {Guacamole.Mouse.State} state The current mouse state.
+     */
+	this.onmousemove = null;
+
+    /**
+     * Fired whenever the mouse leaves the boundaries of the element associated
+     * with this Guacamole.Mouse.
+     * 
+     * @event
+     */
+	this.onmouseout = null;
+
+    /**
+     * Counter of mouse events to ignore. This decremented by mousemove, and
+     * while non-zero, mouse events will have no effect.
+     * @private
+     */
+    var ignore_mouse = 0;
+
+    /**
+     * Cumulative scroll delta amount. This value is accumulated through scroll
+     * events and results in scroll button clicks if it exceeds a certain
+     * threshold.
+     *
+     * @private
+     */
+    var scroll_delta = 0;
+
+    function cancelEvent(e) {
+        e.stopPropagation();
+        if (e.preventDefault) e.preventDefault();
+        e.returnValue = false;
+    }
+
+    // Block context menu so right-click gets sent properly
+    element.addEventListener("contextmenu", function(e) {
+        cancelEvent(e);
+    }, false);
+
+    element.addEventListener("mousemove", function(e) {
+
+        cancelEvent(e);
+
+        // If ignoring events, decrement counter
+        if (ignore_mouse) {
+            ignore_mouse--;
+            return;
+        }
+
+        guac_mouse.currentState.fromClientPosition(element, e.clientX, e.clientY);
+
+        if (guac_mouse.onmousemove)
+            guac_mouse.onmousemove(guac_mouse.currentState);
+
+    }, false);
+
+    element.addEventListener("mousedown", function(e) {
+
+        cancelEvent(e);
+
+        // Do not handle if ignoring events
+        if (ignore_mouse)
+            return;
+
+        switch (e.button) {
+            case 0:
+                guac_mouse.currentState.left = true;
+                break;
+            case 1:
+                guac_mouse.currentState.middle = true;
+                break;
+            case 2:
+                guac_mouse.currentState.right = true;
+                break;
+        }
+
+        if (guac_mouse.onmousedown)
+            guac_mouse.onmousedown(guac_mouse.currentState);
+
+    }, false);
+
+    element.addEventListener("mouseup", function(e) {
+
+        cancelEvent(e);
+
+        // Do not handle if ignoring events
+        if (ignore_mouse)
+            return;
+
+        switch (e.button) {
+            case 0:
+                guac_mouse.currentState.left = false;
+                break;
+            case 1:
+                guac_mouse.currentState.middle = false;
+                break;
+            case 2:
+                guac_mouse.currentState.right = false;
+                break;
+        }
+
+        if (guac_mouse.onmouseup)
+            guac_mouse.onmouseup(guac_mouse.currentState);
+
+    }, false);
+
+    element.addEventListener("mouseout", function(e) {
+
+        // Get parent of the element the mouse pointer is leaving
+       	if (!e) e = window.event;
+
+        // Check that mouseout is due to actually LEAVING the element
+        var target = e.relatedTarget || e.toElement;
+        while (target) {
+            if (target === element)
+                return;
+            target = target.parentNode;
+        }
+
+        cancelEvent(e);
+
+        // Release all buttons
+        if (guac_mouse.currentState.left
+            || guac_mouse.currentState.middle
+            || guac_mouse.currentState.right) {
+
+            guac_mouse.currentState.left = false;
+            guac_mouse.currentState.middle = false;
+            guac_mouse.currentState.right = false;
+
+            if (guac_mouse.onmouseup)
+                guac_mouse.onmouseup(guac_mouse.currentState);
+        }
+
+        // Fire onmouseout event
+        if (guac_mouse.onmouseout)
+            guac_mouse.onmouseout();
+
+    }, false);
+
+    // Override selection on mouse event element.
+    element.addEventListener("selectstart", function(e) {
+        cancelEvent(e);
+    }, false);
+
+    // Ignore all pending mouse events when touch events are the apparent source
+    function ignorePendingMouseEvents() { ignore_mouse = guac_mouse.touchMouseThreshold; }
+
+    element.addEventListener("touchmove",  ignorePendingMouseEvents, false);
+    element.addEventListener("touchstart", ignorePendingMouseEvents, false);
+    element.addEventListener("touchend",   ignorePendingMouseEvents, false);
+
+    // Scroll wheel support
+    function mousewheel_handler(e) {
+
+        // Determine approximate scroll amount (in pixels)
+        var delta = e.deltaY || -e.wheelDeltaY || -e.wheelDelta;
+
+        // If successfully retrieved scroll amount, convert to pixels if not
+        // already in pixels
+        if (delta) {
+
+            // Convert to pixels if delta was lines
+            if (e.deltaMode === 1)
+                delta = e.deltaY * guac_mouse.PIXELS_PER_LINE;
+
+            // Convert to pixels if delta was pages
+            else if (e.deltaMode === 2)
+                delta = e.deltaY * guac_mouse.PIXELS_PER_PAGE;
+
+        }
+
+        // Otherwise, assume legacy mousewheel event and line scrolling
+        else
+            delta = e.detail * guac_mouse.PIXELS_PER_LINE;
+        
+        // Update overall delta
+        scroll_delta += delta;
+
+        // Up
+        if (scroll_delta <= -guac_mouse.scrollThreshold) {
+
+            // Repeatedly click the up button until insufficient delta remains
+            do {
+
+                if (guac_mouse.onmousedown) {
+                    guac_mouse.currentState.up = true;
+                    guac_mouse.onmousedown(guac_mouse.currentState);
+                }
+
+                if (guac_mouse.onmouseup) {
+                    guac_mouse.currentState.up = false;
+                    guac_mouse.onmouseup(guac_mouse.currentState);
+                }
+
+                scroll_delta += guac_mouse.scrollThreshold;
+
+            } while (scroll_delta <= -guac_mouse.scrollThreshold);
+
+            // Reset delta
+            scroll_delta = 0;
+
+        }
+
+        // Down
+        if (scroll_delta >= guac_mouse.scrollThreshold) {
+
+            // Repeatedly click the down button until insufficient delta remains
+            do {
+
+                if (guac_mouse.onmousedown) {
+                    guac_mouse.currentState.down = true;
+                    guac_mouse.onmousedown(guac_mouse.currentState);
+                }
+
+                if (guac_mouse.onmouseup) {
+                    guac_mouse.currentState.down = false;
+                    guac_mouse.onmouseup(guac_mouse.currentState);
+                }
+
+                scroll_delta -= guac_mouse.scrollThreshold;
+
+            } while (scroll_delta >= guac_mouse.scrollThreshold);
+
+            // Reset delta
+            scroll_delta = 0;
+
+        }
+
+        cancelEvent(e);
+
+    }
+
+    element.addEventListener('DOMMouseScroll', mousewheel_handler, false);
+    element.addEventListener('mousewheel',     mousewheel_handler, false);
+    element.addEventListener('wheel',          mousewheel_handler, false);
+
+    /**
+     * Whether the browser supports CSS3 cursor styling, including hotspot
+     * coordinates.
+     *
+     * @private
+     * @type {Boolean}
+     */
+    var CSS3_CURSOR_SUPPORTED = (function() {
+
+        var div = document.createElement("div");
+
+        // If no cursor property at all, then no support
+        if (!("cursor" in div.style))
+            return false;
+
+        try {
+            // Apply simple 1x1 PNG
+            div.style.cursor = "url(data:image/png;base64,"
+                             + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"
+                             + "AQMAAAAl21bKAAAAA1BMVEX///+nxBvI"
+                             + "AAAACklEQVQI12NgAAAAAgAB4iG8MwAA"
+                             + "AABJRU5ErkJggg==) 0 0, auto";
+        }
+        catch (e) {
+            return false;
+        }
+
+        // Verify cursor property is set to URL with hotspot
+        return /\burl\([^()]*\)\s+0\s+0\b/.test(div.style.cursor || "");
+
+    })();
+
+    /**
+     * Changes the local mouse cursor to the given canvas, having the given
+     * hotspot coordinates. This affects styling of the element backing this
+     * Guacamole.Mouse only, and may fail depending on browser support for
+     * setting the mouse cursor.
+     * 
+     * If setting the local cursor is desired, it is up to the implementation
+     * to do something else, such as use the software cursor built into
+     * Guacamole.Display, if the local cursor cannot be set.
+     *
+     * @param {HTMLCanvasElement} canvas The cursor image.
+     * @param {Number} x The X-coordinate of the cursor hotspot.
+     * @param {Number} y The Y-coordinate of the cursor hotspot.
+     * @return {Boolean} true if the cursor was successfully set, false if the
+     *                   cursor could not be set for any reason.
+     */
+    this.setCursor = function(canvas, x, y) {
+
+        // Attempt to set via CSS3 cursor styling
+        if (CSS3_CURSOR_SUPPORTED) {
+            var dataURL = canvas.toDataURL('image/png');
+            element.style.cursor = "url(" + dataURL + ") " + x + " " + y + ", auto";
+            return true;
+        }
+
+        // Otherwise, setting cursor failed
+        return false;
+
+    };
+
+};
+
+/**
+ * Simple container for properties describing the state of a mouse.
+ * 
+ * @constructor
+ * @param {Number} x The X position of the mouse pointer in pixels.
+ * @param {Number} y The Y position of the mouse pointer in pixels.
+ * @param {Boolean} left Whether the left mouse button is pressed. 
+ * @param {Boolean} middle Whether the middle mouse button is pressed. 
+ * @param {Boolean} right Whether the right mouse button is pressed. 
+ * @param {Boolean} up Whether the up mouse button is pressed (the fourth
+ *                     button, usually part of a scroll wheel). 
+ * @param {Boolean} down Whether the down mouse button is pressed (the fifth
+ *                       button, usually part of a scroll wheel). 
+ */
+Guacamole.Mouse.State = function(x, y, left, middle, right, up, down) {
+
+    /**
+     * Reference to this Guacamole.Mouse.State.
+     * @private
+     */
+    var guac_state = this;
+
+    /**
+     * The current X position of the mouse pointer.
+     * @type {Number}
+     */
+    this.x = x;
+
+    /**
+     * The current Y position of the mouse pointer.
+     * @type {Number}
+     */
+    this.y = y;
+
+    /**
+     * Whether the left mouse button is currently pressed.
+     * @type {Boolean}
+     */
+    this.left = left;
+
+    /**
+     * Whether the middle mouse button is currently pressed.
+     * @type {Boolean}
+     */
+    this.middle = middle;
+
+    /**
+     * Whether the right mouse button is currently pressed.
+     * @type {Boolean}
+     */
+    this.right = right;
+
+    /**
+     * Whether the up mouse button is currently pressed. This is the fourth
+     * mouse button, associated with upward scrolling of the mouse scroll
+     * wheel.
+     * @type {Boolean}
+     */
+    this.up = up;
+
+    /**
+     * Whether the down mouse button is currently pressed. This is the fifth 
+     * mouse button, associated with downward scrolling of the mouse scroll
+     * wheel.
+     * @type {Boolean}
+     */
+    this.down = down;
+
+    /**
+     * Updates the position represented within this state object by the given
+     * element and clientX/clientY coordinates (commonly available within event
+     * objects). Position is translated from clientX/clientY (relative to
+     * viewport) to element-relative coordinates.
+     * 
+     * @param {Element} element The element the coordinates should be relative
+     *                          to.
+     * @param {Number} clientX The X coordinate to translate, viewport-relative.
+     * @param {Number} clientY The Y coordinate to translate, viewport-relative.
+     */
+    this.fromClientPosition = function(element, clientX, clientY) {
+    
+        guac_state.x = clientX - element.offsetLeft;
+        guac_state.y = clientY - element.offsetTop;
+
+        // This is all JUST so we can get the mouse position within the element
+        var parent = element.offsetParent;
+        while (parent && !(parent === document.body)) {
+            guac_state.x -= parent.offsetLeft - parent.scrollLeft;
+            guac_state.y -= parent.offsetTop  - parent.scrollTop;
+
+            parent = parent.offsetParent;
+        }
+
+        // Element ultimately depends on positioning within document body,
+        // take document scroll into account. 
+        if (parent) {
+            var documentScrollLeft = document.body.scrollLeft || document.documentElement.scrollLeft;
+            var documentScrollTop = document.body.scrollTop || document.documentElement.scrollTop;
+
+            guac_state.x -= parent.offsetLeft - documentScrollLeft;
+            guac_state.y -= parent.offsetTop  - documentScrollTop;
+        }
+
+    };
+
+};
+
+/**
+ * Provides cross-browser relative touch event translation for a given element.
+ * 
+ * Touch events are translated into mouse events as if the touches occurred
+ * on a touchpad (drag to push the mouse pointer, tap to click).
+ * 
+ * @constructor
+ * @param {Element} element The Element to use to provide touch events.
+ */
+Guacamole.Mouse.Touchpad = function(element) {
+
+    /**
+     * Reference to this Guacamole.Mouse.Touchpad.
+     * @private
+     */
+    var guac_touchpad = this;
+
+    /**
+     * The distance a two-finger touch must move per scrollwheel event, in
+     * pixels.
+     */
+    this.scrollThreshold = 20 * (window.devicePixelRatio || 1);
+
+    /**
+     * The maximum number of milliseconds to wait for a touch to end for the
+     * gesture to be considered a click.
+     */
+    this.clickTimingThreshold = 250;
+
+    /**
+     * The maximum number of pixels to allow a touch to move for the gesture to
+     * be considered a click.
+     */
+    this.clickMoveThreshold = 10 * (window.devicePixelRatio || 1);
+
+    /**
+     * The current mouse state. The properties of this state are updated when
+     * mouse events fire. This state object is also passed in as a parameter to
+     * the handler of any mouse events.
+     * 
+     * @type {Guacamole.Mouse.State}
+     */
+    this.currentState = new Guacamole.Mouse.State(
+        0, 0, 
+        false, false, false, false, false
+    );
+
+    /**
+     * Fired whenever a mouse button is effectively pressed. This can happen
+     * as part of a "click" gesture initiated by the user by tapping one
+     * or more fingers over the touchpad element, as part of a "scroll"
+     * gesture initiated by dragging two fingers up or down, etc.
+     * 
+     * @event
+     * @param {Guacamole.Mouse.State} state The current mouse state.
+     */
+	this.onmousedown = null;
+
+    /**
+     * Fired whenever a mouse button is effectively released. This can happen
+     * as part of a "click" gesture initiated by the user by tapping one
+     * or more fingers over the touchpad element, as part of a "scroll"
+     * gesture initiated by dragging two fingers up or down, etc.
+     * 
+     * @event
+     * @param {Guacamole.Mouse.State} state The current mouse state.
+     */
+	this.onmouseup = null;
+
+    /**
+     * Fired whenever the user moves the mouse by dragging their finger over
+     * the touchpad element.
+     * 
+     * @event
+     * @param {Guacamole.Mouse.State} state The current mouse state.
+     */
+	this.onmousemove = null;
+
+    var touch_count = 0;
+    var last_touch_x = 0;
+    var last_touch_y = 0;
+    var last_touch_time = 0;
+    var pixels_moved = 0;
+
+    var touch_buttons = {
+        1: "left",
+        2: "right",
+        3: "middle"
+    };
+
+    var gesture_in_progress = false;
+    var click_release_timeout = null;
+
+    element.addEventListener("touchend", function(e) {
+        
+        e.preventDefault();
+            
+        // If we're handling a gesture AND this is the last touch
+        if (gesture_in_progress && e.touches.length === 0) {
+            
+            var time = new Date().getTime();
+
+            // Get corresponding mouse button
+            var button = touch_buttons[touch_count];
+
+            // If mouse already down, release anad clear timeout
+            if (guac_touchpad.currentState[button]) {
+
+                // Fire button up event
+                guac_touchpad.currentState[button] = false;
+                if (guac_touchpad.onmouseup)
+                    guac_touchpad.onmouseup(guac_touchpad.currentState);
+
+                // Clear timeout, if set
+                if (click_release_timeout) {
+                    window.clearTimeout(click_release_timeout);
+                    click_release_timeout = null;
+                }
+
+            }
+
+            // If single tap detected (based on time and distance)
+            if (time - last_touch_time <= guac_touchpad.clickTimingThreshold
+                    && pixels_moved < guac_touchpad.clickMoveThreshold) {
+
+                // Fire button down event
+                guac_touchpad.currentState[button] = true;
+                if (guac_touchpad.onmousedown)
+                    guac_touchpad.onmousedown(guac_touchpad.currentState);
+
+                // Delay mouse up - mouse up should be canceled if
+                // touchstart within timeout.
+                click_release_timeout = window.setTimeout(function() {
+                    
+                    // Fire button up event
+                    guac_touchpad.currentState[button] = false;
+                    if (guac_touchpad.onmouseup)
+                        guac_touchpad.onmouseup(guac_touchpad.currentState);
+                    
+                    // Gesture now over
+                    gesture_in_progress = false;
+
+                }, guac_touchpad.clickTimingThreshold);
+
+            }
+
+            // If we're not waiting to see if this is a click, stop gesture
+            if (!click_release_timeout)
+                gesture_in_progress = false;
+
+        }
+
+    }, false);
+
+    element.addEventListener("touchstart", function(e) {
+
+        e.preventDefault();
+
+        // Track number of touches, but no more than three
+        touch_count = Math.min(e.touches.length, 3);
+
+        // Clear timeout, if set
+        if (click_release_timeout) {
+            window.clearTimeout(click_release_timeout);
+            click_release_timeout = null;
+        }
+
+        // Record initial touch location and time for touch movement
+        // and tap gestures
+        if (!gesture_in_progress) {
+
+            // Stop mouse events while touching
+            gesture_in_progress = true;
+
+            // Record touch location and time
+            var starting_touch = e.touches[0];
+            last_touch_x = starting_touch.clientX;
+            last_touch_y = starting_touch.clientY;
+            last_touch_time = new Date().getTime();
+            pixels_moved = 0;
+
+        }
+
+    }, false);
+
+    element.addEventListener("touchmove", function(e) {
+
+        e.preventDefault();
+
+        // Get change in touch location
+        var touch = e.touches[0];
+        var delta_x = touch.clientX - last_touch_x;
+        var delta_y = touch.clientY - last_touch_y;
+
+        // Track pixels moved
+        pixels_moved += Math.abs(delta_x) + Math.abs(delta_y);
+
+        // If only one touch involved, this is mouse move
+        if (touch_count === 1) {
+
+            // Calculate average velocity in Manhatten pixels per millisecond
+            var velocity = pixels_moved / (new Date().getTime() - last_touch_time);
+
+            // Scale mouse movement relative to velocity
+            var scale = 1 + velocity;
+
+            // Update mouse location
+            guac_touchpad.currentState.x += delta_x*scale;
+            guac_touchpad.currentState.y += delta_y*scale;
+
+            // Prevent mouse from leaving screen
+
+            if (guac_touchpad.currentState.x < 0)
+                guac_touchpad.currentState.x = 0;
+            else if (guac_touchpad.currentState.x >= element.offsetWidth)
+                guac_touchpad.currentState.x = element.offsetWidth - 1;
+
+            if (guac_touchpad.currentState.y < 0)
+                guac_touchpad.currentState.y = 0;
+            else if (guac_touchpad.currentState.y >= element.offsetHeight)
+                guac_touchpad.currentState.y = element.offsetHeight - 1;
+
+            // Fire movement event, if defined
+            if (guac_touchpad.onmousemove)
+                guac_touchpad.onmousemove(guac_touchpad.currentState);
+
+            // Update touch location
+            last_touch_x = touch.clientX;
+            last_touch_y = touch.clientY;
+
+        }
+
+        // Interpret two-finger swipe as scrollwheel
+        else if (touch_count === 2) {
+
+            // If change in location passes threshold for scroll
+            if (Math.abs(delta_y) >= guac_touchpad.scrollThreshold) {
+
+                // Decide button based on Y movement direction
+                var button;
+                if (delta_y > 0) button = "down";
+                else             button = "up";
+
+                // Fire button down event
+                guac_touchpad.currentState[button] = true;
+                if (guac_touchpad.onmousedown)
+                    guac_touchpad.onmousedown(guac_touchpad.currentState);
+
+                // Fire button up event
+                guac_touchpad.currentState[button] = false;
+                if (guac_touchpad.onmouseup)
+                    guac_touchpad.onmouseup(guac_touchpad.currentState);
+
+                // Only update touch location after a scroll has been
+                // detected
+                last_touch_x = touch.clientX;
+                last_touch_y = touch.clientY;
+
+            }
+
+        }
+
+    }, false);
+
+};
+
+/**
+ * Provides cross-browser absolute touch event translation for a given element.
+ *
+ * Touch events are translated into mouse events as if the touches occurred
+ * on a touchscreen (tapping anywhere on the screen clicks at that point,
+ * long-press to right-click).
+ *
+ * @constructor
+ * @param {Element} element The Element to use to provide touch events.
+ */
+Guacamole.Mouse.Touchscreen = function(element) {
+
+    /**
+     * Reference to this Guacamole.Mouse.Touchscreen.
+     * @private
+     */
+    var guac_touchscreen = this;
+
+    /**
+     * Whether a gesture is known to be in progress. If false, touch events
+     * will be ignored.
+     *
+     * @private
+     */
+    var gesture_in_progress = false;
+
+    /**
+     * The start X location of a gesture.
+     * @private
+     */
+    var gesture_start_x = null;
+
+    /**
+     * The start Y location of a gesture.
+     * @private
+     */
+    var gesture_start_y = null;
+
+    /**
+     * The timeout associated with the delayed, cancellable click release.
+     *
+     * @private
+     */
+    var click_release_timeout = null;
+
+    /**
+     * The timeout associated with long-press for right click.
+     *
+     * @private
+     */
+    var long_press_timeout = null;
+
+    /**
+     * The distance a two-finger touch must move per scrollwheel event, in
+     * pixels.
+     */
+    this.scrollThreshold = 20 * (window.devicePixelRatio || 1);
+
+    /**
+     * The maximum number of milliseconds to wait for a touch to end for the
+     * gesture to be considered a click.
+     */
+    this.clickTimingThreshold = 250;
+
+    /**
+     * The maximum number of pixels to allow a touch to move for the gesture to
+     * be considered a click.
+     */
+    this.clickMoveThreshold = 16 * (window.devicePixelRatio || 1);
+
+    /**
+     * The amount of time a press must be held for long press to be
+     * detected.
+     */
+    this.longPressThreshold = 500;
+
+    /**
+     * The current mouse state. The properties of this state are updated when
+     * mouse events fire. This state object is also passed in as a parameter to
+     * the handler of any mouse events.
+     *
+     * @type {Guacamole.Mouse.State}
+     */
+    this.currentState = new Guacamole.Mouse.State(
+        0, 0,
+        false, false, false, false, false
+    );
+
+    /**
+     * Fired whenever a mouse button is effectively pressed. This can happen
+     * as part of a "mousedown" gesture initiated by the user by pressing one
+     * finger over the touchscreen element, as part of a "scroll" gesture
+     * initiated by dragging two fingers up or down, etc.
+     *
+     * @event
+     * @param {Guacamole.Mouse.State} state The current mouse state.
+     */
+	this.onmousedown = null;
+
+    /**
+     * Fired whenever a mouse button is effectively released. This can happen
+     * as part of a "mouseup" gesture initiated by the user by removing the
+     * finger pressed against the touchscreen element, or as part of a "scroll"
+     * gesture initiated by dragging two fingers up or down, etc.
+     *
+     * @event
+     * @param {Guacamole.Mouse.State} state The current mouse state.
+     */
+	this.onmouseup = null;
+
+    /**
+     * Fired whenever the user moves the mouse by dragging their finger over
+     * the touchscreen element. Note that unlike Guacamole.Mouse.Touchpad,
+     * dragging a finger over the touchscreen element will always cause
+     * the mouse button to be effectively down, as if clicking-and-dragging.
+     *
+     * @event
+     * @param {Guacamole.Mouse.State} state The current mouse state.
+     */
+	this.onmousemove = null;
+
+    /**
+     * Presses the given mouse button, if it isn't already pressed. Valid
+     * button values are "left", "middle", "right", "up", and "down".
+     *
+     * @private
+     * @param {String} button The mouse button to press.
+     */
+    function press_button(button) {
+        if (!guac_touchscreen.currentState[button]) {
+            guac_touchscreen.currentState[button] = true;
+            if (guac_touchscreen.onmousedown)
+                guac_touchscreen.onmousedown(guac_touchscreen.currentState);
+        }
+    }
+
+    /**
+     * Releases the given mouse button, if it isn't already released. Valid
+     * button values are "left", "middle", "right", "up", and "down".
+     *
+     * @private
+     * @param {String} button The mouse button to release.
+     */
+    function release_button(button) {
+        if (guac_touchscreen.currentState[button]) {
+            guac_touchscreen.currentState[button] = false;
+            if (guac_touchscreen.onmouseup)
+                guac_touchscreen.onmouseup(guac_touchscreen.currentState);
+        }
+    }
+
+    /**
+     * Clicks (presses and releases) the given mouse button. Valid button
+     * values are "left", "middle", "right", "up", and "down".
+     *
+     * @private
+     * @param {String} button The mouse button to click.
+     */
+    function click_button(button) {
+        press_button(button);
+        release_button(button);
+    }
+
+    /**
+     * Moves the mouse to the given coordinates. These coordinates must be
+     * relative to the browser window, as they will be translated based on
+     * the touch event target's location within the browser window.
+     *
+     * @private
+     * @param {Number} x The X coordinate of the mouse pointer.
+     * @param {Number} y The Y coordinate of the mouse pointer.
+     */
+    function move_mouse(x, y) {
+        guac_touchscreen.currentState.fromClientPosition(element, x, y);
+        if (guac_touchscreen.onmousemove)
+            guac_touchscreen.onmousemove(guac_touchscreen.currentState);
+    }
+
+    /**
+     * Returns whether the given touch event exceeds the movement threshold for
+     * clicking, based on where the touch gesture began.
+     *
+     * @private
+     * @param {TouchEvent} e The touch event to check.
+     * @return {Boolean} true if the movement threshold is exceeded, false
+     *                   otherwise.
+     */
+    function finger_moved(e) {
+        var touch = e.touches[0] || e.changedTouches[0];
+        var delta_x = touch.clientX - gesture_start_x;
+        var delta_y = touch.clientY - gesture_start_y;
+        return Math.sqrt(delta_x*delta_x + delta_y*delta_y) >= guac_touchscreen.clickMoveThreshold;
+    }
+
+    /**
+     * Begins a new gesture at the location of the first touch in the given
+     * touch event.
+     * 
+     * @private
+     * @param {TouchEvent} e The touch event beginning this new gesture.
+     */
+    function begin_gesture(e) {
+        var touch = e.touches[0];
+        gesture_in_progress = true;
+        gesture_start_x = touch.clientX;
+        gesture_start_y = touch.clientY;
+    }
+
+    /**
+     * End the current gesture entirely. Wait for all touches to be done before
+     * resuming gesture detection.
+     * 
+     * @private
+     */
+    function end_gesture() {
+        window.clearTimeout(click_release_timeout);
+        window.clearTimeout(long_press_timeout);
+        gesture_in_progress = false;
+    }
+
+    element.addEventListener("touchend", function(e) {
+
+        // Do not handle if no gesture
+        if (!gesture_in_progress)
+            return;
+
+        // Ignore if more than one touch
+        if (e.touches.length !== 0 || e.changedTouches.length !== 1) {
+            end_gesture();
+            return;
+        }
+
+        // Long-press, if any, is over
+        window.clearTimeout(long_press_timeout);
+
+        // Always release mouse button if pressed
+        release_button("left");
+
+        // If finger hasn't moved enough to cancel the click
+        if (!finger_moved(e)) {
+
+            e.preventDefault();
+
+            // If not yet pressed, press and start delay release
+            if (!guac_touchscreen.currentState.left) {
+
+                var touch = e.changedTouches[0];
+                move_mouse(touch.clientX, touch.clientY);
+                press_button("left");
+
+                // Release button after a delay, if not canceled
+                click_release_timeout = window.setTimeout(function() {
+                    release_button("left");
+                    end_gesture();
+                }, guac_touchscreen.clickTimingThreshold);
+
+            }
+
+        } // end if finger not moved
+
+    }, false);
+
+    element.addEventListener("touchstart", function(e) {
+
+        // Ignore if more than one touch
+        if (e.touches.length !== 1) {
+            end_gesture();
+            return;
+        }
+
+        e.preventDefault();
+
+        // New touch begins a new gesture
+        begin_gesture(e);
+
+        // Keep button pressed if tap after left click
+        window.clearTimeout(click_release_timeout);
+
+        // Click right button if this turns into a long-press
+        long_press_timeout = window.setTimeout(function() {
+            var touch = e.touches[0];
+            move_mouse(touch.clientX, touch.clientY);
+            click_button("right");
+            end_gesture();
+        }, guac_touchscreen.longPressThreshold);
+
+    }, false);
+
+    element.addEventListener("touchmove", function(e) {
+
+        // Do not handle if no gesture
+        if (!gesture_in_progress)
+            return;
+
+        // Cancel long press if finger moved
+        if (finger_moved(e))
+            window.clearTimeout(long_press_timeout);
+
+        // Ignore if more than one touch
+        if (e.touches.length !== 1) {
+            end_gesture();
+            return;
+        }
+
+        // Update mouse position if dragging
+        if (guac_touchscreen.currentState.left) {
+
+            e.preventDefault();
+
+            // Update state
+            var touch = e.touches[0];
+            move_mouse(touch.clientX, touch.clientY);
+
+        }
+
+    }, false);
+
+};
diff --git a/guacamole-common-js/src/main/webapp/modules/Namespace.js b/guacamole-common-js/src/main/webapp/modules/Namespace.js
new file mode 100644
index 0000000..2942a63
--- /dev/null
+++ b/guacamole-common-js/src/main/webapp/modules/Namespace.js
@@ -0,0 +1,29 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * The namespace used by the Guacamole JavaScript API. Absolutely all classes
+ * defined by the Guacamole JavaScript API will be within this namespace.
+ *
+ * @namespace
+ */
+var Guacamole = Guacamole || {};
diff --git a/guacamole-common-js/src/main/webapp/modules/Object.js b/guacamole-common-js/src/main/webapp/modules/Object.js
new file mode 100644
index 0000000..2db8bc0
--- /dev/null
+++ b/guacamole-common-js/src/main/webapp/modules/Object.js
@@ -0,0 +1,213 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+var Guacamole = Guacamole || {};
+
+/**
+ * An object used by the Guacamole client to house arbitrarily-many named
+ * input and output streams.
+ * 
+ * @constructor
+ * @param {Guacamole.Client} client
+ *     The client owning this object.
+ *
+ * @param {Number} index
+ *     The index of this object.
+ */
+Guacamole.Object = function guacamoleObject(client, index) {
+
+    /**
+     * Reference to this Guacamole.Object.
+     *
+     * @private
+     * @type {Guacamole.Object}
+     */
+    var guacObject = this;
+
+    /**
+     * Map of stream name to corresponding queue of callbacks. The queue of
+     * callbacks is guaranteed to be in order of request.
+     *
+     * @private
+     * @type {Object.<String, Function[]>}
+     */
+    var bodyCallbacks = {};
+
+    /**
+     * Removes and returns the callback at the head of the callback queue for
+     * the stream having the given name. If no such callbacks exist, null is
+     * returned.
+     *
+     * @private
+     * @param {String} name
+     *     The name of the stream to retrieve a callback for.
+     *
+     * @returns {Function}
+     *     The next callback associated with the stream having the given name,
+     *     or null if no such callback exists.
+     */
+    var dequeueBodyCallback = function dequeueBodyCallback(name) {
+
+        // If no callbacks defined, simply return null
+        var callbacks = bodyCallbacks[name];
+        if (!callbacks)
+            return null;
+
+        // Otherwise, pull off first callback, deleting the queue if empty
+        var callback = callbacks.shift();
+        if (callbacks.length === 0)
+            delete bodyCallbacks[name];
+
+        // Return found callback
+        return callback;
+
+    };
+
+    /**
+     * Adds the given callback to the tail of the callback queue for the stream
+     * having the given name.
+     *
+     * @private
+     * @param {String} name
+     *     The name of the stream to associate with the given callback.
+     *
+     * @param {Function} callback
+     *     The callback to add to the queue of the stream with the given name.
+     */
+    var enqueueBodyCallback = function enqueueBodyCallback(name, callback) {
+
+        // Get callback queue by name, creating first if necessary
+        var callbacks = bodyCallbacks[name];
+        if (!callbacks) {
+            callbacks = [];
+            bodyCallbacks[name] = callbacks;
+        }
+
+        // Add callback to end of queue
+        callbacks.push(callback);
+
+    };
+
+    /**
+     * The index of this object.
+     *
+     * @type {Number}
+     */
+    this.index = index;
+
+    /**
+     * Called when this object receives the body of a requested input stream.
+     * By default, all objects will invoke the callbacks provided to their
+     * requestInputStream() functions based on the name of the stream
+     * requested. This behavior can be overridden by specifying a different
+     * handler here.
+     *
+     * @event
+     * @param {Guacamole.InputStream} inputStream
+     *     The input stream of the received body.
+     *
+     * @param {String} mimetype
+     *     The mimetype of the data being received.
+     *
+     * @param {String} name
+     *     The name of the stream whose body has been received.
+     */
+    this.onbody = function defaultBodyHandler(inputStream, mimetype, name) {
+
+        // Call queued callback for the received body, if any
+        var callback = dequeueBodyCallback(name);
+        if (callback)
+            callback(inputStream, mimetype);
+
+    };
+
+    /**
+     * Called when this object is being undefined. Once undefined, no further
+     * communication involving this object may occur.
+     * 
+     * @event
+     */
+    this.onundefine = null;
+
+    /**
+     * Requests read access to the input stream having the given name. If
+     * successful, a new input stream will be created.
+     *
+     * @param {String} name
+     *     The name of the input stream to request.
+     *
+     * @param {Function} [bodyCallback]
+     *     The callback to invoke when the body of the requested input stream
+     *     is received. This callback will be provided a Guacamole.InputStream
+     *     and its mimetype as its two only arguments. If the onbody handler of
+     *     this object is overridden, this callback will not be invoked.
+     */
+    this.requestInputStream = function requestInputStream(name, bodyCallback) {
+
+        // Queue body callback if provided
+        if (bodyCallback)
+            enqueueBodyCallback(name, bodyCallback);
+
+        // Send request for input stream
+        client.requestObjectInputStream(guacObject.index, name);
+
+    };
+
+    /**
+     * Creates a new output stream associated with this object and having the
+     * given mimetype and name. The legality of a mimetype and name is dictated
+     * by the object itself.
+     *
+     * @param {String} mimetype
+     *     The mimetype of the data which will be sent to the output stream.
+     *
+     * @param {String} name
+     *     The defined name of an output stream within this object.
+     *
+     * @returns {Guacamole.OutputStream}
+     *     An output stream which will write blobs to the named output stream
+     *     of this object.
+     */
+    this.createOutputStream = function createOutputStream(mimetype, name) {
+        return client.createObjectOutputStream(guacObject.index, mimetype, name);
+    };
+
+};
+
+/**
+ * The reserved name denoting the root stream of any object. The contents of
+ * the root stream MUST be a JSON map of stream name to mimetype.
+ *
+ * @constant
+ * @type {String}
+ */
+Guacamole.Object.ROOT_STREAM = '/';
+
+/**
+ * The mimetype of a stream containing JSON which maps available stream names
+ * to their corresponding mimetype. The root stream of a Guacamole.Object MUST
+ * have this mimetype.
+ *
+ * @constant
+ * @type {String}
+ */
+Guacamole.Object.STREAM_INDEX_MIMETYPE = 'application/vnd.glyptodon.guacamole.stream-index+json';
diff --git a/guacamole-common-js/src/main/webapp/modules/OnScreenKeyboard.js b/guacamole-common-js/src/main/webapp/modules/OnScreenKeyboard.js
new file mode 100644
index 0000000..81d5680
--- /dev/null
+++ b/guacamole-common-js/src/main/webapp/modules/OnScreenKeyboard.js
@@ -0,0 +1,946 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+var Guacamole = Guacamole || {};
+
+/**
+ * Dynamic on-screen keyboard. Given the layout object for an on-screen
+ * keyboard, this object will construct a clickable on-screen keyboard with its
+ * own key events.
+ *
+ * @constructor
+ * @param {Guacamole.OnScreenKeyboard.Layout} layout
+ *     The layout of the on-screen keyboard to display.
+ */
+Guacamole.OnScreenKeyboard = function(layout) {
+
+    /**
+     * Reference to this Guacamole.OnScreenKeyboard.
+     *
+     * @private
+     * @type {Guacamole.OnScreenKeyboard}
+     */
+    var osk = this;
+
+    /**
+     * Map of currently-set modifiers to the keysym associated with their
+     * original press. When the modifier is cleared, this keysym must be
+     * released.
+     *
+     * @private
+     * @type {Object.<String, Number>}
+     */
+    var modifierKeysyms = {};
+
+    /**
+     * Map of all key names to their current pressed states. If a key is not
+     * pressed, it may not be in this map at all, but all pressed keys will
+     * have a corresponding mapping to true.
+     *
+     * @private
+     * @type {Object.<String, Boolean>}
+     */
+    var pressed = {};
+
+    /**
+     * All scalable elements which are part of the on-screen keyboard. Each
+     * scalable element is carefully controlled to ensure the interface layout
+     * and sizing remains constant, even on browsers that would otherwise
+     * experience rounding error due to unit conversions.
+     *
+     * @private
+     * @type {ScaledElement[]}
+     */
+    var scaledElements = [];
+
+    /**
+     * Adds a CSS class to an element.
+     * 
+     * @private
+     * @function
+     * @param {Element} element
+     *     The element to add a class to.
+     *
+     * @param {String} classname
+     *     The name of the class to add.
+     */
+    var addClass = function addClass(element, classname) {
+
+        // If classList supported, use that
+        if (element.classList)
+            element.classList.add(classname);
+
+        // Otherwise, simply append the class
+        else
+            element.className += " " + classname;
+
+    };
+
+    /**
+     * Removes a CSS class from an element.
+     * 
+     * @private
+     * @function
+     * @param {Element} element
+     *     The element to remove a class from.
+     *
+     * @param {String} classname
+     *     The name of the class to remove.
+     */
+    var removeClass = function removeClass(element, classname) {
+
+        // If classList supported, use that
+        if (element.classList)
+            element.classList.remove(classname);
+
+        // Otherwise, manually filter out classes with given name
+        else {
+            element.className = element.className.replace(/([^ ]+)[ ]*/g,
+                function removeMatchingClasses(match, testClassname) {
+
+                    // If same class, remove
+                    if (testClassname === classname)
+                        return "";
+
+                    // Otherwise, allow
+                    return match;
+                    
+                }
+            );
+        }
+
+    };
+
+    /**
+     * Counter of mouse events to ignore. This decremented by mousemove, and
+     * while non-zero, mouse events will have no effect.
+     *
+     * @private
+     * @type {Number}
+     */
+    var ignoreMouse = 0;
+
+    /**
+     * Ignores all pending mouse events when touch events are the apparent
+     * source. Mouse events are ignored until at least touchMouseThreshold
+     * mouse events occur without corresponding touch events.
+     *
+     * @private
+     */
+    var ignorePendingMouseEvents = function ignorePendingMouseEvents() {
+        ignoreMouse = osk.touchMouseThreshold;
+    };
+
+    /**
+     * An element whose dimensions are maintained according to an arbitrary
+     * scale. The conversion factor for these arbitrary units to pixels is
+     * provided later via a call to scale().
+     *
+     * @private
+     * @constructor
+     * @param {Element} element
+     *     The element whose scale should be maintained.
+     *
+     * @param {Number} width
+     *     The width of the element, in arbitrary units, relative to other
+     *     ScaledElements.
+     *
+     * @param {Number} height
+     *     The height of the element, in arbitrary units, relative to other
+     *     ScaledElements.
+     *     
+     * @param {Boolean} [scaleFont=false]
+     *     Whether the line height and font size should be scaled as well.
+     */
+    var ScaledElement = function ScaledElement(element, width, height, scaleFont) {
+
+        /**
+         * The width of this ScaledElement, in arbitrary units, relative to
+         * other ScaledElements.
+         *
+         * @type {Number}
+         */
+         this.width = width;
+
+        /**
+         * The height of this ScaledElement, in arbitrary units, relative to
+         * other ScaledElements.
+         *
+         * @type {Number}
+         */
+         this.height = height;
+ 
+        /**
+         * Resizes the associated element, updating its dimensions according to
+         * the given pixels per unit.
+         *
+         * @param {Number} pixels
+         *     The number of pixels to assign per arbitrary unit.
+         */
+        this.scale = function(pixels) {
+
+            // Scale element width/height
+            element.style.width  = (width  * pixels) + "px";
+            element.style.height = (height * pixels) + "px";
+
+            // Scale font, if requested
+            if (scaleFont) {
+                element.style.lineHeight = (height * pixels) + "px";
+                element.style.fontSize   = pixels + "px";
+            }
+
+        };
+
+    };
+
+    /**
+     * Returns whether all modifiers having the given names are currently
+     * active.
+     *
+     * @private
+     * @param {String[]} names
+     *     The names of all modifiers to test.
+     *
+     * @returns {Boolean}
+     *     true if all specified modifiers are pressed, false otherwise.
+     */
+    var modifiersPressed = function modifiersPressed(names) {
+
+        // If any required modifiers are not pressed, return false
+        for (var i=0; i < names.length; i++) {
+
+            // Test whether current modifier is pressed
+            var name = names[i];
+            if (!(name in modifierKeysyms))
+                return false;
+
+        }
+
+        // Otherwise, all required modifiers are pressed
+        return true;
+
+    };
+
+    /**
+     * Returns the single matching Key object associated with the key of the
+     * given name, where that Key object's requirements (such as pressed
+     * modifiers) are all currently satisfied.
+     *
+     * @private
+     * @param {String} keyName
+     *     The name of the key to retrieve.
+     *
+     * @returns {Guacamole.OnScreenKeyboard.Key}
+     *     The Key object associated with the given name, where that object's
+     *     requirements are all currently satisfied, or null if no such Key
+     *     can be found.
+     */
+    var getActiveKey = function getActiveKey(keyName) {
+
+        // Get key array for given name
+        var keys = osk.keys[keyName];
+        if (!keys)
+            return null;
+
+        // Find last matching key
+        for (var i = keys.length - 1; i >= 0; i--) {
+
+            // Get candidate key
+            var candidate = keys[i];
+
+            // If all required modifiers are pressed, use that key
+            if (modifiersPressed(candidate.requires))
+                return candidate;
+
+        }
+
+        // No valid key
+        return null;
+
+    };
+
+    /**
+     * Presses the key having the given name, updating the associated key
+     * element with the "guac-keyboard-pressed" CSS class. If the key is
+     * already pressed, this function has no effect.
+     *
+     * @private
+     * @param {String} keyName
+     *     The name of the key to press.
+     *
+     * @param {String} keyElement
+     *     The element associated with the given key.
+     */
+    var press = function press(keyName, keyElement) {
+
+        // Press key if not yet pressed
+        if (!pressed[keyName]) {
+
+            addClass(keyElement, "guac-keyboard-pressed");
+
+            // Get current key based on modifier state
+            var key = getActiveKey(keyName);
+
+            // Update modifier state
+            if (key.modifier) {
+
+                // Construct classname for modifier
+                var modifierClass = "guac-keyboard-modifier-" + getCSSName(key.modifier);
+
+                // Retrieve originally-pressed keysym, if modifier was already pressed
+                var originalKeysym = modifierKeysyms[key.modifier];
+
+                // Activate modifier if not pressed
+                if (!originalKeysym) {
+                    
+                    addClass(keyboard, modifierClass);
+                    modifierKeysyms[key.modifier] = key.keysym;
+                    
+                    // Send key event
+                    if (osk.onkeydown)
+                        osk.onkeydown(key.keysym);
+
+                }
+
+                // Deactivate if not pressed
+                else {
+
+                    removeClass(keyboard, modifierClass);
+                    delete modifierKeysyms[key.modifier];
+                    
+                    // Send key event
+                    if (osk.onkeyup)
+                        osk.onkeyup(originalKeysym);
+
+                }
+
+            }
+
+            // If not modifier, send key event now
+            else if (osk.onkeydown)
+                osk.onkeydown(key.keysym);
+
+            // Mark key as pressed
+            pressed[keyName] = true;
+
+        }
+
+    };
+
+    /**
+     * Releases the key having the given name, removing the
+     * "guac-keyboard-pressed" CSS class from the associated element. If the
+     * key is already released, this function has no effect.
+     *
+     * @private
+     * @param {String} keyName
+     *     The name of the key to release.
+     *
+     * @param {String} keyElement
+     *     The element associated with the given key.
+     */
+    var release = function release(keyName, keyElement) {
+
+        // Release key if currently pressed
+        if (pressed[keyName]) {
+
+            removeClass(keyElement, "guac-keyboard-pressed");
+
+            // Get current key based on modifier state
+            var key = getActiveKey(keyName);
+
+            // Send key event if not a modifier key
+            if (!key.modifier && osk.onkeyup)
+                osk.onkeyup(key.keysym);
+
+            // Mark key as released
+            pressed[keyName] = false;
+
+        }
+
+    };
+
+    // Create keyboard
+    var keyboard = document.createElement("div");
+    keyboard.className = "guac-keyboard";
+
+    // Do not allow selection or mouse movement to propagate/register.
+    keyboard.onselectstart =
+    keyboard.onmousemove   =
+    keyboard.onmouseup     =
+    keyboard.onmousedown   = function handleMouseEvents(e) {
+
+        // If ignoring events, decrement counter
+        if (ignoreMouse)
+            ignoreMouse--;
+
+        e.stopPropagation();
+        return false;
+
+    };
+
+    /**
+     * The number of mousemove events to require before re-enabling mouse
+     * event handling after receiving a touch event.
+     *
+     * @type {Number}
+     */
+    this.touchMouseThreshold = 3;
+
+    /**
+     * Fired whenever the user presses a key on this Guacamole.OnScreenKeyboard.
+     * 
+     * @event
+     * @param {Number} keysym The keysym of the key being pressed.
+     */
+    this.onkeydown = null;
+
+    /**
+     * Fired whenever the user releases a key on this Guacamole.OnScreenKeyboard.
+     * 
+     * @event
+     * @param {Number} keysym The keysym of the key being released.
+     */
+    this.onkeyup = null;
+
+    /**
+     * The keyboard layout provided at time of construction.
+     *
+     * @type {Guacamole.OnScreenKeyboard.Layout}
+     */
+    this.layout = new Guacamole.OnScreenKeyboard.Layout(layout);
+
+    /**
+     * Returns the element containing the entire on-screen keyboard.
+     * @returns {Element} The element containing the entire on-screen keyboard.
+     */
+    this.getElement = function() {
+        return keyboard;
+    };
+
+    /**
+     * Resizes all elements within this Guacamole.OnScreenKeyboard such that
+     * the width is close to but does not exceed the specified width. The
+     * height of the keyboard is determined based on the width.
+     * 
+     * @param {Number} width The width to resize this Guacamole.OnScreenKeyboard
+     *                       to, in pixels.
+     */
+    this.resize = function(width) {
+
+        // Get pixel size of a unit
+        var unit = Math.floor(width * 10 / osk.layout.width) / 10;
+
+        // Resize all scaled elements
+        for (var i=0; i<scaledElements.length; i++) {
+            var scaledElement = scaledElements[i];
+            scaledElement.scale(unit);
+        }
+
+    };
+
+    /**
+     * Given the name of a key and its corresponding definition, which may be
+     * an array of keys objects, a number (keysym), a string (key title), or a
+     * single key object, returns an array of key objects, deriving any missing
+     * properties as needed, and ensuring the key name is defined.
+     *
+     * @private
+     * @param {String} name
+     *     The name of the key being coerced into an array of Key objects.
+     *
+     * @param {Number|String|Guacamole.OnScreenKeyboard.Key|Guacamole.OnScreenKeyboard.Key[]} object
+     *     The object defining the behavior of the key having the given name,
+     *     which may be the title of the key (a string), the keysym (a number),
+     *     a single Key object, or an array of Key objects.
+     *     
+     * @returns {Guacamole.OnScreenKeyboard.Key[]}
+     *     An array of all keys associated with the given name.
+     */
+    var asKeyArray = function asKeyArray(name, object) {
+
+        // If already an array, just coerce into a true Key[] 
+        if (object instanceof Array) {
+            var keys = [];
+            for (var i=0; i < object.length; i++) {
+                keys.push(new Guacamole.OnScreenKeyboard.Key(object[i], name));
+            }
+            return keys;
+        }
+
+        // Derive key object from keysym if that's all we have
+        if (typeof object === 'number') {
+            return [new Guacamole.OnScreenKeyboard.Key({
+                name   : name,
+                keysym : object
+            })];
+        }
+
+        // Derive key object from title if that's all we have
+        if (typeof object === 'string') {
+            return [new Guacamole.OnScreenKeyboard.Key({
+                name  : name,
+                title : object
+            })];
+        }
+
+        // Otherwise, assume it's already a key object, just not an array
+        return [new Guacamole.OnScreenKeyboard.Key(object, name)];
+
+    };
+
+    /**
+     * Converts the rather forgiving key mapping allowed by
+     * Guacamole.OnScreenKeyboard.Layout into a rigorous mapping of key name
+     * to key definition, where the key definition is always an array of Key
+     * objects.
+     *
+     * @private
+     * @param {Object.<String, Number|String|Guacamole.OnScreenKeyboard.Key|Guacamole.OnScreenKeyboard.Key[]>} keys
+     *     A mapping of key name to key definition, where the key definition is
+     *     the title of the key (a string), the keysym (a number), a single
+     *     Key object, or an array of Key objects.
+     *
+     * @returns {Object.<String, Guacamole.OnScreenKeyboard.Key[]>}
+     *     A more-predictable mapping of key name to key definition, where the
+     *     key definition is always simply an array of Key objects.
+     */
+    var getKeys = function getKeys(keys) {
+
+        var keyArrays = {};
+
+        // Coerce all keys into individual key arrays
+        for (var name in layout.keys) {
+            keyArrays[name] = asKeyArray(name, keys[name]);
+        }
+
+        return keyArrays;
+
+    };
+
+    /**
+     * Map of all key names to their corresponding set of keys. Each key name
+     * may correspond to multiple keys due to the effect of modifiers.
+     *
+     * @type {Object.<String, Guacamole.OnScreenKeyboard.Key[]>}
+     */
+    this.keys = getKeys(layout.keys);
+
+    /**
+     * Given an arbitrary string representing the name of some component of the
+     * on-screen keyboard, returns a string formatted for use as a CSS class
+     * name. The result will be lowercase. Word boundaries previously denoted
+     * by CamelCase will be replaced by individual hyphens, as will all
+     * contiguous non-alphanumeric characters.
+     *
+     * @private
+     * @param {String} name
+     *     An arbitrary string representing the name of some component of the
+     *     on-screen keyboard.
+     *
+     * @returns {String}
+     *     A string formatted for use as a CSS class name.
+     */
+    var getCSSName = function getCSSName(name) {
+
+        // Convert name from possibly-CamelCase to hyphenated lowercase
+        var cssName = name
+               .replace(/([a-z])([A-Z])/g, '$1-$2')
+               .replace(/[^A-Za-z0-9]+/g, '-')
+               .toLowerCase();
+
+        return cssName;
+
+    };
+
+    /**
+     * Appends DOM elements to the given element as dictated by the layout
+     * structure object provided. If a name is provided, an additional CSS
+     * class, prepended with "guac-keyboard-", will be added to the top-level
+     * element.
+     * 
+     * If the layout structure object is an array, all elements within that
+     * array will be recursively appended as children of a group, and the
+     * top-level element will be given the CSS class "guac-keyboard-group".
+     *
+     * If the layout structure object is an object, all properties within that
+     * object will be recursively appended as children of a group, and the
+     * top-level element will be given the CSS class "guac-keyboard-group". The
+     * name of each property will be applied as the name of each child object
+     * for the sake of CSS. Each property will be added in sorted order.
+     *
+     * If the layout structure object is a string, the key having that name
+     * will be appended. The key will be given the CSS class
+     * "guac-keyboard-key" and "guac-keyboard-key-NAME", where NAME is the name
+     * of the key. If the name of the key is a single character, this will
+     * first be transformed into the C-style hexadecimal literal for the
+     * Unicode codepoint of that character. For example, the key "A" would
+     * become "guac-keyboard-key-0x41".
+     * 
+     * If the layout structure object is a number, a gap of that size will be
+     * inserted. The gap will be given the CSS class "guac-keyboard-gap", and
+     * will be scaled according to the same size units as each key.
+     *
+     * @private
+     * @param {Element} element
+     *     The element to append elements to.
+     *
+     * @param {Array|Object|String|Number} object
+     *     The layout structure object to use when constructing the elements to
+     *     append.
+     *
+     * @param {String} [name]
+     *     The name of the top-level element being appended, if any.
+     */
+    var appendElements = function appendElements(element, object, name) {
+
+        var i;
+
+        // Create div which will become the group or key
+        var div = document.createElement('div');
+
+        // Add class based on name, if name given
+        if (name)
+            addClass(div, 'guac-keyboard-' + getCSSName(name));
+
+        // If an array, append each element
+        if (object instanceof Array) {
+
+            // Add group class
+            addClass(div, 'guac-keyboard-group');
+
+            // Append all elements of array
+            for (i=0; i < object.length; i++)
+                appendElements(div, object[i]);
+
+        }
+
+        // If an object, append each property value
+        else if (object instanceof Object) {
+
+            // Add group class
+            addClass(div, 'guac-keyboard-group');
+
+            // Append all children, sorted by name
+            var names = Object.keys(object).sort();
+            for (i=0; i < names.length; i++) {
+                var name = names[i];
+                appendElements(div, object[name], name);
+            }
+
+        }
+
+        // If a number, create as a gap 
+        else if (typeof object === 'number') {
+
+            // Add gap class
+            addClass(div, 'guac-keyboard-gap');
+
+            // Maintain scale
+            scaledElements.push(new ScaledElement(div, object, object));
+
+        }
+
+        // If a string, create as a key
+        else if (typeof object === 'string') {
+
+            // If key name is only one character, use codepoint for name
+            var keyName = object;
+            if (keyName.length === 1)
+                keyName = '0x' + keyName.charCodeAt(0).toString(16);
+
+            // Add key container class
+            addClass(div, 'guac-keyboard-key-container');
+
+            // Create key element which will contain all possible caps
+            var keyElement = document.createElement('div');
+            keyElement.className = 'guac-keyboard-key '
+                                 + 'guac-keyboard-key-' + getCSSName(keyName);
+
+            // Add all associated keys as caps within DOM
+            var keys = osk.keys[object];
+            if (keys) {
+                for (i=0; i < keys.length; i++) {
+
+                    // Get current key
+                    var key = keys[i];
+
+                    // Create cap element for key
+                    var capElement = document.createElement('div');
+                    capElement.className   = 'guac-keyboard-cap';
+                    capElement.textContent = key.title;
+
+                    // Add classes for any requirements
+                    for (var j=0; j < key.requires.length; j++) {
+                        var requirement = key.requires[j];
+                        addClass(capElement, 'guac-keyboard-requires-' + getCSSName(requirement));
+                        addClass(keyElement, 'guac-keyboard-uses-'     + getCSSName(requirement));
+                    }
+
+                    // Add cap to key within DOM
+                    keyElement.appendChild(capElement);
+
+                }
+            }
+
+            // Add key to DOM, maintain scale
+            div.appendChild(keyElement);
+            scaledElements.push(new ScaledElement(div, osk.layout.keyWidths[object] || 1, 1, true));
+
+            /**
+             * Handles a touch event which results in the pressing of an OSK
+             * key. Touch events will result in mouse events being ignored for
+             * touchMouseThreshold events.
+             *
+             * @private
+             * @param {TouchEvent} e
+             *     The touch event being handled.
+             */
+            var touchPress = function touchPress(e) {
+                e.preventDefault();
+                ignoreMouse = osk.touchMouseThreshold;
+                press(object, keyElement);
+            };
+
+            /**
+             * Handles a touch event which results in the release of an OSK
+             * key. Touch events will result in mouse events being ignored for
+             * touchMouseThreshold events.
+             *
+             * @private
+             * @param {TouchEvent} e
+             *     The touch event being handled.
+             */
+            var touchRelease = function touchRelease(e) {
+                e.preventDefault();
+                ignoreMouse = osk.touchMouseThreshold;
+                release(object, keyElement);
+            };
+
+            /**
+             * Handles a mouse event which results in the pressing of an OSK
+             * key. If mouse events are currently being ignored, this handler
+             * does nothing.
+             *
+             * @private
+             * @param {MouseEvent} e
+             *     The touch event being handled.
+             */
+            var mousePress = function mousePress(e) {
+                e.preventDefault();
+                if (ignoreMouse === 0)
+                    press(object, keyElement);
+            };
+
+            /**
+             * Handles a mouse event which results in the release of an OSK
+             * key. If mouse events are currently being ignored, this handler
+             * does nothing.
+             *
+             * @private
+             * @param {MouseEvent} e
+             *     The touch event being handled.
+             */
+            var mouseRelease = function mouseRelease(e) {
+                e.preventDefault();
+                if (ignoreMouse === 0)
+                    release(object, keyElement);
+            };
+
+            // Handle touch events on key
+            keyElement.addEventListener("touchstart", touchPress,   true);
+            keyElement.addEventListener("touchend",   touchRelease, true);
+
+            // Handle mouse events on key
+            keyElement.addEventListener("mousedown", mousePress,   true);
+            keyElement.addEventListener("mouseup",   mouseRelease, true);
+            keyElement.addEventListener("mouseout",  mouseRelease, true);
+
+        } // end if object is key name
+
+        // Add newly-created group/key
+        element.appendChild(div);
+
+    };
+
+    // Create keyboard layout in DOM
+    appendElements(keyboard, layout.layout);
+
+};
+
+/**
+ * Represents an entire on-screen keyboard layout, including all available
+ * keys, their behaviors, and their relative position and sizing.
+ *
+ * @constructor
+ * @param {Guacamole.OnScreenKeyboard.Layout|Object} template
+ *     The object whose identically-named properties will be used to initialize
+ *     the properties of this layout.
+ */
+Guacamole.OnScreenKeyboard.Layout = function(template) {
+
+    /**
+     * The language of keyboard layout, such as "en_US". This property is for
+     * informational purposes only, but it is recommend to conform to the
+     * [language code]_[country code] format.
+     *
+     * @type {String}
+     */
+    this.language = template.language;
+
+    /**
+     * The type of keyboard layout, such as "qwerty". This property is for
+     * informational purposes only, and does not conform to any standard.
+     *
+     * @type {String}
+     */
+    this.type = template.type;
+
+    /**
+     * Map of key name to corresponding keysym, title, or key object. If only
+     * the keysym or title is provided, the key object will be created
+     * implicitly. In all cases, the name property of the key object will be
+     * taken from the name given in the mapping.
+     *
+     * @type {Object.<String, Number|String|Guacamole.OnScreenKeyboard.Key|Guacamole.OnScreenKeyboard.Key[]>}
+     */
+    this.keys = template.keys;
+
+    /**
+     * Arbitrarily nested, arbitrarily grouped key names. The contents of the
+     * layout will be traversed to produce an identically-nested grouping of
+     * keys in the DOM tree. All strings will be transformed into their
+     * corresponding sets of keys, while all objects and arrays will be
+     * transformed into named groups and anonymous groups respectively. Any
+     * numbers present will be transformed into gaps of that size, scaled
+     * according to the same units as each key.
+     *
+     * @type {Object}
+     */
+    this.layout = template.layout;
+
+    /**
+     * The width of the entire keyboard, in arbitrary units. The width of each
+     * key is relative to this width, as both width values are assumed to be in
+     * the same units. The conversion factor between these units and pixels is
+     * derived later via a call to resize() on the Guacamole.OnScreenKeyboard.
+     *
+     * @type {Number}
+     */
+    this.width = template.width;
+
+    /**
+     * The width of each key, in arbitrary units, relative to other keys in
+     * this layout. The true pixel size of each key will be determined by the
+     * overall size of the keyboard. If not defined here, the width of each
+     * key will default to 1.
+     *
+     * @type {Object.<String, Number>}
+     */
+    this.keyWidths = template.keyWidths || {};
+
+};
+
+/**
+ * Represents a single key, or a single possible behavior of a key. Each key
+ * on the on-screen keyboard must have at least one associated
+ * Guacamole.OnScreenKeyboard.Key, whether that key is explicitly defined or
+ * implied, and may have multiple Guacamole.OnScreenKeyboard.Key if behavior
+ * depends on modifier states.
+ *
+ * @constructor
+ * @param {Guacamole.OnScreenKeyboard.Key|Object} template
+ *     The object whose identically-named properties will be used to initialize
+ *     the properties of this key.
+ *     
+ * @param {String} [name]
+ *     The name to use instead of any name provided within the template, if
+ *     any. If omitted, the name within the template will be used, assuming the
+ *     template contains a name.
+ */
+Guacamole.OnScreenKeyboard.Key = function(template, name) {
+
+    /**
+     * The unique name identifying this key within the keyboard layout.
+     *
+     * @type {String}
+     */
+    this.name = name || template.name;
+
+    /**
+     * The human-readable title that will be displayed to the user within the
+     * key. If not provided, this will be derived from the key name.
+     *
+     * @type {String}
+     */
+    this.title = template.title || this.name;
+
+    /**
+     * The keysym to be pressed/released when this key is pressed/released. If
+     * not provided, this will be derived from the title if the title is a
+     * single character.
+     *
+     * @type {Number}
+     */
+    this.keysym = template.keysym || (function deriveKeysym(title) {
+
+        // Do not derive keysym if title is not exactly one character
+        if (!title || title.length !== 1)
+            return null;
+
+        // For characters between U+0000 and U+00FF, the keysym is the codepoint
+        var charCode = title.charCodeAt(0);
+        if (charCode >= 0x0000 && charCode <= 0x00FF)
+            return charCode;
+
+        // For characters between U+0100 and U+10FFFF, the keysym is the codepoint or'd with 0x01000000
+        if (charCode >= 0x0100 && charCode <= 0x10FFFF)
+            return 0x01000000 | charCode;
+
+        // Unable to derive keysym
+        return null;
+
+    })(this.title);
+
+    /**
+     * The name of the modifier set when the key is pressed and cleared when
+     * this key is released, if any. The names of modifiers are distinct from
+     * the names of keys; both the "RightShift" and "LeftShift" keys may set
+     * the "shift" modifier, for example. By default, the key will affect no
+     * modifiers.
+     * 
+     * @type {String}
+     */
+    this.modifier = template.modifier;
+
+    /**
+     * An array containing the names of each modifier required for this key to
+     * have an effect. For example, a lowercase letter may require nothing,
+     * while an uppercase letter would require "shift", assuming the Shift key
+     * is named "shift" within the layout. By default, the key will require
+     * no modifiers.
+     *
+     * @type {String[]}
+     */
+    this.requires = template.requires || [];
+
+};
diff --git a/guacamole-common-js/src/main/webapp/modules/OutputStream.js b/guacamole-common-js/src/main/webapp/modules/OutputStream.js
new file mode 100644
index 0000000..d4fdc58
--- /dev/null
+++ b/guacamole-common-js/src/main/webapp/modules/OutputStream.js
@@ -0,0 +1,71 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+var Guacamole = Guacamole || {};
+
+/**
+ * Abstract stream which can receive data.
+ * 
+ * @constructor
+ * @param {Guacamole.Client} client The client owning this stream.
+ * @param {Number} index The index of this stream.
+ */
+Guacamole.OutputStream = function(client, index) {
+
+    /**
+     * Reference to this stream.
+     * @private
+     */
+    var guac_stream = this;
+
+    /**
+     * The index of this stream.
+     * @type {Number}
+     */
+    this.index = index;
+
+    /**
+     * Fired whenever an acknowledgement is received from the server, indicating
+     * that a stream operation has completed, or an error has occurred.
+     * 
+     * @event
+     * @param {Guacamole.Status} status The status of the operation.
+     */
+    this.onack = null;
+
+    /**
+     * Writes the given base64-encoded data to this stream as a blob.
+     * 
+     * @param {String} data The base64-encoded data to send.
+     */
+    this.sendBlob = function(data) {
+        client.sendBlob(guac_stream.index, data);
+    };
+
+    /**
+     * Closes this stream.
+     */
+    this.sendEnd = function() {
+        client.endStream(guac_stream.index);
+    };
+
+};
diff --git a/guacamole-common-js/src/main/webapp/modules/Parser.js b/guacamole-common-js/src/main/webapp/modules/Parser.js
new file mode 100644
index 0000000..ff03046
--- /dev/null
+++ b/guacamole-common-js/src/main/webapp/modules/Parser.js
@@ -0,0 +1,159 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+var Guacamole = Guacamole || {};
+
+/**
+ * Simple Guacamole protocol parser that invokes an oninstruction event when
+ * full instructions are available from data received via receive().
+ * 
+ * @constructor
+ */
+Guacamole.Parser = function() {
+
+    /**
+     * Reference to this parser.
+     * @private
+     */
+    var parser = this;
+
+    /**
+     * Current buffer of received data. This buffer grows until a full
+     * element is available. After a full element is available, that element
+     * is flushed into the element buffer.
+     * 
+     * @private
+     */
+    var buffer = "";
+
+    /**
+     * Buffer of all received, complete elements. After an entire instruction
+     * is read, this buffer is flushed, and a new instruction begins.
+     * 
+     * @private
+     */
+    var element_buffer = [];
+
+    // The location of the last element's terminator
+    var element_end = -1;
+
+    // Where to start the next length search or the next element
+    var start_index = 0;
+
+    /**
+     * Appends the given instruction data packet to the internal buffer of
+     * this Guacamole.Parser, executing all completed instructions at
+     * the beginning of this buffer, if any.
+     *
+     * @param {String} packet The instruction data to receive.
+     */
+    this.receive = function(packet) {
+
+        // Truncate buffer as necessary
+        if (start_index > 4096 && element_end >= start_index) {
+
+            buffer = buffer.substring(start_index);
+
+            // Reset parse relative to truncation
+            element_end -= start_index;
+            start_index = 0;
+
+        }
+
+        // Append data to buffer
+        buffer += packet;
+
+        // While search is within currently received data
+        while (element_end < buffer.length) {
+
+            // If we are waiting for element data
+            if (element_end >= start_index) {
+
+                // We now have enough data for the element. Parse.
+                var element = buffer.substring(start_index, element_end);
+                var terminator = buffer.substring(element_end, element_end+1);
+
+                // Add element to array
+                element_buffer.push(element);
+
+                // If last element, handle instruction
+                if (terminator == ";") {
+
+                    // Get opcode
+                    var opcode = element_buffer.shift();
+
+                    // Call instruction handler.
+                    if (parser.oninstruction != null)
+                        parser.oninstruction(opcode, element_buffer);
+
+                    // Clear elements
+                    element_buffer.length = 0;
+
+                }
+                else if (terminator != ',')
+                    throw new Error("Illegal terminator.");
+
+                // Start searching for length at character after
+                // element terminator
+                start_index = element_end + 1;
+
+            }
+
+            // Search for end of length
+            var length_end = buffer.indexOf(".", start_index);
+            if (length_end != -1) {
+
+                // Parse length
+                var length = parseInt(buffer.substring(element_end+1, length_end));
+                if (length == NaN)
+                    throw new Error("Non-numeric character in element length.");
+
+                // Calculate start of element
+                start_index = length_end + 1;
+
+                // Calculate location of element terminator
+                element_end = start_index + length;
+
+            }
+            
+            // If no period yet, continue search when more data
+            // is received
+            else {
+                start_index = buffer.length;
+                break;
+            }
+
+        } // end parse loop
+
+    };
+
+    /**
+     * 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;
+
+};
diff --git a/guacamole-common-js/src/main/webapp/modules/Status.js b/guacamole-common-js/src/main/webapp/modules/Status.js
new file mode 100644
index 0000000..64c5b85
--- /dev/null
+++ b/guacamole-common-js/src/main/webapp/modules/Status.js
@@ -0,0 +1,190 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+var Guacamole = Guacamole || {};
+
+/**
+ * A Guacamole status. Each Guacamole status consists of a status code, defined
+ * by the protocol, and an optional human-readable message, usually only
+ * included for debugging convenience.
+ *
+ * @constructor
+ * @param {Number} code
+ *     The Guacamole status code, as defined by Guacamole.Status.Code.
+ *
+ * @param {String} [message]
+ *     An optional human-readable message.
+ */
+Guacamole.Status = function(code, message) {
+
+    /**
+     * Reference to this Guacamole.Status.
+     * @private
+     */
+    var guac_status = this;
+
+    /**
+     * The Guacamole status code.
+     * @see Guacamole.Status.Code
+     * @type {Number}
+     */
+    this.code = code;
+
+    /**
+     * An arbitrary human-readable message associated with this status, if any.
+     * The human-readable message is not required, and is generally provided
+     * for debugging purposes only. For user feedback, it is better to translate
+     * the Guacamole status code into a message.
+     * 
+     * @type {String}
+     */
+    this.message = message;
+
+    /**
+     * Returns whether this status represents an error.
+     * @returns {Boolean} true if this status represents an error, false
+     *                    otherwise.
+     */
+    this.isError = function() {
+        return guac_status.code < 0 || guac_status.code > 0x00FF;
+    };
+
+};
+
+/**
+ * Enumeration of all Guacamole status codes.
+ */
+Guacamole.Status.Code = {
+
+    /**
+     * The operation succeeded.
+     *
+     * @type {Number}
+     */
+    "SUCCESS": 0x0000,
+
+    /**
+     * The requested operation is unsupported.
+     *
+     * @type {Number}
+     */
+    "UNSUPPORTED": 0x0100,
+
+    /**
+     * The operation could not be performed due to an internal failure.
+     *
+     * @type {Number}
+     */
+    "SERVER_ERROR": 0x0200,
+
+    /**
+     * The operation could not be performed as the server is busy.
+     *
+     * @type {Number}
+     */
+    "SERVER_BUSY": 0x0201,
+
+    /**
+     * The operation could not be performed because the upstream server is not
+     * responding.
+     *
+     * @type {Number}
+     */
+    "UPSTREAM_TIMEOUT": 0x0202,
+
+    /**
+     * The operation was unsuccessful due to an error or otherwise unexpected
+     * condition of the upstream server.
+     *
+     * @type {Number}
+     */
+    "UPSTREAM_ERROR": 0x0203,
+
+    /**
+     * The operation could not be performed as the requested resource does not
+     * exist.
+     *
+     * @type {Number}
+     */
+    "RESOURCE_NOT_FOUND": 0x0204,
+
+    /**
+     * The operation could not be performed as the requested resource is
+     * already in use.
+     *
+     * @type {Number}
+     */
+    "RESOURCE_CONFLICT": 0x0205,
+
+    /**
+     * The operation could not be performed because bad parameters were given.
+     *
+     * @type {Number}
+     */
+    "CLIENT_BAD_REQUEST": 0x0300,
+
+    /**
+     * Permission was denied to perform the operation, as the user is not yet
+     * authorized (not yet logged in, for example).
+     *
+     * @type {Number}
+     */
+    "CLIENT_UNAUTHORIZED": 0x0301,
+
+    /**
+     * Permission was denied to perform the operation, and this permission will
+     * not be granted even if the user is authorized.
+     *
+     * @type {Number}
+     */
+    "CLIENT_FORBIDDEN": 0x0303,
+
+    /**
+     * The client took too long to respond.
+     *
+     * @type {Number}
+     */
+    "CLIENT_TIMEOUT": 0x0308,
+
+    /**
+     * The client sent too much data.
+     *
+     * @type {Number}
+     */
+    "CLIENT_OVERRUN": 0x030D,
+
+    /**
+     * The client sent data of an unsupported or unexpected type.
+     *
+     * @type {Number}
+     */
+    "CLIENT_BAD_TYPE": 0x030F,
+
+    /**
+     * The operation failed because the current client is already using too
+     * many resources.
+     *
+     * @type {Number}
+     */
+    "CLIENT_TOO_MANY": 0x031D
+
+};
diff --git a/guacamole-common-js/src/main/webapp/modules/StringReader.js b/guacamole-common-js/src/main/webapp/modules/StringReader.js
new file mode 100644
index 0000000..7b49e3f
--- /dev/null
+++ b/guacamole-common-js/src/main/webapp/modules/StringReader.js
@@ -0,0 +1,170 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+var Guacamole = Guacamole || {};
+
+/**
+ * A reader which automatically handles the given input stream, returning
+ * strictly text data. Note that this object will overwrite any installed event
+ * handlers on the given Guacamole.InputStream.
+ * 
+ * @constructor
+ * @param {Guacamole.InputStream} stream The stream that data will be read
+ *                                       from.
+ */
+Guacamole.StringReader = function(stream) {
+
+    /**
+     * Reference to this Guacamole.InputStream.
+     * @private
+     */
+    var guac_reader = this;
+
+    /**
+     * Wrapped Guacamole.ArrayBufferReader.
+     * @private
+     * @type {Guacamole.ArrayBufferReader}
+     */
+    var array_reader = new Guacamole.ArrayBufferReader(stream);
+
+    /**
+     * The number of bytes remaining for the current codepoint.
+     *
+     * @private
+     * @type {Number}
+     */
+    var bytes_remaining = 0;
+
+    /**
+     * The current codepoint value, as calculated from bytes read so far.
+     *
+     * @private
+     * @type {Number}
+     */
+    var codepoint = 0;
+
+    /**
+     * Decodes the given UTF-8 data into a Unicode string. The data may end in
+     * the middle of a multibyte character.
+     * 
+     * @private
+     * @param {ArrayBuffer} buffer Arbitrary UTF-8 data.
+     * @return {String} A decoded Unicode string.
+     */
+    function __decode_utf8(buffer) {
+
+        var text = "";
+
+        var bytes = new Uint8Array(buffer);
+        for (var i=0; i<bytes.length; i++) {
+
+            // Get current byte
+            var value = bytes[i];
+
+            // Start new codepoint if nothing yet read
+            if (bytes_remaining === 0) {
+
+                // 1 byte (0xxxxxxx)
+                if ((value | 0x7F) === 0x7F)
+                    text += String.fromCharCode(value);
+
+                // 2 byte (110xxxxx)
+                else if ((value | 0x1F) === 0xDF) {
+                    codepoint = value & 0x1F;
+                    bytes_remaining = 1;
+                }
+
+                // 3 byte (1110xxxx)
+                else if ((value | 0x0F )=== 0xEF) {
+                    codepoint = value & 0x0F;
+                    bytes_remaining = 2;
+                }
+
+                // 4 byte (11110xxx)
+                else if ((value | 0x07) === 0xF7) {
+                    codepoint = value & 0x07;
+                    bytes_remaining = 3;
+                }
+
+                // Invalid byte
+                else
+                    text += "\uFFFD";
+
+            }
+
+            // Continue existing codepoint (10xxxxxx)
+            else if ((value | 0x3F) === 0xBF) {
+
+                codepoint = (codepoint << 6) | (value & 0x3F);
+                bytes_remaining--;
+
+                // Write codepoint if finished
+                if (bytes_remaining === 0)
+                    text += String.fromCharCode(codepoint);
+
+            }
+
+            // Invalid byte
+            else {
+                bytes_remaining = 0;
+                text += "\uFFFD";
+            }
+
+        }
+
+        return text;
+
+    }
+
+    // Receive blobs as strings
+    array_reader.ondata = function(buffer) {
+
+        // Decode UTF-8
+        var text = __decode_utf8(buffer);
+
+        // Call handler, if present
+        if (guac_reader.ontext)
+            guac_reader.ontext(text);
+
+    };
+
+    // Simply call onend when end received
+    array_reader.onend = function() {
+        if (guac_reader.onend)
+            guac_reader.onend();
+    };
+
+    /**
+     * Fired once for every blob of text data received.
+     * 
+     * @event
+     * @param {String} text The data packet received.
+     */
+    this.ontext = null;
+
+    /**
+     * Fired once this stream is finished and no further data will be written.
+     * @event
+     */
+    this.onend = null;
+
+};
\ No newline at end of file
diff --git a/guacamole-common-js/src/main/webapp/modules/StringWriter.js b/guacamole-common-js/src/main/webapp/modules/StringWriter.js
new file mode 100644
index 0000000..1f942f2
--- /dev/null
+++ b/guacamole-common-js/src/main/webapp/modules/StringWriter.js
@@ -0,0 +1,194 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+var Guacamole = Guacamole || {};
+
+/**
+ * A writer which automatically writes to the given output stream with text
+ * data.
+ * 
+ * @constructor
+ * @param {Guacamole.OutputStream} stream The stream that data will be written
+ *                                        to.
+ */
+Guacamole.StringWriter = function(stream) {
+
+    /**
+     * Reference to this Guacamole.StringWriter.
+     * @private
+     */
+    var guac_writer = this;
+
+    /**
+     * Wrapped Guacamole.ArrayBufferWriter.
+     * @private
+     * @type {Guacamole.ArrayBufferWriter}
+     */
+    var array_writer = new Guacamole.ArrayBufferWriter(stream);
+
+    /**
+     * Internal buffer for UTF-8 output.
+     * @private
+     */
+    var buffer = new Uint8Array(8192);
+
+    /**
+     * The number of bytes currently in the buffer.
+     * @private
+     */
+    var length = 0;
+
+    // Simply call onack for acknowledgements
+    array_writer.onack = function(status) {
+        if (guac_writer.onack)
+            guac_writer.onack(status);
+    };
+
+    /**
+     * Expands the size of the underlying buffer by the given number of bytes,
+     * updating the length appropriately.
+     * 
+     * @private
+     * @param {Number} bytes The number of bytes to add to the underlying
+     *                       buffer.
+     */
+    function __expand(bytes) {
+
+        // Resize buffer if more space needed
+        if (length+bytes >= buffer.length) {
+            var new_buffer = new Uint8Array((length+bytes)*2);
+            new_buffer.set(buffer);
+            buffer = new_buffer;
+        }
+
+        length += bytes;
+
+    }
+
+    /**
+     * Appends a single Unicode character to the current buffer, resizing the
+     * buffer if necessary. The character will be encoded as UTF-8.
+     * 
+     * @private
+     * @param {Number} codepoint The codepoint of the Unicode character to
+     *                           append.
+     */
+    function __append_utf8(codepoint) {
+
+        var mask;
+        var bytes;
+
+        // 1 byte
+        if (codepoint <= 0x7F) {
+            mask = 0x00;
+            bytes = 1;
+        }
+
+        // 2 byte
+        else if (codepoint <= 0x7FF) {
+            mask = 0xC0;
+            bytes = 2;
+        }
+
+        // 3 byte
+        else if (codepoint <= 0xFFFF) {
+            mask = 0xE0;
+            bytes = 3;
+        }
+
+        // 4 byte
+        else if (codepoint <= 0x1FFFFF) {
+            mask = 0xF0;
+            bytes = 4;
+        }
+
+        // If invalid codepoint, append replacement character
+        else {
+            __append_utf8(0xFFFD);
+            return;
+        }
+
+        // Offset buffer by size
+        __expand(bytes);
+        var offset = length - 1;
+
+        // Add trailing bytes, if any
+        for (var i=1; i<bytes; i++) {
+            buffer[offset--] = 0x80 | (codepoint & 0x3F);
+            codepoint >>= 6;
+        }
+
+        // Set initial byte
+        buffer[offset] = mask | codepoint;
+
+    }
+
+    /**
+     * Encodes the given string as UTF-8, returning an ArrayBuffer containing
+     * the resulting bytes.
+     * 
+     * @private
+     * @param {String} text The string to encode as UTF-8.
+     * @return {Uint8Array} The encoded UTF-8 data.
+     */
+    function __encode_utf8(text) {
+
+        // Fill buffer with UTF-8
+        for (var i=0; i<text.length; i++) {
+            var codepoint = text.charCodeAt(i);
+            __append_utf8(codepoint);
+        }
+
+        // Flush buffer
+        if (length > 0) {
+            var out_buffer = buffer.subarray(0, length);
+            length = 0;
+            return out_buffer;
+        }
+
+    }
+
+    /**
+     * Sends the given text.
+     * 
+     * @param {String} text The text to send.
+     */
+    this.sendText = function(text) {
+        array_writer.sendData(__encode_utf8(text));
+    };
+
+    /**
+     * Signals that no further text will be sent, effectively closing the
+     * stream.
+     */
+    this.sendEnd = function() {
+        array_writer.sendEnd();
+    };
+
+    /**
+     * Fired for received data, if acknowledged by the server.
+     * @event
+     * @param {Guacamole.Status} status The status of the operation.
+     */
+    this.onack = null;
+
+};
\ No newline at end of file
diff --git a/guacamole-common-js/src/main/webapp/modules/Tunnel.js b/guacamole-common-js/src/main/webapp/modules/Tunnel.js
new file mode 100644
index 0000000..b98f504
--- /dev/null
+++ b/guacamole-common-js/src/main/webapp/modules/Tunnel.js
@@ -0,0 +1,1003 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+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;
+
+    /**
+     * 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;
+
+};
+
+/**
+ * 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_uuid;
+
+    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();
+
+            tunnel.state = Guacamole.Tunnel.State.OPEN;
+            if (tunnel.onstatechange)
+                tunnel.onstatechange(tunnel.state);
+
+        };
+
+        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();
+
+                    // 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;
+
+            } 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;
+            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();
diff --git a/guacamole-common-js/src/main/webapp/modules/Version.js b/guacamole-common-js/src/main/webapp/modules/Version.js
new file mode 100644
index 0000000..2d7f21c
--- /dev/null
+++ b/guacamole-common-js/src/main/webapp/modules/Version.js
@@ -0,0 +1,33 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+var Guacamole = Guacamole || {};
+
+/**
+ * The unique ID of this version of the Guacamole JavaScript API. This ID will
+ * be the version string of the guacamole-common-js Maven project, and can be
+ * used in downstream applications as a sanity check that the proper version
+ * of the APIs is being used (in case an older version is cached, for example).
+ *
+ * @type {String}
+ */
+Guacamole.API_VERSION = "0.9.9";
diff --git a/guacamole-common-js/src/main/webapp/modules/VideoPlayer.js b/guacamole-common-js/src/main/webapp/modules/VideoPlayer.js
new file mode 100644
index 0000000..5722ac8
--- /dev/null
+++ b/guacamole-common-js/src/main/webapp/modules/VideoPlayer.js
@@ -0,0 +1,111 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+var Guacamole = Guacamole || {};
+
+/**
+ * Abstract video player which accepts, queues and plays back arbitrary video
+ * data. It is up to implementations of this class to provide some means of
+ * handling a provided Guacamole.InputStream and rendering the received data to
+ * the provided Guacamole.Display.VisibleLayer. Data received along the
+ * provided stream is to be played back immediately.
+ *
+ * @constructor
+ */
+Guacamole.VideoPlayer = function VideoPlayer() {
+
+    /**
+     * Notifies this Guacamole.VideoPlayer that all video up to the current
+     * point in time has been given via the underlying stream, and that any
+     * difference in time between queued video data and the current time can be
+     * considered latency.
+     */
+    this.sync = function sync() {
+        // Default implementation - do nothing
+    };
+
+};
+
+/**
+ * Determines whether the given mimetype is supported by any built-in
+ * implementation of Guacamole.VideoPlayer, and thus will be properly handled
+ * by Guacamole.VideoPlayer.getInstance().
+ *
+ * @param {String} mimetype
+ *     The mimetype to check.
+ *
+ * @returns {Boolean}
+ *     true if the given mimetype is supported by any built-in
+ *     Guacamole.VideoPlayer, false otherwise.
+ */
+Guacamole.VideoPlayer.isSupportedType = function isSupportedType(mimetype) {
+
+    // There are currently no built-in video players (and therefore no
+    // supported types)
+    return false;
+
+};
+
+/**
+ * Returns a list of all mimetypes supported by any built-in
+ * Guacamole.VideoPlayer, in rough order of priority. Beware that only the core
+ * mimetypes themselves will be listed. Any mimetype parameters, even required
+ * ones, will not be included in the list.
+ *
+ * @returns {String[]}
+ *     A list of all mimetypes supported by any built-in Guacamole.VideoPlayer,
+ *     excluding any parameters.
+ */
+Guacamole.VideoPlayer.getSupportedTypes = function getSupportedTypes() {
+
+    // There are currently no built-in video players (and therefore no
+    // supported types)
+    return [];
+
+};
+
+/**
+ * Returns an instance of Guacamole.VideoPlayer providing support for the given
+ * video format. If support for the given video format is not available, null
+ * is returned.
+ *
+ * @param {Guacamole.InputStream} stream
+ *     The Guacamole.InputStream to read video data from.
+ *
+ * @param {Guacamole.Display.VisibleLayer} layer
+ *     The destination layer in which this Guacamole.VideoPlayer should play
+ *     the received video data.
+ *
+ * @param {String} mimetype
+ *     The mimetype of the video data in the provided stream.
+ *
+ * @return {Guacamole.VideoPlayer}
+ *     A Guacamole.VideoPlayer instance supporting the given mimetype and
+ *     reading from the given stream, or null if support for the given mimetype
+ *     is absent.
+ */
+Guacamole.VideoPlayer.getInstance = function getInstance(stream, layer, mimetype) {
+
+    // There are currently no built-in video players
+    return null;
+
+};
diff --git a/guacamole-common-js/static.xml b/guacamole-common-js/static.xml
index d3903dc..e88abdc 100644
--- a/guacamole-common-js/static.xml
+++ b/guacamole-common-js/static.xml
@@ -6,7 +6,17 @@
     </formats>
     <fileSets>
         <fileSet>
-            <directory>src/main/resources</directory>
+            <directory>src/main/webapp/modules/</directory>
+            <includes>
+                <include>*.js</include>
+            </includes>
+            <outputDirectory>modules/</outputDirectory>
+        </fileSet>
+        <fileSet>
+            <directory>target/${project.name}-${project.version}/</directory>
+            <includes>
+                <include>*.js</include>
+            </includes>
             <outputDirectory></outputDirectory>
         </fileSet>
     </fileSets>
diff --git a/guacamole-common/LICENSE b/guacamole-common/LICENSE
index 7714141..540cdcf 100644
--- a/guacamole-common/LICENSE
+++ b/guacamole-common/LICENSE
@@ -1,470 +1,19 @@
-                          MOZILLA PUBLIC LICENSE
-                                Version 1.1
-
-                              ---------------
-
-1. Definitions.
-
-     1.0.1. "Commercial Use" means distribution or otherwise making the
-     Covered Code available to a third party.
-
-     1.1. "Contributor" means each entity that creates or contributes to
-     the creation of Modifications.
-
-     1.2. "Contributor Version" means the combination of the Original
-     Code, prior Modifications used by a Contributor, and the Modifications
-     made by that particular Contributor.
-
-     1.3. "Covered Code" means the Original Code or Modifications or the
-     combination of the Original Code and Modifications, in each case
-     including portions thereof.
-
-     1.4. "Electronic Distribution Mechanism" means a mechanism generally
-     accepted in the software development community for the electronic
-     transfer of data.
-
-     1.5. "Executable" means Covered Code in any form other than Source
-     Code.
-
-     1.6. "Initial Developer" means the individual or entity identified
-     as the Initial Developer in the Source Code notice required by Exhibit
-     A.
-
-     1.7. "Larger Work" means a work which combines Covered Code or
-     portions thereof with code not governed by the terms of this License.
-
-     1.8. "License" means this document.
-
-     1.8.1. "Licensable" means having the right to grant, to the maximum
-     extent possible, whether at the time of the initial grant or
-     subsequently acquired, any and all of the rights conveyed herein.
-
-     1.9. "Modifications" means any addition to or deletion from the
-     substance or structure of either the Original Code or any previous
-     Modifications. When Covered Code is released as a series of files, a
-     Modification is:
-          A. Any addition to or deletion from the contents of a file
-          containing Original Code or previous Modifications.
-
-          B. Any new file that contains any part of the Original Code or
-          previous Modifications.
-
-     1.10. "Original Code" means Source Code of computer software code
-     which is described in the Source Code notice required by Exhibit A as
-     Original Code, and which, at the time of its release under this
-     License is not already Covered Code governed by this License.
-
-     1.10.1. "Patent Claims" means any patent claim(s), now owned or
-     hereafter acquired, including without limitation,  method, process,
-     and apparatus claims, in any patent Licensable by grantor.
-
-     1.11. "Source Code" means the preferred form of the Covered Code for
-     making modifications to it, including all modules it contains, plus
-     any associated interface definition files, scripts used to control
-     compilation and installation of an Executable, or source code
-     differential comparisons against either the Original Code or another
-     well known, available Covered Code of the Contributor's choice. The
-     Source Code can be in a compressed or archival form, provided the
-     appropriate decompression or de-archiving software is widely available
-     for no charge.
-
-     1.12. "You" (or "Your")  means an individual or a legal entity
-     exercising rights under, and complying with all of the terms of, this
-     License or a future version of this License issued under Section 6.1.
-     For legal entities, "You" includes any entity which controls, is
-     controlled by, or is under common control with You. For purposes of
-     this definition, "control" means (a) the power, direct or indirect,
-     to cause the direction or management of such entity, whether by
-     contract or otherwise, or (b) ownership of more than fifty percent
-     (50%) of the outstanding shares or beneficial ownership of such
-     entity.
-
-2. Source Code License.
-
-     2.1. The Initial Developer Grant.
-     The Initial Developer hereby grants You a world-wide, royalty-free,
-     non-exclusive license, subject to third party intellectual property
-     claims:
-          (a)  under intellectual property rights (other than patent or
-          trademark) Licensable by Initial Developer to use, reproduce,
-          modify, display, perform, sublicense and distribute the Original
-          Code (or portions thereof) with or without Modifications, and/or
-          as part of a Larger Work; and
-
-          (b) under Patents Claims infringed by the making, using or
-          selling of Original Code, to make, have made, use, practice,
-          sell, and offer for sale, and/or otherwise dispose of the
-          Original Code (or portions thereof).
-
-          (c) the licenses granted in this Section 2.1(a) and (b) are
-          effective on the date Initial Developer first distributes
-          Original Code under the terms of this License.
-
-          (d) Notwithstanding Section 2.1(b) above, no patent license is
-          granted: 1) for code that You delete from the Original Code; 2)
-          separate from the Original Code;  or 3) for infringements caused
-          by: i) the modification of the Original Code or ii) the
-          combination of the Original Code with other software or devices.
-
-     2.2. Contributor Grant.
-     Subject to third party intellectual property claims, each Contributor
-     hereby grants You a world-wide, royalty-free, non-exclusive license
-
-          (a)  under intellectual property rights (other than patent or
-          trademark) Licensable by Contributor, to use, reproduce, modify,
-          display, perform, sublicense and distribute the Modifications
-          created by such Contributor (or portions thereof) either on an
-          unmodified basis, with other Modifications, as Covered Code
-          and/or as part of a Larger Work; and
-
-          (b) under Patent Claims infringed by the making, using, or
-          selling of  Modifications made by that Contributor either alone
-          and/or in combination with its Contributor Version (or portions
-          of such combination), to make, use, sell, offer for sale, have
-          made, and/or otherwise dispose of: 1) Modifications made by that
-          Contributor (or portions thereof); and 2) the combination of
-          Modifications made by that Contributor with its Contributor
-          Version (or portions of such combination).
-
-          (c) the licenses granted in Sections 2.2(a) and 2.2(b) are
-          effective on the date Contributor first makes Commercial Use of
-          the Covered Code.
-
-          (d)    Notwithstanding Section 2.2(b) above, no patent license is
-          granted: 1) for any code that Contributor has deleted from the
-          Contributor Version; 2)  separate from the Contributor Version;
-          3)  for infringements caused by: i) third party modifications of
-          Contributor Version or ii)  the combination of Modifications made
-          by that Contributor with other software  (except as part of the
-          Contributor Version) or other devices; or 4) under Patent Claims
-          infringed by Covered Code in the absence of Modifications made by
-          that Contributor.
-
-3. Distribution Obligations.
-
-     3.1. Application of License.
-     The Modifications which You create or to which You contribute are
-     governed by the terms of this License, including without limitation
-     Section 2.2. The Source Code version of Covered Code may be
-     distributed only under the terms of this License or a future version
-     of this License released under Section 6.1, and You must include a
-     copy of this License with every copy of the Source Code You
-     distribute. You may not offer or impose any terms on any Source Code
-     version that alters or restricts the applicable version of this
-     License or the recipients' rights hereunder. However, You may include
-     an additional document offering the additional rights described in
-     Section 3.5.
-
-     3.2. Availability of Source Code.
-     Any Modification which You create or to which You contribute must be
-     made available in Source Code form under the terms of this License
-     either on the same media as an Executable version or via an accepted
-     Electronic Distribution Mechanism to anyone to whom you made an
-     Executable version available; and if made available via Electronic
-     Distribution Mechanism, must remain available for at least twelve (12)
-     months after the date it initially became available, or at least six
-     (6) months after a subsequent version of that particular Modification
-     has been made available to such recipients. You are responsible for
-     ensuring that the Source Code version remains available even if the
-     Electronic Distribution Mechanism is maintained by a third party.
-
-     3.3. Description of Modifications.
-     You must cause all Covered Code to which You contribute to contain a
-     file documenting the changes You made to create that Covered Code and
-     the date of any change. You must include a prominent statement that
-     the Modification is derived, directly or indirectly, from Original
-     Code provided by the Initial Developer and including the name of the
-     Initial Developer in (a) the Source Code, and (b) in any notice in an
-     Executable version or related documentation in which You describe the
-     origin or ownership of the Covered Code.
-
-     3.4. Intellectual Property Matters
-          (a) Third Party Claims.
-          If Contributor has knowledge that a license under a third party's
-          intellectual property rights is required to exercise the rights
-          granted by such Contributor under Sections 2.1 or 2.2,
-          Contributor must include a text file with the Source Code
-          distribution titled "LEGAL" which describes the claim and the
-          party making the claim in sufficient detail that a recipient will
-          know whom to contact. If Contributor obtains such knowledge after
-          the Modification is made available as described in Section 3.2,
-          Contributor shall promptly modify the LEGAL file in all copies
-          Contributor makes available thereafter and shall take other steps
-          (such as notifying appropriate mailing lists or newsgroups)
-          reasonably calculated to inform those who received the Covered
-          Code that new knowledge has been obtained.
-
-          (b) Contributor APIs.
-          If Contributor's Modifications include an application programming
-          interface and Contributor has knowledge of patent licenses which
-          are reasonably necessary to implement that API, Contributor must
-          also include this information in the LEGAL file.
-
-               (c)    Representations.
-          Contributor represents that, except as disclosed pursuant to
-          Section 3.4(a) above, Contributor believes that Contributor's
-          Modifications are Contributor's original creation(s) and/or
-          Contributor has sufficient rights to grant the rights conveyed by
-          this License.
-
-     3.5. Required Notices.
-     You must duplicate the notice in Exhibit A in each file of the Source
-     Code.  If it is not possible to put such notice in a particular Source
-     Code file due to its structure, then You must include such notice in a
-     location (such as a relevant directory) where a user would be likely
-     to look for such a notice.  If You created one or more Modification(s)
-     You may add your name as a Contributor to the notice described in
-     Exhibit A.  You must also duplicate this License in any documentation
-     for the Source Code where You describe recipients' rights or ownership
-     rights relating to Covered Code.  You may choose to offer, and to
-     charge a fee for, warranty, support, indemnity or liability
-     obligations to one or more recipients of Covered Code. However, You
-     may do so only on Your own behalf, and not on behalf of the Initial
-     Developer or any Contributor. You must make it absolutely clear than
-     any such warranty, support, indemnity or liability obligation is
-     offered by You alone, and You hereby agree to indemnify the Initial
-     Developer and every Contributor for any liability incurred by the
-     Initial Developer or such Contributor as a result of warranty,
-     support, indemnity or liability terms You offer.
-
-     3.6. Distribution of Executable Versions.
-     You may distribute Covered Code in Executable form only if the
-     requirements of Section 3.1-3.5 have been met for that Covered Code,
-     and if You include a notice stating that the Source Code version of
-     the Covered Code is available under the terms of this License,
-     including a description of how and where You have fulfilled the
-     obligations of Section 3.2. The notice must be conspicuously included
-     in any notice in an Executable version, related documentation or
-     collateral in which You describe recipients' rights relating to the
-     Covered Code. You may distribute the Executable version of Covered
-     Code or ownership rights under a license of Your choice, which may
-     contain terms different from this License, provided that You are in
-     compliance with the terms of this License and that the license for the
-     Executable version does not attempt to limit or alter the recipient's
-     rights in the Source Code version from the rights set forth in this
-     License. If You distribute the Executable version under a different
-     license You must make it absolutely clear that any terms which differ
-     from this License are offered by You alone, not by the Initial
-     Developer or any Contributor. You hereby agree to indemnify the
-     Initial Developer and every Contributor for any liability incurred by
-     the Initial Developer or such Contributor as a result of any such
-     terms You offer.
-
-     3.7. Larger Works.
-     You may create a Larger Work by combining Covered Code with other code
-     not governed by the terms of this License and distribute the Larger
-     Work as a single product. In such a case, You must make sure the
-     requirements of this License are fulfilled for the Covered Code.
-
-4. Inability to Comply Due to Statute or Regulation.
-
-     If it is impossible for You to comply with any of the terms of this
-     License with respect to some or all of the Covered Code due to
-     statute, judicial order, or regulation then You must: (a) comply with
-     the terms of this License to the maximum extent possible; and (b)
-     describe the limitations and the code they affect. Such description
-     must be included in the LEGAL file described in Section 3.4 and must
-     be included with all distributions of the Source Code. Except to the
-     extent prohibited by statute or regulation, such description must be
-     sufficiently detailed for a recipient of ordinary skill to be able to
-     understand it.
-
-5. Application of this License.
-
-     This License applies to code to which the Initial Developer has
-     attached the notice in Exhibit A and to related Covered Code.
-
-6. Versions of the License.
-
-     6.1. New Versions.
-     Netscape Communications Corporation ("Netscape") may publish revised
-     and/or new versions of the License from time to time. Each version
-     will be given a distinguishing version number.
-
-     6.2. Effect of New Versions.
-     Once Covered Code has been published under a particular version of the
-     License, You may always continue to use it under the terms of that
-     version. You may also choose to use such Covered Code under the terms
-     of any subsequent version of the License published by Netscape. No one
-     other than Netscape has the right to modify the terms applicable to
-     Covered Code created under this License.
-
-     6.3. Derivative Works.
-     If You create or use a modified version of this License (which you may
-     only do in order to apply it to code which is not already Covered Code
-     governed by this License), You must (a) rename Your license so that
-     the phrases "Mozilla", "MOZILLAPL", "MOZPL", "Netscape",
-     "MPL", "NPL" or any confusingly similar phrase do not appear in your
-     license (except to note that your license differs from this License)
-     and (b) otherwise make it clear that Your version of the license
-     contains terms which differ from the Mozilla Public License and
-     Netscape Public License. (Filling in the name of the Initial
-     Developer, Original Code or Contributor in the notice described in
-     Exhibit A shall not of themselves be deemed to be modifications of
-     this License.)
-
-7. DISCLAIMER OF WARRANTY.
-
-     COVERED CODE IS PROVIDED UNDER THIS LICENSE ON AN "AS IS" BASIS,
-     WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING,
-     WITHOUT LIMITATION, WARRANTIES THAT THE COVERED CODE IS FREE OF
-     DEFECTS, MERCHANTABLE, FIT FOR A PARTICULAR PURPOSE OR NON-INFRINGING.
-     THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE COVERED CODE
-     IS WITH YOU. SHOULD ANY COVERED CODE PROVE DEFECTIVE IN ANY RESPECT,
-     YOU (NOT THE INITIAL DEVELOPER OR ANY OTHER CONTRIBUTOR) ASSUME THE
-     COST OF ANY NECESSARY SERVICING, REPAIR OR CORRECTION. THIS DISCLAIMER
-     OF WARRANTY CONSTITUTES AN ESSENTIAL PART OF THIS LICENSE. NO USE OF
-     ANY COVERED CODE IS AUTHORIZED HEREUNDER EXCEPT UNDER THIS DISCLAIMER.
-
-8. TERMINATION.
-
-     8.1.  This License and the rights granted hereunder will terminate
-     automatically if You fail to comply with terms herein and fail to cure
-     such breach within 30 days of becoming aware of the breach. All
-     sublicenses to the Covered Code which are properly granted shall
-     survive any termination of this License. Provisions which, by their
-     nature, must remain in effect beyond the termination of this License
-     shall survive.
-
-     8.2.  If You initiate litigation by asserting a patent infringement
-     claim (excluding declatory judgment actions) against Initial Developer
-     or a Contributor (the Initial Developer or Contributor against whom
-     You file such action is referred to as "Participant")  alleging that:
-
-     (a)  such Participant's Contributor Version directly or indirectly
-     infringes any patent, then any and all rights granted by such
-     Participant to You under Sections 2.1 and/or 2.2 of this License
-     shall, upon 60 days notice from Participant terminate prospectively,
-     unless if within 60 days after receipt of notice You either: (i)
-     agree in writing to pay Participant a mutually agreeable reasonable
-     royalty for Your past and future use of Modifications made by such
-     Participant, or (ii) withdraw Your litigation claim with respect to
-     the Contributor Version against such Participant.  If within 60 days
-     of notice, a reasonable royalty and payment arrangement are not
-     mutually agreed upon in writing by the parties or the litigation claim
-     is not withdrawn, the rights granted by Participant to You under
-     Sections 2.1 and/or 2.2 automatically terminate at the expiration of
-     the 60 day notice period specified above.
-
-     (b)  any software, hardware, or device, other than such Participant's
-     Contributor Version, directly or indirectly infringes any patent, then
-     any rights granted to You by such Participant under Sections 2.1(b)
-     and 2.2(b) are revoked effective as of the date You first made, used,
-     sold, distributed, or had made, Modifications made by that
-     Participant.
-
-     8.3.  If You assert a patent infringement claim against Participant
-     alleging that such Participant's Contributor Version directly or
-     indirectly infringes any patent where such claim is resolved (such as
-     by license or settlement) prior to the initiation of patent
-     infringement litigation, then the reasonable value of the licenses
-     granted by such Participant under Sections 2.1 or 2.2 shall be taken
-     into account in determining the amount or value of any payment or
-     license.
-
-     8.4.  In the event of termination under Sections 8.1 or 8.2 above,
-     all end user license agreements (excluding distributors and resellers)
-     which have been validly granted by You or any distributor hereunder
-     prior to termination shall survive termination.
-
-9. LIMITATION OF LIABILITY.
-
-     UNDER NO CIRCUMSTANCES AND UNDER NO LEGAL THEORY, WHETHER TORT
-     (INCLUDING NEGLIGENCE), CONTRACT, OR OTHERWISE, SHALL YOU, THE INITIAL
-     DEVELOPER, ANY OTHER CONTRIBUTOR, OR ANY DISTRIBUTOR OF COVERED CODE,
-     OR ANY SUPPLIER OF ANY OF SUCH PARTIES, BE LIABLE TO ANY PERSON FOR
-     ANY INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES OF ANY
-     CHARACTER INCLUDING, WITHOUT LIMITATION, DAMAGES FOR LOSS OF GOODWILL,
-     WORK STOPPAGE, COMPUTER FAILURE OR MALFUNCTION, OR ANY AND ALL OTHER
-     COMMERCIAL DAMAGES OR LOSSES, EVEN IF SUCH PARTY SHALL HAVE BEEN
-     INFORMED OF THE POSSIBILITY OF SUCH DAMAGES. THIS LIMITATION OF
-     LIABILITY SHALL NOT APPLY TO LIABILITY FOR DEATH OR PERSONAL INJURY
-     RESULTING FROM SUCH PARTY'S NEGLIGENCE TO THE EXTENT APPLICABLE LAW
-     PROHIBITS SUCH LIMITATION. SOME JURISDICTIONS DO NOT ALLOW THE
-     EXCLUSION OR LIMITATION OF INCIDENTAL OR CONSEQUENTIAL DAMAGES, SO
-     THIS EXCLUSION AND LIMITATION MAY NOT APPLY TO YOU.
-
-10. U.S. GOVERNMENT END USERS.
-
-     The Covered Code is a "commercial item," as that term is defined in
-     48 C.F.R. 2.101 (Oct. 1995), consisting of "commercial computer
-     software" and "commercial computer software documentation," as such
-     terms are used in 48 C.F.R. 12.212 (Sept. 1995). Consistent with 48
-     C.F.R. 12.212 and 48 C.F.R. 227.7202-1 through 227.7202-4 (June 1995),
-     all U.S. Government End Users acquire Covered Code with only those
-     rights set forth herein.
-
-11. MISCELLANEOUS.
-
-     This License represents the complete agreement concerning subject
-     matter hereof. If any provision of this License is held to be
-     unenforceable, such provision shall be reformed only to the extent
-     necessary to make it enforceable. This License shall be governed by
-     California law provisions (except to the extent applicable law, if
-     any, provides otherwise), excluding its conflict-of-law provisions.
-     With respect to disputes in which at least one party is a citizen of,
-     or an entity chartered or registered to do business in the United
-     States of America, any litigation relating to this License shall be
-     subject to the jurisdiction of the Federal Courts of the Northern
-     District of California, with venue lying in Santa Clara County,
-     California, with the losing party responsible for costs, including
-     without limitation, court costs and reasonable attorneys' fees and
-     expenses. The application of the United Nations Convention on
-     Contracts for the International Sale of Goods is expressly excluded.
-     Any law or regulation which provides that the language of a contract
-     shall be construed against the drafter shall not apply to this
-     License.
-
-12. RESPONSIBILITY FOR CLAIMS.
-
-     As between Initial Developer and the Contributors, each party is
-     responsible for claims and damages arising, directly or indirectly,
-     out of its utilization of rights under this License and You agree to
-     work with Initial Developer and Contributors to distribute such
-     responsibility on an equitable basis. Nothing herein is intended or
-     shall be deemed to constitute any admission of liability.
-
-13. MULTIPLE-LICENSED CODE.
-
-     Initial Developer may designate portions of the Covered Code as
-     "Multiple-Licensed".  "Multiple-Licensed" means that the Initial
-     Developer permits you to utilize portions of the Covered Code under
-     Your choice of the NPL or the alternative licenses, if any, specified
-     by the Initial Developer in the file described in Exhibit A.
-
-EXHIBIT A -Mozilla Public License.
-
-     ``The contents of this file are subject to the Mozilla Public License
-     Version 1.1 (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.mozilla.org/MPL/
-
-     Software distributed under the License is distributed on an "AS IS"
-     basis, WITHOUT WARRANTY OF ANY KIND, either express or implied. See the
-     License for the specific language governing rights and limitations
-     under the License.
-
-     The Original Code is ______________________________________.
-
-     The Initial Developer of the Original Code is ________________________.
-     Portions created by ______________________ are Copyright (C) ______
-     _______________________. All Rights Reserved.
-
-     Contributor(s): ______________________________________.
-
-     Alternatively, the contents of this file may be used under the terms
-     of the _____ license (the  "[___] License"), in which case the
-     provisions of [______] License are applicable instead of those
-     above.  If you wish to allow use of your version of this file only
-     under the terms of the [____] License and not to allow others to use
-     your version of this file under the MPL, indicate your decision by
-     deleting  the provisions above and replace  them with the notice and
-     other provisions required by the [___] License.  If you do not delete
-     the provisions above, a recipient may use your version of this file
-     under either the MPL or the [___] License."
-
-     [NOTE: The text of this Exhibit A may differ slightly from the text of
-     the notices in the Source Code files of the Original Code. You should
-     use the text of this Exhibit A rather than the text found in the
-     Original Code Source Code for Your Modifications.]
-
+Copyright (C) 2013 Glyptodon LLC
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/guacamole-common/doc/example/ExampleTunnelServlet.java b/guacamole-common/doc/example/ExampleTunnelServlet.java
index 00f4098..88fd57b 100644
--- a/guacamole-common/doc/example/ExampleTunnelServlet.java
+++ b/guacamole-common/doc/example/ExampleTunnelServlet.java
@@ -1,6 +1,26 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
 
 import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpSession;
 import net.sourceforge.guacamole.GuacamoleException;
 import net.sourceforge.guacamole.properties.GuacamoleProperties;
 import net.sourceforge.guacamole.net.GuacamoleSocket;
@@ -8,7 +28,6 @@ import net.sourceforge.guacamole.net.GuacamoleTunnel;
 import net.sourceforge.guacamole.net.InetGuacamoleSocket;
 import net.sourceforge.guacamole.protocol.GuacamoleConfiguration;
 import net.sourceforge.guacamole.protocol.ConfiguredGuacamoleSocket;
-import net.sourceforge.guacamole.servlet.GuacamoleSession;
 import net.sourceforge.guacamole.servlet.GuacamoleHTTPTunnelServlet;
 
 public class ExampleTunnelServlet extends GuacamoleHTTPTunnelServlet {
@@ -17,8 +36,6 @@ public class ExampleTunnelServlet extends GuacamoleHTTPTunnelServlet {
     protected GuacamoleTunnel doConnect(HttpServletRequest request)
         throws GuacamoleException {
 
-        HttpSession httpSession = request.getSession(true);
-
         String hostname = GuacamoleProperties.getProperty(
             GuacamoleProperties.GUACD_HOSTNAME);
 
@@ -36,12 +53,8 @@ public class ExampleTunnelServlet extends GuacamoleHTTPTunnelServlet {
                 config
         );
 
+        // Create and return tunnel
         GuacamoleTunnel tunnel = new GuacamoleTunnel(socket);
-
-        // Attach tunnel
-        GuacamoleSession session = new GuacamoleSession(httpSession);
-        session.attachTunnel(tunnel);
-
         return tunnel;
 
     }
diff --git a/guacamole-common/pom.xml b/guacamole-common/pom.xml
index a5b6223..df5ec7f 100644
--- a/guacamole-common/pom.xml
+++ b/guacamole-common/pom.xml
@@ -5,7 +5,7 @@
     <groupId>org.glyptodon.guacamole</groupId>
     <artifactId>guacamole-common</artifactId>
     <packaging>jar</packaging>
-    <version>0.8.0</version>
+    <version>0.9.9</version>
     <name>guacamole-common</name>
     <url>http://guac-dev.org/</url>
 
@@ -17,18 +17,8 @@
     <!-- All applicable licenses -->
     <licenses>
         <license>
-            <name>Mozilla Public License Version 1.1</name>
-            <url>http://www.mozilla.org/MPL/1.1/</url>
-            <distribution>repo</distribution>
-        </license>
-        <license>
-            <name>GNU General Public License, version 2</name>
-            <url>http://www.gnu.org/licenses/gpl-2.0.html</url>
-            <distribution>repo</distribution>
-        </license>
-        <license>
-            <name>GNU Lesser General Public License, version 2.1</name>
-            <url>http://www.gnu.org/licenses/lgpl-2.1.html</url>
+            <name>The MIT License</name>
+            <url>http://www.opensource.org/licenses/mit-license.php</url>
             <distribution>repo</distribution>
         </license>
     </licenses>
@@ -64,9 +54,15 @@
             <plugin>
                 <groupId>org.apache.maven.plugins</groupId>
                 <artifactId>maven-compiler-plugin</artifactId>
+                <version>3.3</version>
                 <configuration>
                     <source>1.6</source>
                     <target>1.6</target>
+                    <compilerArgs>
+                        <arg>-Xlint:all</arg>
+                        <arg>-Werror</arg>
+                    </compilerArgs>
+                    <fork>true</fork>
                 </configuration>
             </plugin>
 
@@ -74,6 +70,7 @@
             <plugin>
                 <groupId>org.apache.maven.plugins</groupId>
                 <artifactId>maven-source-plugin</artifactId>
+                <version>2.4</version>
                 <executions>
                     <execution>
                         <id>attach-sources</id>
@@ -88,6 +85,7 @@
             <plugin>
                 <groupId>org.apache.maven.plugins</groupId>
                 <artifactId>maven-javadoc-plugin</artifactId>
+                <version>2.10.3</version>
                 <configuration>
                     <detectOfflineLinks>false</detectOfflineLinks>
                 </configuration>
@@ -114,13 +112,29 @@
             <scope>provided</scope>
         </dependency>
 
+        <!-- JSR 356 WebSocket API -->
+        <dependency>
+            <groupId>javax.websocket</groupId>
+            <artifactId>javax.websocket-api</artifactId>
+            <version>1.0</version>
+            <scope>provided</scope>
+        </dependency>
+
         <!-- SLF4J - logging -->
         <dependency>
             <groupId>org.slf4j</groupId>
             <artifactId>slf4j-api</artifactId>
-            <version>1.6.1</version>
+            <version>1.7.7</version>
         </dependency>
-        
+
+        <!-- JUnit -->
+        <dependency>
+            <groupId>junit</groupId>
+            <artifactId>junit</artifactId>
+            <version>4.10</version>
+            <scope>test</scope>
+        </dependency>
+
     </dependencies>
 
 </project>
diff --git a/guacamole-common/src/main/java/org/glyptodon/guacamole/GuacamoleClientBadTypeException.java b/guacamole-common/src/main/java/org/glyptodon/guacamole/GuacamoleClientBadTypeException.java
new file mode 100644
index 0000000..9bb9ac1
--- /dev/null
+++ b/guacamole-common/src/main/java/org/glyptodon/guacamole/GuacamoleClientBadTypeException.java
@@ -0,0 +1,71 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole;
+
+import org.glyptodon.guacamole.protocol.GuacamoleStatus;
+
+
+/**
+ * An exception which is thrown when data has been submitted with an unsupported
+ * mimetype.
+ * 
+ * @author Michael Jumper
+ */
+public class GuacamoleClientBadTypeException extends GuacamoleClientException {
+
+    /**
+     * Creates a new GuacamoleClientBadTypeException with the given message and cause.
+     *
+     * @param message A human readable description of the exception that
+     *                occurred.
+     * @param cause The cause of this exception.
+     */
+    public GuacamoleClientBadTypeException(String message, Throwable cause) {
+        super(message, cause);
+    }
+
+    /**
+     * Creates a new GuacamoleClientBadTypeException with the given message.
+     *
+     * @param message A human readable description of the exception that
+     *                occurred.
+     */
+    public GuacamoleClientBadTypeException(String message) {
+        super(message);
+    }
+
+    /**
+     * Creates a new GuacamoleClientBadTypeException with the given cause.
+     *
+     * @param cause The cause of this exception.
+     */
+    public GuacamoleClientBadTypeException(Throwable cause) {
+        super(cause);
+    }
+
+    @Override
+    public GuacamoleStatus getStatus() {
+        return GuacamoleStatus.CLIENT_BAD_TYPE;
+    }
+
+}
diff --git a/guacamole-common/src/main/java/org/glyptodon/guacamole/GuacamoleClientException.java b/guacamole-common/src/main/java/org/glyptodon/guacamole/GuacamoleClientException.java
index bf98aa3..667f416 100644
--- a/guacamole-common/src/main/java/org/glyptodon/guacamole/GuacamoleClientException.java
+++ b/guacamole-common/src/main/java/org/glyptodon/guacamole/GuacamoleClientException.java
@@ -1,41 +1,29 @@
-
-package org.glyptodon.guacamole;
-
-/* ***** BEGIN LICENSE BLOCK *****
- * Version: MPL 1.1/GPL 2.0/LGPL 2.1
- *
- * The contents of this file are subject to the Mozilla Public License Version
- * 1.1 (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.mozilla.org/MPL/
- *
- * Software distributed under the License is distributed on an "AS IS" basis,
- * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- * for the specific language governing rights and limitations under the
- * License.
- *
- * The Original Code is guacamole-common.
- *
- * The Initial Developer of the Original Code is
- * Michael Jumper.
- * Portions created by the Initial Developer are Copyright (C) 2010
- * the Initial Developer. All Rights Reserved.
+/*
+ * Copyright (C) 2013 Glyptodon LLC
  *
- * Contributor(s):
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
  *
- * Alternatively, the contents of this file may be used under the terms of
- * either the GNU General Public License Version 2 or later (the "GPL"), or
- * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
- * in which case the provisions of the GPL or the LGPL are applicable instead
- * of those above. If you wish to allow use of your version of this file only
- * under the terms of either the GPL or the LGPL, and not to allow others to
- * use your version of this file under the terms of the MPL, indicate your
- * decision by deleting the provisions above and replace them with the notice
- * and other provisions required by the GPL or the LGPL. If you do not delete
- * the provisions above, a recipient may use your version of this file under
- * the terms of any one of the MPL, the GPL or the LGPL.
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
  *
- * ***** END LICENSE BLOCK ***** */
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole;
+
+import org.glyptodon.guacamole.protocol.GuacamoleStatus;
+
 
 /**
  * A generic exception thrown when part of the Guacamole API encounters
@@ -76,4 +64,9 @@ public class GuacamoleClientException extends GuacamoleException {
         super(cause);
     }
 
+    @Override
+    public GuacamoleStatus getStatus() {
+        return GuacamoleStatus.CLIENT_BAD_REQUEST;
+    }
+
 }
diff --git a/guacamole-common/src/main/java/org/glyptodon/guacamole/GuacamoleClientOverrunException.java b/guacamole-common/src/main/java/org/glyptodon/guacamole/GuacamoleClientOverrunException.java
new file mode 100644
index 0000000..1e90001
--- /dev/null
+++ b/guacamole-common/src/main/java/org/glyptodon/guacamole/GuacamoleClientOverrunException.java
@@ -0,0 +1,73 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole;
+
+import org.glyptodon.guacamole.protocol.GuacamoleStatus;
+
+
+/**
+ * An exception which is thrown when the client has sent too much data. This
+ * usually indicates that a server-side buffer is not large enough to
+ * accommodate the data, or protocol specifications prohibit data of the size
+ * received.
+ * 
+ * @author Michael Jumper
+ */
+public class GuacamoleClientOverrunException extends GuacamoleClientException {
+
+    /**
+     * Creates a new GuacamoleClientOverrunException with the given message and cause.
+     *
+     * @param message A human readable description of the exception that
+     *                occurred.
+     * @param cause The cause of this exception.
+     */
+    public GuacamoleClientOverrunException(String message, Throwable cause) {
+        super(message, cause);
+    }
+
+    /**
+     * Creates a new GuacamoleClientOverrunException with the given message.
+     *
+     * @param message A human readable description of the exception that
+     *                occurred.
+     */
+    public GuacamoleClientOverrunException(String message) {
+        super(message);
+    }
+
+    /**
+     * Creates a new GuacamoleClientOverrunException with the given cause.
+     *
+     * @param cause The cause of this exception.
+     */
+    public GuacamoleClientOverrunException(Throwable cause) {
+        super(cause);
+    }
+
+    @Override
+    public GuacamoleStatus getStatus() {
+        return GuacamoleStatus.CLIENT_OVERRUN;
+    }
+
+}
diff --git a/guacamole-common/src/main/java/org/glyptodon/guacamole/GuacamoleClientTimeoutException.java b/guacamole-common/src/main/java/org/glyptodon/guacamole/GuacamoleClientTimeoutException.java
new file mode 100644
index 0000000..46378a2
--- /dev/null
+++ b/guacamole-common/src/main/java/org/glyptodon/guacamole/GuacamoleClientTimeoutException.java
@@ -0,0 +1,70 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole;
+
+import org.glyptodon.guacamole.protocol.GuacamoleStatus;
+
+
+/**
+ * An exception which is thrown when the client is taking too long to respond.
+ * 
+ * @author Michael Jumper
+ */
+public class GuacamoleClientTimeoutException extends GuacamoleClientException {
+
+    /**
+     * Creates a new GuacamoleClientTimeoutException with the given message and cause.
+     *
+     * @param message A human readable description of the exception that
+     *                occurred.
+     * @param cause The cause of this exception.
+     */
+    public GuacamoleClientTimeoutException(String message, Throwable cause) {
+        super(message, cause);
+    }
+
+    /**
+     * Creates a new GuacamoleClientTimeoutException with the given message.
+     *
+     * @param message A human readable description of the exception that
+     *                occurred.
+     */
+    public GuacamoleClientTimeoutException(String message) {
+        super(message);
+    }
+
+    /**
+     * Creates a new GuacamoleClientTimeoutException with the given cause.
+     *
+     * @param cause The cause of this exception.
+     */
+    public GuacamoleClientTimeoutException(Throwable cause) {
+        super(cause);
+    }
+
+    @Override
+    public GuacamoleStatus getStatus() {
+        return GuacamoleStatus.CLIENT_TIMEOUT;
+    }
+
+}
diff --git a/guacamole-common/src/main/java/org/glyptodon/guacamole/GuacamoleClientTooManyException.java b/guacamole-common/src/main/java/org/glyptodon/guacamole/GuacamoleClientTooManyException.java
new file mode 100644
index 0000000..91e77c1
--- /dev/null
+++ b/guacamole-common/src/main/java/org/glyptodon/guacamole/GuacamoleClientTooManyException.java
@@ -0,0 +1,72 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole;
+
+import org.glyptodon.guacamole.protocol.GuacamoleStatus;
+
+
+/**
+ * An exception which is thrown when too many requests have been received
+ * by the current client, and further requests are being rejected, either
+ * temporarily or permanently.
+ * 
+ * @author Michael Jumper
+ */
+public class GuacamoleClientTooManyException extends GuacamoleClientException {
+
+    /**
+     * Creates a new GuacamoleClientTooManyException with the given message and cause.
+     *
+     * @param message A human readable description of the exception that
+     *                occurred.
+     * @param cause The cause of this exception.
+     */
+    public GuacamoleClientTooManyException(String message, Throwable cause) {
+        super(message, cause);
+    }
+
+    /**
+     * Creates a new GuacamoleClientTooManyException with the given message.
+     *
+     * @param message A human readable description of the exception that
+     *                occurred.
+     */
+    public GuacamoleClientTooManyException(String message) {
+        super(message);
+    }
+
+    /**
+     * Creates a new GuacamoleClientTooManyException with the given cause.
+     *
+     * @param cause The cause of this exception.
+     */
+    public GuacamoleClientTooManyException(Throwable cause) {
+        super(cause);
+    }
+
+    @Override
+    public GuacamoleStatus getStatus() {
+        return GuacamoleStatus.CLIENT_TOO_MANY;
+    }
+
+}
diff --git a/guacamole-common/src/main/java/org/glyptodon/guacamole/GuacamoleConnectionClosedException.java b/guacamole-common/src/main/java/org/glyptodon/guacamole/GuacamoleConnectionClosedException.java
new file mode 100644
index 0000000..a887282
--- /dev/null
+++ b/guacamole-common/src/main/java/org/glyptodon/guacamole/GuacamoleConnectionClosedException.java
@@ -0,0 +1,72 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole;
+
+import org.glyptodon.guacamole.protocol.GuacamoleStatus;
+
+
+/**
+ * An exception which is thrown when an operation cannot be performed because
+ * its corresponding connection is closed.
+ *
+ * @author Michael Jumper
+ */
+public class GuacamoleConnectionClosedException extends GuacamoleServerException {
+
+    /**
+     * Creates a new GuacamoleConnectionClosedException with the given message
+     * and cause.
+     *
+     * @param message A human readable description of the exception that
+     *                occurred.
+     * @param cause The cause of this exception.
+     */
+    public GuacamoleConnectionClosedException(String message, Throwable cause) {
+        super(message, cause);
+    }
+
+    /**
+     * Creates a new GuacamoleConnectionClosedException with the given message.
+     *
+     * @param message A human readable description of the exception that
+     *                occurred.
+     */
+    public GuacamoleConnectionClosedException(String message) {
+        super(message);
+    }
+
+    /**
+     * Creates a new GuacamoleConnectionClosedException with the given cause.
+     *
+     * @param cause The cause of this exception.
+     */
+    public GuacamoleConnectionClosedException(Throwable cause) {
+        super(cause);
+    }
+
+    @Override
+    public GuacamoleStatus getStatus() {
+        return GuacamoleStatus.SERVER_ERROR;
+    }
+
+}
diff --git a/guacamole-common/src/main/java/org/glyptodon/guacamole/GuacamoleException.java b/guacamole-common/src/main/java/org/glyptodon/guacamole/GuacamoleException.java
index a2eb28a..412c0e9 100644
--- a/guacamole-common/src/main/java/org/glyptodon/guacamole/GuacamoleException.java
+++ b/guacamole-common/src/main/java/org/glyptodon/guacamole/GuacamoleException.java
@@ -1,41 +1,29 @@
-
-package org.glyptodon.guacamole;
-
-/* ***** BEGIN LICENSE BLOCK *****
- * Version: MPL 1.1/GPL 2.0/LGPL 2.1
- *
- * The contents of this file are subject to the Mozilla Public License Version
- * 1.1 (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.mozilla.org/MPL/
+/*
+ * Copyright (C) 2013 Glyptodon LLC
  *
- * Software distributed under the License is distributed on an "AS IS" basis,
- * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- * for the specific language governing rights and limitations under the
- * License.
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
  *
- * The Original Code is guacamole-common.
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
  *
- * The Initial Developer of the Original Code is
- * Michael Jumper.
- * Portions created by the Initial Developer are Copyright (C) 2010
- * the Initial Developer. All Rights Reserved.
- *
- * Contributor(s):
- *
- * Alternatively, the contents of this file may be used under the terms of
- * either the GNU General Public License Version 2 or later (the "GPL"), or
- * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
- * in which case the provisions of the GPL or the LGPL are applicable instead
- * of those above. If you wish to allow use of your version of this file only
- * under the terms of either the GPL or the LGPL, and not to allow others to
- * use your version of this file under the terms of the MPL, indicate your
- * decision by deleting the provisions above and replace them with the notice
- * and other provisions required by the GPL or the LGPL. If you do not delete
- * the provisions above, a recipient may use your version of this file under
- * the terms of any one of the MPL, the GPL or the LGPL.
- *
- * ***** END LICENSE BLOCK ***** */
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole;
+
+import org.glyptodon.guacamole.protocol.GuacamoleStatus;
+
 
 /**
  * A generic exception thrown when parts of the Guacamole API encounter
@@ -44,7 +32,7 @@ package org.glyptodon.guacamole;
  * @author Michael Jumper
  */
 public class GuacamoleException extends Exception {
-
+    
     /**
      * Creates a new GuacamoleException with the given message and cause.
      *
@@ -75,4 +63,15 @@ public class GuacamoleException extends Exception {
         super(cause);
     }
 
+    /**
+     * Returns the Guacamole status associated with this exception. This status
+     * can then be easily translated into an HTTP error code or Guacamole
+     * protocol error code.
+     * 
+     * @return The corresponding Guacamole status.
+     */
+    public GuacamoleStatus getStatus() {
+        return GuacamoleStatus.SERVER_ERROR;
+    }
+    
 }
diff --git a/guacamole-common/src/main/java/org/glyptodon/guacamole/GuacamoleResourceConflictException.java b/guacamole-common/src/main/java/org/glyptodon/guacamole/GuacamoleResourceConflictException.java
new file mode 100644
index 0000000..9138bae
--- /dev/null
+++ b/guacamole-common/src/main/java/org/glyptodon/guacamole/GuacamoleResourceConflictException.java
@@ -0,0 +1,72 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole;
+
+import org.glyptodon.guacamole.protocol.GuacamoleStatus;
+
+
+/**
+ * An exception which is thrown when a resource has been requested, but that
+ * resource is locked or currently in use, and cannot be accessed by the
+ * current user.
+ * 
+ * @author Michael Jumper
+ */
+public class GuacamoleResourceConflictException extends GuacamoleClientException {
+
+    /**
+     * Creates a new GuacamoleResourceConflictException with the given message and cause.
+     *
+     * @param message A human readable description of the exception that
+     *                occurred.
+     * @param cause The cause of this exception.
+     */
+    public GuacamoleResourceConflictException(String message, Throwable cause) {
+        super(message, cause);
+    }
+
+    /**
+     * Creates a new GuacamoleResourceConflictException with the given message.
+     *
+     * @param message A human readable description of the exception that
+     *                occurred.
+     */
+    public GuacamoleResourceConflictException(String message) {
+        super(message);
+    }
+
+    /**
+     * Creates a new GuacamoleResourceConflictException with the given cause.
+     *
+     * @param cause The cause of this exception.
+     */
+    public GuacamoleResourceConflictException(Throwable cause) {
+        super(cause);
+    }
+
+    @Override
+    public GuacamoleStatus getStatus() {
+        return GuacamoleStatus.RESOURCE_CONFLICT;
+    }
+
+}
diff --git a/guacamole-common/src/main/java/org/glyptodon/guacamole/GuacamoleResourceNotFoundException.java b/guacamole-common/src/main/java/org/glyptodon/guacamole/GuacamoleResourceNotFoundException.java
index cb90a41..3794c24 100644
--- a/guacamole-common/src/main/java/org/glyptodon/guacamole/GuacamoleResourceNotFoundException.java
+++ b/guacamole-common/src/main/java/org/glyptodon/guacamole/GuacamoleResourceNotFoundException.java
@@ -1,41 +1,29 @@
-
-package org.glyptodon.guacamole;
-
-/* ***** BEGIN LICENSE BLOCK *****
- * Version: MPL 1.1/GPL 2.0/LGPL 2.1
- *
- * The contents of this file are subject to the Mozilla Public License Version
- * 1.1 (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.mozilla.org/MPL/
- *
- * Software distributed under the License is distributed on an "AS IS" basis,
- * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- * for the specific language governing rights and limitations under the
- * License.
- *
- * The Original Code is guacamole-common.
- *
- * The Initial Developer of the Original Code is
- * Michael Jumper.
- * Portions created by the Initial Developer are Copyright (C) 2010
- * the Initial Developer. All Rights Reserved.
+/*
+ * Copyright (C) 2013 Glyptodon LLC
  *
- * Contributor(s):
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
  *
- * Alternatively, the contents of this file may be used under the terms of
- * either the GNU General Public License Version 2 or later (the "GPL"), or
- * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
- * in which case the provisions of the GPL or the LGPL are applicable instead
- * of those above. If you wish to allow use of your version of this file only
- * under the terms of either the GPL or the LGPL, and not to allow others to
- * use your version of this file under the terms of the MPL, indicate your
- * decision by deleting the provisions above and replace them with the notice
- * and other provisions required by the GPL or the LGPL. If you do not delete
- * the provisions above, a recipient may use your version of this file under
- * the terms of any one of the MPL, the GPL or the LGPL.
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
  *
- * ***** END LICENSE BLOCK ***** */
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole;
+
+import org.glyptodon.guacamole.protocol.GuacamoleStatus;
+
 
 /**
  * A generic exception thrown when part of the Guacamole API fails to find
@@ -76,4 +64,9 @@ public class GuacamoleResourceNotFoundException extends GuacamoleClientException
         super(cause);
     }
 
+    @Override
+    public GuacamoleStatus getStatus() {
+        return GuacamoleStatus.RESOURCE_NOT_FOUND;
+    }
+
 }
diff --git a/guacamole-common/src/main/java/org/glyptodon/guacamole/GuacamoleSecurityException.java b/guacamole-common/src/main/java/org/glyptodon/guacamole/GuacamoleSecurityException.java
index e40bcff..417e5d2 100644
--- a/guacamole-common/src/main/java/org/glyptodon/guacamole/GuacamoleSecurityException.java
+++ b/guacamole-common/src/main/java/org/glyptodon/guacamole/GuacamoleSecurityException.java
@@ -1,41 +1,29 @@
-
-package org.glyptodon.guacamole;
-
-/* ***** BEGIN LICENSE BLOCK *****
- * Version: MPL 1.1/GPL 2.0/LGPL 2.1
- *
- * The contents of this file are subject to the Mozilla Public License Version
- * 1.1 (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.mozilla.org/MPL/
- *
- * Software distributed under the License is distributed on an "AS IS" basis,
- * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- * for the specific language governing rights and limitations under the
- * License.
- *
- * The Original Code is guacamole-common.
- *
- * The Initial Developer of the Original Code is
- * Michael Jumper.
- * Portions created by the Initial Developer are Copyright (C) 2010
- * the Initial Developer. All Rights Reserved.
+/*
+ * Copyright (C) 2013 Glyptodon LLC
  *
- * Contributor(s):
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
  *
- * Alternatively, the contents of this file may be used under the terms of
- * either the GNU General Public License Version 2 or later (the "GPL"), or
- * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
- * in which case the provisions of the GPL or the LGPL are applicable instead
- * of those above. If you wish to allow use of your version of this file only
- * under the terms of either the GPL or the LGPL, and not to allow others to
- * use your version of this file under the terms of the MPL, indicate your
- * decision by deleting the provisions above and replace them with the notice
- * and other provisions required by the GPL or the LGPL. If you do not delete
- * the provisions above, a recipient may use your version of this file under
- * the terms of any one of the MPL, the GPL or the LGPL.
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
  *
- * ***** END LICENSE BLOCK ***** */
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole;
+
+import org.glyptodon.guacamole.protocol.GuacamoleStatus;
+
 
 /**
  * A security-related exception thrown when parts of the Guacamole API is
@@ -75,4 +63,9 @@ public class GuacamoleSecurityException extends GuacamoleClientException {
         super(cause);
     }
 
+    @Override
+    public GuacamoleStatus getStatus() {
+        return GuacamoleStatus.CLIENT_FORBIDDEN;
+    }
+
 }
diff --git a/guacamole-common/src/main/java/org/glyptodon/guacamole/GuacamoleServerBusyException.java b/guacamole-common/src/main/java/org/glyptodon/guacamole/GuacamoleServerBusyException.java
new file mode 100644
index 0000000..96473fe
--- /dev/null
+++ b/guacamole-common/src/main/java/org/glyptodon/guacamole/GuacamoleServerBusyException.java
@@ -0,0 +1,71 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole;
+
+import org.glyptodon.guacamole.protocol.GuacamoleStatus;
+
+
+/**
+ * An exception which is thrown when the server is too busy to service the
+ * request.
+ * 
+ * @author Michael Jumper
+ */
+public class GuacamoleServerBusyException extends GuacamoleServerException {
+
+    /**
+     * Creates a new GuacamoleServerBusyException with the given message and cause.
+     *
+     * @param message A human readable description of the exception that
+     *                occurred.
+     * @param cause The cause of this exception.
+     */
+    public GuacamoleServerBusyException(String message, Throwable cause) {
+        super(message, cause);
+    }
+
+    /**
+     * Creates a new GuacamoleServerBusyException with the given message.
+     *
+     * @param message A human readable description of the exception that
+     *                occurred.
+     */
+    public GuacamoleServerBusyException(String message) {
+        super(message);
+    }
+
+    /**
+     * Creates a new GuacamoleServerBusyException with the given cause.
+     *
+     * @param cause The cause of this exception.
+     */
+    public GuacamoleServerBusyException(Throwable cause) {
+        super(cause);
+    }
+
+    @Override
+    public GuacamoleStatus getStatus() {
+        return GuacamoleStatus.SERVER_BUSY;
+    }
+
+}
diff --git a/guacamole-common/src/main/java/org/glyptodon/guacamole/GuacamoleServerException.java b/guacamole-common/src/main/java/org/glyptodon/guacamole/GuacamoleServerException.java
index 24057ba..d37ec1b 100644
--- a/guacamole-common/src/main/java/org/glyptodon/guacamole/GuacamoleServerException.java
+++ b/guacamole-common/src/main/java/org/glyptodon/guacamole/GuacamoleServerException.java
@@ -1,41 +1,29 @@
-
-package org.glyptodon.guacamole;
-
-/* ***** BEGIN LICENSE BLOCK *****
- * Version: MPL 1.1/GPL 2.0/LGPL 2.1
- *
- * The contents of this file are subject to the Mozilla Public License Version
- * 1.1 (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.mozilla.org/MPL/
- *
- * Software distributed under the License is distributed on an "AS IS" basis,
- * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- * for the specific language governing rights and limitations under the
- * License.
- *
- * The Original Code is guacamole-common.
- *
- * The Initial Developer of the Original Code is
- * Michael Jumper.
- * Portions created by the Initial Developer are Copyright (C) 2010
- * the Initial Developer. All Rights Reserved.
+/*
+ * Copyright (C) 2013 Glyptodon LLC
  *
- * Contributor(s):
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
  *
- * Alternatively, the contents of this file may be used under the terms of
- * either the GNU General Public License Version 2 or later (the "GPL"), or
- * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
- * in which case the provisions of the GPL or the LGPL are applicable instead
- * of those above. If you wish to allow use of your version of this file only
- * under the terms of either the GPL or the LGPL, and not to allow others to
- * use your version of this file under the terms of the MPL, indicate your
- * decision by deleting the provisions above and replace them with the notice
- * and other provisions required by the GPL or the LGPL. If you do not delete
- * the provisions above, a recipient may use your version of this file under
- * the terms of any one of the MPL, the GPL or the LGPL.
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
  *
- * ***** END LICENSE BLOCK ***** */
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole;
+
+import org.glyptodon.guacamole.protocol.GuacamoleStatus;
+
 
 /**
  * A generic exception thrown when part of the Guacamole API encounters
@@ -47,7 +35,7 @@ package org.glyptodon.guacamole;
 public class GuacamoleServerException extends GuacamoleException {
 
     /**
-     * Creates a new GuacamoleException with the given message and cause.
+     * Creates a new GuacamoleServerException with the given message and cause.
      *
      * @param message A human readable description of the exception that
      *                occurred.
@@ -58,7 +46,7 @@ public class GuacamoleServerException extends GuacamoleException {
     }
 
     /**
-     * Creates a new GuacamoleException with the given message.
+     * Creates a new GuacamoleServerException with the given message.
      *
      * @param message A human readable description of the exception that
      *                occurred.
@@ -68,7 +56,7 @@ public class GuacamoleServerException extends GuacamoleException {
     }
 
     /**
-     * Creates a new GuacamoleException with the given cause.
+     * Creates a new GuacamoleServerException with the given cause.
      *
      * @param cause The cause of this exception.
      */
@@ -76,4 +64,9 @@ public class GuacamoleServerException extends GuacamoleException {
         super(cause);
     }
 
+    @Override
+    public GuacamoleStatus getStatus() {
+        return GuacamoleStatus.SERVER_ERROR;
+    }
+
 }
diff --git a/guacamole-common/src/main/java/org/glyptodon/guacamole/GuacamoleUnauthorizedException.java b/guacamole-common/src/main/java/org/glyptodon/guacamole/GuacamoleUnauthorizedException.java
new file mode 100644
index 0000000..a9db5af
--- /dev/null
+++ b/guacamole-common/src/main/java/org/glyptodon/guacamole/GuacamoleUnauthorizedException.java
@@ -0,0 +1,72 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole;
+
+import org.glyptodon.guacamole.protocol.GuacamoleStatus;
+
+
+/**
+ * A security-related exception thrown when parts of the Guacamole API is
+ * denying access to a resource, but access MAY be granted were the user
+ * authorized (logged in).
+ *
+ * @author Michael Jumper
+ */
+public class GuacamoleUnauthorizedException extends GuacamoleSecurityException {
+
+    /**
+     * Creates a new GuacamoleUnauthorizedException with the given message and cause.
+     *
+     * @param message A human readable description of the exception that
+     *                occurred.
+     * @param cause The cause of this exception.
+     */
+    public GuacamoleUnauthorizedException(String message, Throwable cause) {
+        super(message, cause);
+    }
+
+    /**
+     * Creates a new GuacamoleUnauthorizedException with the given message.
+     *
+     * @param message A human readable description of the exception that
+     *                occurred.
+     */
+    public GuacamoleUnauthorizedException(String message) {
+        super(message);
+    }
+
+    /**
+     * Creates a new GuacamoleUnauthorizedException with the given cause.
+     *
+     * @param cause The cause of this exception.
+     */
+    public GuacamoleUnauthorizedException(Throwable cause) {
+        super(cause);
+    }
+
+    @Override
+    public GuacamoleStatus getStatus() {
+        return GuacamoleStatus.CLIENT_UNAUTHORIZED;
+    }
+
+}
diff --git a/guacamole-common/src/main/java/org/glyptodon/guacamole/GuacamoleUnsupportedException.java b/guacamole-common/src/main/java/org/glyptodon/guacamole/GuacamoleUnsupportedException.java
new file mode 100644
index 0000000..8aa1869
--- /dev/null
+++ b/guacamole-common/src/main/java/org/glyptodon/guacamole/GuacamoleUnsupportedException.java
@@ -0,0 +1,71 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole;
+
+import org.glyptodon.guacamole.protocol.GuacamoleStatus;
+
+
+/**
+ * An exception which is thrown when the requested operation is unsupported
+ * or unimplemented.
+ *
+ * @author Michael Jumper
+ */
+public class GuacamoleUnsupportedException extends GuacamoleServerException {
+
+    /**
+     * Creates a new GuacamoleUnsupportedException with the given message and cause.
+     *
+     * @param message A human readable description of the exception that
+     *                occurred.
+     * @param cause The cause of this exception.
+     */
+    public GuacamoleUnsupportedException(String message, Throwable cause) {
+        super(message, cause);
+    }
+
+    /**
+     * Creates a new GuacamoleUnsupportedException with the given message.
+     *
+     * @param message A human readable description of the exception that
+     *                occurred.
+     */
+    public GuacamoleUnsupportedException(String message) {
+        super(message);
+    }
+
+    /**
+     * Creates a new GuacamoleUnsupportedException with the given cause.
+     *
+     * @param cause The cause of this exception.
+     */
+    public GuacamoleUnsupportedException(Throwable cause) {
+        super(cause);
+    }
+
+    @Override
+    public GuacamoleStatus getStatus() {
+        return GuacamoleStatus.UNSUPPORTED;
+    }
+
+}
diff --git a/guacamole-common/src/main/java/org/glyptodon/guacamole/GuacamoleUpstreamException.java b/guacamole-common/src/main/java/org/glyptodon/guacamole/GuacamoleUpstreamException.java
new file mode 100644
index 0000000..c0bfb80
--- /dev/null
+++ b/guacamole-common/src/main/java/org/glyptodon/guacamole/GuacamoleUpstreamException.java
@@ -0,0 +1,72 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole;
+
+import org.glyptodon.guacamole.protocol.GuacamoleStatus;
+
+
+/**
+ * An exception which indicates than an upstream server (such as the remote
+ * desktop) is returning an error or is otherwise unreachable.
+ * 
+ * @author Michael Jumper
+ */
+public class GuacamoleUpstreamException extends GuacamoleException {
+
+    /**
+     * Creates a new GuacamoleUpstreamException with the given message and
+     * cause.
+     *
+     * @param message A human readable description of the exception that
+     *                occurred.
+     * @param cause The cause of this exception.
+     */
+    public GuacamoleUpstreamException(String message, Throwable cause) {
+        super(message, cause);
+    }
+
+    /**
+     * Creates a new GuacamoleUpstreamException with the given message.
+     *
+     * @param message A human readable description of the exception that
+     *                occurred.
+     */
+    public GuacamoleUpstreamException(String message) {
+        super(message);
+    }
+
+    /**
+     * Creates a new GuacamoleUpstreamException with the given cause.
+     *
+     * @param cause The cause of this exception.
+     */
+    public GuacamoleUpstreamException(Throwable cause) {
+        super(cause);
+    }
+
+    @Override
+    public GuacamoleStatus getStatus() {
+        return GuacamoleStatus.UPSTREAM_ERROR;
+    }
+
+}
diff --git a/guacamole-common/src/main/java/org/glyptodon/guacamole/GuacamoleUpstreamTimeoutException.java b/guacamole-common/src/main/java/org/glyptodon/guacamole/GuacamoleUpstreamTimeoutException.java
new file mode 100644
index 0000000..e1e0998
--- /dev/null
+++ b/guacamole-common/src/main/java/org/glyptodon/guacamole/GuacamoleUpstreamTimeoutException.java
@@ -0,0 +1,72 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole;
+
+import org.glyptodon.guacamole.protocol.GuacamoleStatus;
+
+
+/**
+ * An exception which indicates than an upstream server (such as the remote
+ * desktop) is taking too long to respond.
+ * 
+ * @author Michael Jumper
+ */
+public class GuacamoleUpstreamTimeoutException extends GuacamoleUpstreamException {
+
+    /**
+     * Creates a new GuacamoleUpstreamException with the given message and
+     * cause.
+     *
+     * @param message A human readable description of the exception that
+     *                occurred.
+     * @param cause The cause of this exception.
+     */
+    public GuacamoleUpstreamTimeoutException(String message, Throwable cause) {
+        super(message, cause);
+    }
+
+    /**
+     * Creates a new GuacamoleUpstreamException with the given message.
+     *
+     * @param message A human readable description of the exception that
+     *                occurred.
+     */
+    public GuacamoleUpstreamTimeoutException(String message) {
+        super(message);
+    }
+
+    /**
+     * Creates a new GuacamoleUpstreamException with the given cause.
+     *
+     * @param cause The cause of this exception.
+     */
+    public GuacamoleUpstreamTimeoutException(Throwable cause) {
+        super(cause);
+    }
+
+    @Override
+    public GuacamoleStatus getStatus() {
+        return GuacamoleStatus.UPSTREAM_TIMEOUT;
+    }
+
+}
diff --git a/guacamole-common/src/main/java/org/glyptodon/guacamole/io/GuacamoleReader.java b/guacamole-common/src/main/java/org/glyptodon/guacamole/io/GuacamoleReader.java
index 23fca3d..2c5cf3d 100644
--- a/guacamole-common/src/main/java/org/glyptodon/guacamole/io/GuacamoleReader.java
+++ b/guacamole-common/src/main/java/org/glyptodon/guacamole/io/GuacamoleReader.java
@@ -1,41 +1,27 @@
-
-package org.glyptodon.guacamole.io;
-
-/* ***** BEGIN LICENSE BLOCK *****
- * Version: MPL 1.1/GPL 2.0/LGPL 2.1
- *
- * The contents of this file are subject to the Mozilla Public License Version
- * 1.1 (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.mozilla.org/MPL/
- *
- * Software distributed under the License is distributed on an "AS IS" basis,
- * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- * for the specific language governing rights and limitations under the
- * License.
- *
- * The Original Code is guacamole-common.
+/*
+ * Copyright (C) 2013 Glyptodon LLC
  *
- * The Initial Developer of the Original Code is
- * Michael Jumper.
- * Portions created by the Initial Developer are Copyright (C) 2010
- * the Initial Developer. All Rights Reserved.
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
  *
- * Contributor(s):
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
  *
- * Alternatively, the contents of this file may be used under the terms of
- * either the GNU General Public License Version 2 or later (the "GPL"), or
- * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
- * in which case the provisions of the GPL or the LGPL are applicable instead
- * of those above. If you wish to allow use of your version of this file only
- * under the terms of either the GPL or the LGPL, and not to allow others to
- * use your version of this file under the terms of the MPL, indicate your
- * decision by deleting the provisions above and replace them with the notice
- * and other provisions required by the GPL or the LGPL. If you do not delete
- * the provisions above, a recipient may use your version of this file under
- * the terms of any one of the MPL, the GPL or the LGPL.
- *
- * ***** END LICENSE BLOCK ***** */
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.io;
+
 
 import org.glyptodon.guacamole.GuacamoleException;
 import org.glyptodon.guacamole.protocol.GuacamoleInstruction;
diff --git a/guacamole-common/src/main/java/org/glyptodon/guacamole/io/GuacamoleWriter.java b/guacamole-common/src/main/java/org/glyptodon/guacamole/io/GuacamoleWriter.java
index ad80296..ec657b9 100644
--- a/guacamole-common/src/main/java/org/glyptodon/guacamole/io/GuacamoleWriter.java
+++ b/guacamole-common/src/main/java/org/glyptodon/guacamole/io/GuacamoleWriter.java
@@ -1,41 +1,27 @@
-
-package org.glyptodon.guacamole.io;
-
-/* ***** BEGIN LICENSE BLOCK *****
- * Version: MPL 1.1/GPL 2.0/LGPL 2.1
- *
- * The contents of this file are subject to the Mozilla Public License Version
- * 1.1 (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.mozilla.org/MPL/
- *
- * Software distributed under the License is distributed on an "AS IS" basis,
- * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- * for the specific language governing rights and limitations under the
- * License.
- *
- * The Original Code is guacamole-common.
+/*
+ * Copyright (C) 2013 Glyptodon LLC
  *
- * The Initial Developer of the Original Code is
- * Michael Jumper.
- * Portions created by the Initial Developer are Copyright (C) 2010
- * the Initial Developer. All Rights Reserved.
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
  *
- * Contributor(s):
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
  *
- * Alternatively, the contents of this file may be used under the terms of
- * either the GNU General Public License Version 2 or later (the "GPL"), or
- * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
- * in which case the provisions of the GPL or the LGPL are applicable instead
- * of those above. If you wish to allow use of your version of this file only
- * under the terms of either the GPL or the LGPL, and not to allow others to
- * use your version of this file under the terms of the MPL, indicate your
- * decision by deleting the provisions above and replace them with the notice
- * and other provisions required by the GPL or the LGPL. If you do not delete
- * the provisions above, a recipient may use your version of this file under
- * the terms of any one of the MPL, the GPL or the LGPL.
- *
- * ***** END LICENSE BLOCK ***** */
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.io;
+
 
 import org.glyptodon.guacamole.GuacamoleException;
 import org.glyptodon.guacamole.protocol.GuacamoleInstruction;
diff --git a/guacamole-common/src/main/java/org/glyptodon/guacamole/io/ReaderGuacamoleReader.java b/guacamole-common/src/main/java/org/glyptodon/guacamole/io/ReaderGuacamoleReader.java
index d525f68..756d939 100644
--- a/guacamole-common/src/main/java/org/glyptodon/guacamole/io/ReaderGuacamoleReader.java
+++ b/guacamole-common/src/main/java/org/glyptodon/guacamole/io/ReaderGuacamoleReader.java
@@ -1,48 +1,38 @@
-
-package org.glyptodon.guacamole.io;
-
-/* ***** BEGIN LICENSE BLOCK *****
- * Version: MPL 1.1/GPL 2.0/LGPL 2.1
- *
- * The contents of this file are subject to the Mozilla Public License Version
- * 1.1 (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.mozilla.org/MPL/
- *
- * Software distributed under the License is distributed on an "AS IS" basis,
- * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- * for the specific language governing rights and limitations under the
- * License.
- *
- * The Original Code is guacamole-common.
- *
- * The Initial Developer of the Original Code is
- * Michael Jumper.
- * Portions created by the Initial Developer are Copyright (C) 2010
- * the Initial Developer. All Rights Reserved.
+/*
+ * Copyright (C) 2013 Glyptodon LLC
  *
- * Contributor(s):
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
  *
- * Alternatively, the contents of this file may be used under the terms of
- * either the GNU General Public License Version 2 or later (the "GPL"), or
- * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
- * in which case the provisions of the GPL or the LGPL are applicable instead
- * of those above. If you wish to allow use of your version of this file only
- * under the terms of either the GPL or the LGPL, and not to allow others to
- * use your version of this file under the terms of the MPL, indicate your
- * decision by deleting the provisions above and replace them with the notice
- * and other provisions required by the GPL or the LGPL. If you do not delete
- * the provisions above, a recipient may use your version of this file under
- * the terms of any one of the MPL, the GPL or the LGPL.
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
  *
- * ***** END LICENSE BLOCK ***** */
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.io;
+
 
 import java.io.IOException;
 import java.io.Reader;
+import java.net.SocketException;
+import java.net.SocketTimeoutException;
 import java.util.Deque;
 import java.util.LinkedList;
+import org.glyptodon.guacamole.GuacamoleConnectionClosedException;
 import org.glyptodon.guacamole.GuacamoleException;
 import org.glyptodon.guacamole.GuacamoleServerException;
+import org.glyptodon.guacamole.GuacamoleUpstreamTimeoutException;
 import org.glyptodon.guacamole.protocol.GuacamoleInstruction;
 
 /**
@@ -191,6 +181,12 @@ public class ReaderGuacamoleReader implements GuacamoleReader {
             } // End read loop
 
         }
+        catch (SocketTimeoutException e) {
+            throw new GuacamoleUpstreamTimeoutException("Connection to guacd timed out.", e);
+        }
+        catch (SocketException e) {
+            throw new GuacamoleConnectionClosedException("Connection to guacd is closed.", e);
+        }
         catch (IOException e) {
             throw new GuacamoleServerException(e);
         }
diff --git a/guacamole-common/src/main/java/org/glyptodon/guacamole/io/WriterGuacamoleWriter.java b/guacamole-common/src/main/java/org/glyptodon/guacamole/io/WriterGuacamoleWriter.java
index 169159a..1301d68 100644
--- a/guacamole-common/src/main/java/org/glyptodon/guacamole/io/WriterGuacamoleWriter.java
+++ b/guacamole-common/src/main/java/org/glyptodon/guacamole/io/WriterGuacamoleWriter.java
@@ -1,46 +1,36 @@
-
-package org.glyptodon.guacamole.io;
-
-/* ***** BEGIN LICENSE BLOCK *****
- * Version: MPL 1.1/GPL 2.0/LGPL 2.1
- *
- * The contents of this file are subject to the Mozilla Public License Version
- * 1.1 (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.mozilla.org/MPL/
- *
- * Software distributed under the License is distributed on an "AS IS" basis,
- * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- * for the specific language governing rights and limitations under the
- * License.
- *
- * The Original Code is guacamole-common.
- *
- * The Initial Developer of the Original Code is
- * Michael Jumper.
- * Portions created by the Initial Developer are Copyright (C) 2010
- * the Initial Developer. All Rights Reserved.
+/*
+ * Copyright (C) 2013 Glyptodon LLC
  *
- * Contributor(s):
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
  *
- * Alternatively, the contents of this file may be used under the terms of
- * either the GNU General Public License Version 2 or later (the "GPL"), or
- * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
- * in which case the provisions of the GPL or the LGPL are applicable instead
- * of those above. If you wish to allow use of your version of this file only
- * under the terms of either the GPL or the LGPL, and not to allow others to
- * use your version of this file under the terms of the MPL, indicate your
- * decision by deleting the provisions above and replace them with the notice
- * and other provisions required by the GPL or the LGPL. If you do not delete
- * the provisions above, a recipient may use your version of this file under
- * the terms of any one of the MPL, the GPL or the LGPL.
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
  *
- * ***** END LICENSE BLOCK ***** */
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.io;
+
 
 import java.io.IOException;
 import java.io.Writer;
+import java.net.SocketException;
+import java.net.SocketTimeoutException;
+import org.glyptodon.guacamole.GuacamoleConnectionClosedException;
 import org.glyptodon.guacamole.GuacamoleException;
 import org.glyptodon.guacamole.GuacamoleServerException;
+import org.glyptodon.guacamole.GuacamoleUpstreamTimeoutException;
 import org.glyptodon.guacamole.protocol.GuacamoleInstruction;
 
 /**
@@ -72,6 +62,12 @@ public class WriterGuacamoleWriter implements GuacamoleWriter {
             output.write(chunk, off, len);
             output.flush();
         }
+        catch (SocketTimeoutException e) {
+            throw new GuacamoleUpstreamTimeoutException("Connection to guacd timed out.", e);
+        }
+        catch (SocketException e) {
+            throw new GuacamoleConnectionClosedException("Connection to guacd is closed.", e);
+        }
         catch (IOException e) {
             throw new GuacamoleServerException(e);
         }
diff --git a/guacamole-common/src/main/java/org/glyptodon/guacamole/io/package-info.java b/guacamole-common/src/main/java/org/glyptodon/guacamole/io/package-info.java
index 531531a..8a12704 100644
--- a/guacamole-common/src/main/java/org/glyptodon/guacamole/io/package-info.java
+++ b/guacamole-common/src/main/java/org/glyptodon/guacamole/io/package-info.java
@@ -1,3 +1,24 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
 
 /**
  * All classes relating directly to data input or output.
diff --git a/guacamole-common/src/main/java/org/glyptodon/guacamole/net/AbstractGuacamoleTunnel.java b/guacamole-common/src/main/java/org/glyptodon/guacamole/net/AbstractGuacamoleTunnel.java
new file mode 100644
index 0000000..d8da194
--- /dev/null
+++ b/guacamole-common/src/main/java/org/glyptodon/guacamole/net/AbstractGuacamoleTunnel.java
@@ -0,0 +1,133 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net;
+
+
+import java.util.concurrent.locks.ReentrantLock;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.io.GuacamoleReader;
+import org.glyptodon.guacamole.io.GuacamoleWriter;
+
+/**
+ * Base GuacamoleTunnel implementation which synchronizes access to the
+ * underlying reader and writer with reentrant locks. Implementations need only
+ * provide the tunnel's UUID and socket.
+ *
+ * @author Michael Jumper
+ */
+public abstract class AbstractGuacamoleTunnel implements GuacamoleTunnel {
+
+    /**
+     * Lock acquired when a read operation is in progress.
+     */
+    private final ReentrantLock readerLock;
+
+    /**
+     * Lock acquired when a write operation is in progress.
+     */
+    private final ReentrantLock writerLock;
+
+    /**
+     * Creates a new GuacamoleTunnel which synchronizes access to the
+     * Guacamole instruction stream associated with the underlying
+     * GuacamoleSocket.
+     */
+    public AbstractGuacamoleTunnel() {
+        readerLock = new ReentrantLock();
+        writerLock = new ReentrantLock();
+    }
+
+    /**
+     * Acquires exclusive read access to the Guacamole instruction stream
+     * and returns a GuacamoleReader for reading from that stream.
+     *
+     * @return A GuacamoleReader for reading from the Guacamole instruction
+     *         stream.
+     */
+    @Override
+    public GuacamoleReader acquireReader() {
+        readerLock.lock();
+        return getSocket().getReader();
+    }
+
+    /**
+     * Relinquishes exclusive read access to the Guacamole instruction
+     * stream. This function should be called whenever a thread finishes using
+     * a GuacamoleTunnel's GuacamoleReader.
+     */
+    @Override
+    public void releaseReader() {
+        readerLock.unlock();
+    }
+
+    /**
+     * Returns whether there are threads waiting for read access to the
+     * Guacamole instruction stream.
+     *
+     * @return true if threads are waiting for read access the Guacamole
+     *         instruction stream, false otherwise.
+     */
+    @Override
+    public boolean hasQueuedReaderThreads() {
+        return readerLock.hasQueuedThreads();
+    }
+
+    /**
+     * Acquires exclusive write access to the Guacamole instruction stream
+     * and returns a GuacamoleWriter for writing to that stream.
+     *
+     * @return A GuacamoleWriter for writing to the Guacamole instruction
+     *         stream.
+     */
+    @Override
+    public GuacamoleWriter acquireWriter() {
+        writerLock.lock();
+        return getSocket().getWriter();
+    }
+
+    /**
+     * Relinquishes exclusive write access to the Guacamole instruction
+     * stream. This function should be called whenever a thread finishes using
+     * a GuacamoleTunnel's GuacamoleWriter.
+     */
+    @Override
+    public void releaseWriter() {
+        writerLock.unlock();
+    }
+
+    @Override
+    public boolean hasQueuedWriterThreads() {
+        return writerLock.hasQueuedThreads();
+    }
+
+    @Override
+    public void close() throws GuacamoleException {
+        getSocket().close();
+    }
+
+    @Override
+    public boolean isOpen() {
+        return getSocket().isOpen();
+    }
+
+}
diff --git a/guacamole-common/src/main/java/org/glyptodon/guacamole/net/DelegatingGuacamoleTunnel.java b/guacamole-common/src/main/java/org/glyptodon/guacamole/net/DelegatingGuacamoleTunnel.java
new file mode 100644
index 0000000..aa56298
--- /dev/null
+++ b/guacamole-common/src/main/java/org/glyptodon/guacamole/net/DelegatingGuacamoleTunnel.java
@@ -0,0 +1,104 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net;
+
+import java.util.UUID;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.io.GuacamoleReader;
+import org.glyptodon.guacamole.io.GuacamoleWriter;
+
+/**
+ * GuacamoleTunnel implementation which simply delegates all function calls to
+ * an underlying GuacamoleTunnel.
+ *
+ * @author Michael Jumper
+ */
+public class DelegatingGuacamoleTunnel implements GuacamoleTunnel {
+
+    /**
+     * The wrapped GuacamoleTunnel.
+     */
+    private final GuacamoleTunnel tunnel;
+
+    /**
+     * Wraps the given tunnel such that all function calls against this tunnel
+     * will be delegated to it.
+     *
+     * @param tunnel
+     *     The GuacamoleTunnel to wrap.
+     */
+    public DelegatingGuacamoleTunnel(GuacamoleTunnel tunnel) {
+        this.tunnel = tunnel;
+    }
+
+    @Override
+    public GuacamoleReader acquireReader() {
+        return tunnel.acquireReader();
+    }
+
+    @Override
+    public void releaseReader() {
+        tunnel.releaseReader();
+    }
+
+    @Override
+    public boolean hasQueuedReaderThreads() {
+        return tunnel.hasQueuedReaderThreads();
+    }
+
+    @Override
+    public GuacamoleWriter acquireWriter() {
+        return tunnel.acquireWriter();
+    }
+
+    @Override
+    public void releaseWriter() {
+        tunnel.releaseWriter();
+    }
+
+    @Override
+    public boolean hasQueuedWriterThreads() {
+        return tunnel.hasQueuedWriterThreads();
+    }
+
+    @Override
+    public UUID getUUID() {
+        return tunnel.getUUID();
+    }
+
+    @Override
+    public GuacamoleSocket getSocket() {
+        return tunnel.getSocket();
+    }
+
+    @Override
+    public void close() throws GuacamoleException {
+        tunnel.close();
+    }
+
+    @Override
+    public boolean isOpen() {
+        return tunnel.isOpen();
+    }
+
+}
diff --git a/guacamole-common/src/main/java/org/glyptodon/guacamole/net/GuacamoleSocket.java b/guacamole-common/src/main/java/org/glyptodon/guacamole/net/GuacamoleSocket.java
index 0d4d389..327ea29 100644
--- a/guacamole-common/src/main/java/org/glyptodon/guacamole/net/GuacamoleSocket.java
+++ b/guacamole-common/src/main/java/org/glyptodon/guacamole/net/GuacamoleSocket.java
@@ -1,41 +1,27 @@
-
-package org.glyptodon.guacamole.net;
-
-/* ***** BEGIN LICENSE BLOCK *****
- * Version: MPL 1.1/GPL 2.0/LGPL 2.1
- *
- * The contents of this file are subject to the Mozilla Public License Version
- * 1.1 (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.mozilla.org/MPL/
- *
- * Software distributed under the License is distributed on an "AS IS" basis,
- * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- * for the specific language governing rights and limitations under the
- * License.
- *
- * The Original Code is guacamole-common.
+/*
+ * Copyright (C) 2013 Glyptodon LLC
  *
- * The Initial Developer of the Original Code is
- * Michael Jumper.
- * Portions created by the Initial Developer are Copyright (C) 2010
- * the Initial Developer. All Rights Reserved.
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
  *
- * Contributor(s):
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
  *
- * Alternatively, the contents of this file may be used under the terms of
- * either the GNU General Public License Version 2 or later (the "GPL"), or
- * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
- * in which case the provisions of the GPL or the LGPL are applicable instead
- * of those above. If you wish to allow use of your version of this file only
- * under the terms of either the GPL or the LGPL, and not to allow others to
- * use your version of this file under the terms of the MPL, indicate your
- * decision by deleting the provisions above and replace them with the notice
- * and other provisions required by the GPL or the LGPL. If you do not delete
- * the provisions above, a recipient may use your version of this file under
- * the terms of any one of the MPL, the GPL or the LGPL.
- *
- * ***** END LICENSE BLOCK ***** */
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net;
+
 
 import org.glyptodon.guacamole.GuacamoleException;
 import org.glyptodon.guacamole.io.GuacamoleReader;
diff --git a/guacamole-common/src/main/java/org/glyptodon/guacamole/net/GuacamoleTunnel.java b/guacamole-common/src/main/java/org/glyptodon/guacamole/net/GuacamoleTunnel.java
index d2e3c38..c4e30b0 100644
--- a/guacamole-common/src/main/java/org/glyptodon/guacamole/net/GuacamoleTunnel.java
+++ b/guacamole-common/src/main/java/org/glyptodon/guacamole/net/GuacamoleTunnel.java
@@ -1,44 +1,29 @@
-
-package org.glyptodon.guacamole.net;
-
-/* ***** BEGIN LICENSE BLOCK *****
- * Version: MPL 1.1/GPL 2.0/LGPL 2.1
- *
- * The contents of this file are subject to the Mozilla Public License Version
- * 1.1 (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.mozilla.org/MPL/
+/*
+ * Copyright (C) 2015 Glyptodon LLC
  *
- * Software distributed under the License is distributed on an "AS IS" basis,
- * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- * for the specific language governing rights and limitations under the
- * License.
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
  *
- * The Original Code is guacamole-common.
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
  *
- * The Initial Developer of the Original Code is
- * Michael Jumper.
- * Portions created by the Initial Developer are Copyright (C) 2010
- * the Initial Developer. All Rights Reserved.
- *
- * Contributor(s):
- *
- * Alternatively, the contents of this file may be used under the terms of
- * either the GNU General Public License Version 2 or later (the "GPL"), or
- * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
- * in which case the provisions of the GPL or the LGPL are applicable instead
- * of those above. If you wish to allow use of your version of this file only
- * under the terms of either the GPL or the LGPL, and not to allow others to
- * use your version of this file under the terms of the MPL, indicate your
- * decision by deleting the provisions above and replace them with the notice
- * and other provisions required by the GPL or the LGPL. If you do not delete
- * the provisions above, a recipient may use your version of this file under
- * the terms of any one of the MPL, the GPL or the LGPL.
- *
- * ***** END LICENSE BLOCK ***** */
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net;
+
 
 import java.util.UUID;
-import java.util.concurrent.locks.ReentrantLock;
 import org.glyptodon.guacamole.GuacamoleException;
 import org.glyptodon.guacamole.io.GuacamoleReader;
 import org.glyptodon.guacamole.io.GuacamoleWriter;
@@ -49,46 +34,7 @@ import org.glyptodon.guacamole.io.GuacamoleWriter;
  *
  * @author Michael Jumper
  */
-public class GuacamoleTunnel {
-
-    /**
-     * The UUID associated with this tunnel. Every tunnel must have a
-     * corresponding UUID such that tunnel read/write requests can be
-     * directed to the proper tunnel.
-     */
-    private UUID uuid;
-
-    /**
-     * The GuacamoleSocket that tunnel should use for communication on
-     * behalf of the connecting user.
-     */
-    private GuacamoleSocket socket;
-
-    /**
-     * Lock acquired when a read operation is in progress.
-     */
-    private ReentrantLock readerLock;
-
-    /**
-     * Lock acquired when a write operation is in progress.
-     */
-    private ReentrantLock writerLock;
-
-    /**
-     * Creates a new GuacamoleTunnel which synchronizes access to the
-     * Guacamole instruction stream associated with the given GuacamoleSocket.
-     *
-     * @param socket The GuacamoleSocket to provide synchronized access for.
-     */
-    public GuacamoleTunnel(GuacamoleSocket socket) {
-
-        this.socket = socket;
-        uuid = UUID.randomUUID();
-
-        readerLock = new ReentrantLock();
-        writerLock = new ReentrantLock();
-
-    }
+public interface GuacamoleTunnel {
 
     /**
      * Acquires exclusive read access to the Guacamole instruction stream
@@ -97,19 +43,14 @@ public class GuacamoleTunnel {
      * @return A GuacamoleReader for reading from the Guacamole instruction
      *         stream.
      */
-    public GuacamoleReader acquireReader() {
-        readerLock.lock();
-        return socket.getReader();
-    }
+    GuacamoleReader acquireReader();
 
     /**
      * Relinquishes exclusive read access to the Guacamole instruction
      * stream. This function should be called whenever a thread finishes using
      * a GuacamoleTunnel's GuacamoleReader.
      */
-    public void releaseReader() {
-        readerLock.unlock();
-    }
+    void releaseReader();
 
     /**
      * Returns whether there are threads waiting for read access to the
@@ -118,9 +59,7 @@ public class GuacamoleTunnel {
      * @return true if threads are waiting for read access the Guacamole
      *         instruction stream, false otherwise.
      */
-    public boolean hasQueuedReaderThreads() {
-        return readerLock.hasQueuedThreads();
-    }
+    boolean hasQueuedReaderThreads();
 
     /**
      * Acquires exclusive write access to the Guacamole instruction stream
@@ -129,19 +68,14 @@ public class GuacamoleTunnel {
      * @return A GuacamoleWriter for writing to the Guacamole instruction
      *         stream.
      */
-    public GuacamoleWriter acquireWriter() {
-        writerLock.lock();
-        return socket.getWriter();
-    }
+    GuacamoleWriter acquireWriter();
 
     /**
      * Relinquishes exclusive write access to the Guacamole instruction
      * stream. This function should be called whenever a thread finishes using
      * a GuacamoleTunnel's GuacamoleWriter.
      */
-    public void releaseWriter() {
-        writerLock.unlock();
-    }
+    void releaseWriter();
 
     /**
      * Returns whether there are threads waiting for write access to the
@@ -150,18 +84,14 @@ public class GuacamoleTunnel {
      * @return true if threads are waiting for write access the Guacamole
      *         instruction stream, false otherwise.
      */
-    public boolean hasQueuedWriterThreads() {
-        return writerLock.hasQueuedThreads();
-    }
+    boolean hasQueuedWriterThreads();
 
     /**
      * Returns the unique identifier associated with this GuacamoleTunnel.
      *
      * @return The unique identifier associated with this GuacamoleTunnel.
      */
-    public UUID getUUID() {
-        return uuid;
-    }
+    UUID getUUID();
 
     /**
      * Returns the GuacamoleSocket used by this GuacamoleTunnel for reading
@@ -169,9 +99,7 @@ public class GuacamoleTunnel {
      *
      * @return The GuacamoleSocket used by this GuacamoleTunnel.
      */
-    public GuacamoleSocket getSocket() {
-        return socket;
-    }
+    GuacamoleSocket getSocket();
 
     /**
      * Release all resources allocated to this GuacamoleTunnel.
@@ -179,17 +107,13 @@ public class GuacamoleTunnel {
      * @throws GuacamoleException if an error occurs while releasing
      *                            resources.
      */
-    public void close() throws GuacamoleException {
-        socket.close();
-    }
+    void close() throws GuacamoleException;
 
     /**
      * Returns whether this GuacamoleTunnel is open, or has been closed.
      *
      * @return true if this GuacamoleTunnel is open, false if it is closed.
      */
-    public boolean isOpen() {
-        return socket.isOpen();
-    }
+    boolean isOpen();
 
 }
diff --git a/guacamole-common/src/main/java/org/glyptodon/guacamole/net/InetGuacamoleSocket.java b/guacamole-common/src/main/java/org/glyptodon/guacamole/net/InetGuacamoleSocket.java
index df28f4e..c4cb658 100644
--- a/guacamole-common/src/main/java/org/glyptodon/guacamole/net/InetGuacamoleSocket.java
+++ b/guacamole-common/src/main/java/org/glyptodon/guacamole/net/InetGuacamoleSocket.java
@@ -1,41 +1,27 @@
-
-package org.glyptodon.guacamole.net;
-
-/* ***** BEGIN LICENSE BLOCK *****
- * Version: MPL 1.1/GPL 2.0/LGPL 2.1
- *
- * The contents of this file are subject to the Mozilla Public License Version
- * 1.1 (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.mozilla.org/MPL/
- *
- * Software distributed under the License is distributed on an "AS IS" basis,
- * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- * for the specific language governing rights and limitations under the
- * License.
- *
- * The Original Code is guacamole-common.
- *
- * The Initial Developer of the Original Code is
- * Michael Jumper.
- * Portions created by the Initial Developer are Copyright (C) 2010
- * the Initial Developer. All Rights Reserved.
+/*
+ * Copyright (C) 2013 Glyptodon LLC
  *
- * Contributor(s):
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
  *
- * Alternatively, the contents of this file may be used under the terms of
- * either the GNU General Public License Version 2 or later (the "GPL"), or
- * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
- * in which case the provisions of the GPL or the LGPL are applicable instead
- * of those above. If you wish to allow use of your version of this file only
- * under the terms of either the GPL or the LGPL, and not to allow others to
- * use your version of this file under the terms of the MPL, indicate your
- * decision by deleting the provisions above and replace them with the notice
- * and other provisions required by the GPL or the LGPL. If you do not delete
- * the provisions above, a recipient may use your version of this file under
- * the terms of any one of the MPL, the GPL or the LGPL.
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
  *
- * ***** END LICENSE BLOCK ***** */
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net;
+
 
 import org.glyptodon.guacamole.io.GuacamoleReader;
 import org.glyptodon.guacamole.io.ReaderGuacamoleReader;
@@ -50,8 +36,10 @@ import java.io.InputStreamReader;
 import java.io.OutputStreamWriter;
 import java.net.InetSocketAddress;
 import java.net.SocketAddress;
+import java.net.SocketTimeoutException;
 import org.glyptodon.guacamole.GuacamoleException;
 import org.glyptodon.guacamole.GuacamoleServerException;
+import org.glyptodon.guacamole.GuacamoleUpstreamTimeoutException;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -124,6 +112,9 @@ public class InetGuacamoleSocket implements GuacamoleSocket {
             writer = new WriterGuacamoleWriter(new OutputStreamWriter(sock.getOutputStream(), "UTF-8"));
 
         }
+        catch (SocketTimeoutException e) {
+            throw new GuacamoleUpstreamTimeoutException("Connection timed out.", e);
+        }
         catch (IOException e) {
             throw new GuacamoleServerException(e);
         }
@@ -156,5 +147,4 @@ public class InetGuacamoleSocket implements GuacamoleSocket {
         return !sock.isClosed();
     }
 
-
 }
diff --git a/guacamole-common/src/main/java/org/glyptodon/guacamole/net/SSLGuacamoleSocket.java b/guacamole-common/src/main/java/org/glyptodon/guacamole/net/SSLGuacamoleSocket.java
index 44c2f3f..8b9f0c4 100644
--- a/guacamole-common/src/main/java/org/glyptodon/guacamole/net/SSLGuacamoleSocket.java
+++ b/guacamole-common/src/main/java/org/glyptodon/guacamole/net/SSLGuacamoleSocket.java
@@ -1,41 +1,27 @@
-
-package org.glyptodon.guacamole.net;
-
-/* ***** BEGIN LICENSE BLOCK *****
- * Version: MPL 1.1/GPL 2.0/LGPL 2.1
- *
- * The contents of this file are subject to the Mozilla Public License Version
- * 1.1 (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.mozilla.org/MPL/
- *
- * Software distributed under the License is distributed on an "AS IS" basis,
- * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- * for the specific language governing rights and limitations under the
- * License.
- *
- * The Original Code is guacamole-common.
+/*
+ * Copyright (C) 2013 Glyptodon LLC
  *
- * The Initial Developer of the Original Code is
- * Michael Jumper.
- * Portions created by the Initial Developer are Copyright (C) 2010
- * the Initial Developer. All Rights Reserved.
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
  *
- * Contributor(s):
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
  *
- * Alternatively, the contents of this file may be used under the terms of
- * either the GNU General Public License Version 2 or later (the "GPL"), or
- * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
- * in which case the provisions of the GPL or the LGPL are applicable instead
- * of those above. If you wish to allow use of your version of this file only
- * under the terms of either the GPL or the LGPL, and not to allow others to
- * use your version of this file under the terms of the MPL, indicate your
- * decision by deleting the provisions above and replace them with the notice
- * and other provisions required by the GPL or the LGPL. If you do not delete
- * the provisions above, a recipient may use your version of this file under
- * the terms of any one of the MPL, the GPL or the LGPL.
- *
- * ***** END LICENSE BLOCK ***** */
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net;
+
 
 import java.io.IOException;
 import java.io.InputStreamReader;
@@ -160,5 +146,4 @@ public class SSLGuacamoleSocket implements GuacamoleSocket {
         return !sock.isClosed();
     }
 
-
 }
diff --git a/guacamole-common/src/main/java/org/glyptodon/guacamole/net/SimpleGuacamoleTunnel.java b/guacamole-common/src/main/java/org/glyptodon/guacamole/net/SimpleGuacamoleTunnel.java
new file mode 100644
index 0000000..f0c28cb
--- /dev/null
+++ b/guacamole-common/src/main/java/org/glyptodon/guacamole/net/SimpleGuacamoleTunnel.java
@@ -0,0 +1,69 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net;
+
+
+import java.util.UUID;
+
+/**
+ * GuacamoleTunnel implementation which uses a provided socket. The UUID of
+ * the tunnel will be randomly generated.
+ *
+ * @author Michael Jumper
+ */
+public class SimpleGuacamoleTunnel extends AbstractGuacamoleTunnel {
+
+    /**
+     * The UUID associated with this tunnel. Every tunnel must have a
+     * corresponding UUID such that tunnel read/write requests can be
+     * directed to the proper tunnel.
+     */
+    private final UUID uuid = UUID.randomUUID();
+
+    /**
+     * The GuacamoleSocket that tunnel should use for communication on
+     * behalf of the connecting user.
+     */
+    private final GuacamoleSocket socket;
+
+    /**
+     * Creates a new GuacamoleTunnel which synchronizes access to the
+     * Guacamole instruction stream associated with the given GuacamoleSocket.
+     *
+     * @param socket The GuacamoleSocket to provide synchronized access for.
+     */
+    public SimpleGuacamoleTunnel(GuacamoleSocket socket) {
+        this.socket = socket;
+    }
+
+    @Override
+    public UUID getUUID() {
+        return uuid;
+    }
+
+    @Override
+    public GuacamoleSocket getSocket() {
+        return socket;
+    }
+
+}
diff --git a/guacamole-common/src/main/java/org/glyptodon/guacamole/net/package-info.java b/guacamole-common/src/main/java/org/glyptodon/guacamole/net/package-info.java
index ae1a8d0..91f1489 100644
--- a/guacamole-common/src/main/java/org/glyptodon/guacamole/net/package-info.java
+++ b/guacamole-common/src/main/java/org/glyptodon/guacamole/net/package-info.java
@@ -1,3 +1,24 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
 
 /**
  * Classes which apply to network-specific concepts, such as low-level sockets
diff --git a/guacamole-common/src/main/java/org/glyptodon/guacamole/package-info.java b/guacamole-common/src/main/java/org/glyptodon/guacamole/package-info.java
index d947b5c..50d76cc 100644
--- a/guacamole-common/src/main/java/org/glyptodon/guacamole/package-info.java
+++ b/guacamole-common/src/main/java/org/glyptodon/guacamole/package-info.java
@@ -1,3 +1,24 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
 
 /**
  * All classes which apply generally across the Guacamole web application
diff --git a/guacamole-common/src/main/java/org/glyptodon/guacamole/protocol/ConfiguredGuacamoleSocket.java b/guacamole-common/src/main/java/org/glyptodon/guacamole/protocol/ConfiguredGuacamoleSocket.java
index 06308f4..ab5bffe 100644
--- a/guacamole-common/src/main/java/org/glyptodon/guacamole/protocol/ConfiguredGuacamoleSocket.java
+++ b/guacamole-common/src/main/java/org/glyptodon/guacamole/protocol/ConfiguredGuacamoleSocket.java
@@ -1,41 +1,27 @@
-
-package org.glyptodon.guacamole.protocol;
-
-/* ***** BEGIN LICENSE BLOCK *****
- * Version: MPL 1.1/GPL 2.0/LGPL 2.1
- *
- * The contents of this file are subject to the Mozilla Public License Version
- * 1.1 (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.mozilla.org/MPL/
- *
- * Software distributed under the License is distributed on an "AS IS" basis,
- * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- * for the specific language governing rights and limitations under the
- * License.
+/*
+ * Copyright (C) 2013 Glyptodon LLC
  *
- * The Original Code is guacamole-common.
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
  *
- * The Initial Developer of the Original Code is
- * Michael Jumper.
- * Portions created by the Initial Developer are Copyright (C) 2010
- * the Initial Developer. All Rights Reserved.
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
  *
- * Contributor(s):
- *
- * Alternatively, the contents of this file may be used under the terms of
- * either the GNU General Public License Version 2 or later (the "GPL"), or
- * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
- * in which case the provisions of the GPL or the LGPL are applicable instead
- * of those above. If you wish to allow use of your version of this file only
- * under the terms of either the GPL or the LGPL, and not to allow others to
- * use your version of this file under the terms of the MPL, indicate your
- * decision by deleting the provisions above and replace them with the notice
- * and other provisions required by the GPL or the LGPL. If you do not delete
- * the provisions above, a recipient may use your version of this file under
- * the terms of any one of the MPL, the GPL or the LGPL.
- *
- * ***** END LICENSE BLOCK ***** */
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.protocol;
+
 
 import java.util.List;
 import org.glyptodon.guacamole.GuacamoleException;
@@ -70,6 +56,39 @@ public class ConfiguredGuacamoleSocket implements GuacamoleSocket {
     private GuacamoleConfiguration config;
 
     /**
+     * The unique identifier associated with this connection, as determined
+     * by the "ready" instruction received from the Guacamole proxy.
+     */
+    private String id;
+    
+    /**
+     * Waits for the instruction having the given opcode, returning that
+     * instruction once it has been read. If the instruction is never read,
+     * an exception is thrown.
+     * 
+     * @param reader The reader to read instructions from.
+     * @param opcode The opcode of the instruction we are expecting.
+     * @return The instruction having the given opcode.
+     * @throws GuacamoleException If an error occurs while reading, or if
+     *                            the expected instruction is not read.
+     */
+    private GuacamoleInstruction expect(GuacamoleReader reader, String opcode)
+        throws GuacamoleException {
+
+        // Wait for an instruction
+        GuacamoleInstruction instruction = reader.readInstruction();
+        if (instruction == null)
+            throw new GuacamoleServerException("End of stream while waiting for \"" + opcode + "\".");
+
+        // Ensure instruction has expected opcode
+        if (!instruction.getOpcode().equals(opcode))
+            throw new GuacamoleServerException("Expected \"" + opcode + "\" instruction but instead received \"" + instruction.getOpcode() + "\".");
+
+        return instruction;
+
+    }
+ 
+    /**
      * Creates a new ConfiguredGuacamoleSocket which uses the given
      * GuacamoleConfiguration to complete the initial protocol handshake over
      * the given GuacamoleSocket. A default GuacamoleClientInformation object
@@ -86,7 +105,6 @@ public class ConfiguredGuacamoleSocket implements GuacamoleSocket {
         this(socket, config, new GuacamoleClientInformation());
     }
 
-
     /**
      * Creates a new ConfiguredGuacamoleSocket which uses the given
      * GuacamoleConfiguration and GuacamoleClientInformation to complete the
@@ -111,22 +129,19 @@ public class ConfiguredGuacamoleSocket implements GuacamoleSocket {
         GuacamoleReader reader = socket.getReader();
         GuacamoleWriter writer = socket.getWriter();
 
-        // Send protocol
-        writer.writeInstruction(new GuacamoleInstruction("select", config.getProtocol()));
-
-        // Wait for server args
-        GuacamoleInstruction instruction;
-        do {
+        // Get protocol / connection ID
+        String select_arg = config.getConnectionID();
+        if (select_arg == null)
+            select_arg = config.getProtocol();
 
-            // Read instruction, fail if end-of-stream
-            instruction = reader.readInstruction();
-            if (instruction == null)
-                throw new GuacamoleServerException("End of stream during initial handshake.");
+        // Send requested protocol or connection ID
+        writer.writeInstruction(new GuacamoleInstruction("select", select_arg));
 
-        } while (!instruction.getOpcode().equals("args"));
+        // Wait for server args
+        GuacamoleInstruction args = expect(reader, "args");
 
         // Build args list off provided names and config
-        List<String> arg_names = instruction.getArgs();
+        List<String> arg_names = args.getArgs();
         String[] arg_values = new String[arg_names.size()];
         for (int i=0; i<arg_names.size(); i++) {
 
@@ -149,7 +164,8 @@ public class ConfiguredGuacamoleSocket implements GuacamoleSocket {
             new GuacamoleInstruction(
                 "size",
                 Integer.toString(info.getOptimalScreenWidth()),
-                Integer.toString(info.getOptimalScreenHeight())
+                Integer.toString(info.getOptimalScreenHeight()),
+                Integer.toString(info.getOptimalResolution())
             )
         );
 
@@ -167,9 +183,25 @@ public class ConfiguredGuacamoleSocket implements GuacamoleSocket {
                     info.getVideoMimetypes().toArray(new String[0])
                 ));
 
+        // Send supported image formats
+        writer.writeInstruction(
+                new GuacamoleInstruction(
+                    "image",
+                    info.getImageMimetypes().toArray(new String[0])
+                ));
+
         // Send args
         writer.writeInstruction(new GuacamoleInstruction("connect", arg_values));
 
+        // Wait for ready, store ID
+        GuacamoleInstruction ready = expect(reader, "ready");
+
+        List<String> ready_args = ready.getArgs();
+        if (ready_args.isEmpty())
+            throw new GuacamoleServerException("No connection ID received");
+
+        id = ready.getArgs().get(0);
+
     }
 
     /**
@@ -183,6 +215,17 @@ public class ConfiguredGuacamoleSocket implements GuacamoleSocket {
         return config;
     }
 
+    /**
+     * Returns the unique ID associated with the Guacamole connection
+     * negotiated by this ConfiguredGuacamoleSocket. The ID is provided by
+     * the "ready" instruction returned by the Guacamole proxy.
+     * 
+     * @return The ID of the negotiated Guacamole connection.
+     */
+    public String getConnectionID() {
+        return id;
+    }
+
     @Override
     public GuacamoleWriter getWriter() {
         return socket.getWriter();
diff --git a/guacamole-common/src/main/java/org/glyptodon/guacamole/protocol/FilteredGuacamoleReader.java b/guacamole-common/src/main/java/org/glyptodon/guacamole/protocol/FilteredGuacamoleReader.java
new file mode 100644
index 0000000..2692e18
--- /dev/null
+++ b/guacamole-common/src/main/java/org/glyptodon/guacamole/protocol/FilteredGuacamoleReader.java
@@ -0,0 +1,99 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.protocol;
+
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.io.GuacamoleReader;
+
+/**
+ * GuacamoleReader which applies a given GuacamoleFilter to observe or alter all
+ * read instructions. Instructions may also be dropped or denied by the the
+ * filter.
+ *
+ * @author Michael Jumper
+ */
+public class FilteredGuacamoleReader implements GuacamoleReader {
+
+    /**
+     * The wrapped GuacamoleReader.
+     */
+    private final GuacamoleReader reader;
+
+    /**
+     * The filter to apply when reading instructions.
+     */
+    private final GuacamoleFilter filter;
+
+    /**
+     * Wraps the given GuacamoleReader, applying the given filter to all read
+     * instructions. Future reads will return only instructions which pass
+     * the filter.
+     *
+     * @param reader The GuacamoleReader to wrap.
+     * @param filter The filter which dictates which instructions are read, and
+     *               how.
+     */
+    public FilteredGuacamoleReader(GuacamoleReader reader, GuacamoleFilter filter) {
+        this.reader = reader;
+        this.filter = filter;
+    }
+    
+    @Override
+    public boolean available() throws GuacamoleException {
+        return reader.available();
+    }
+
+    @Override
+    public char[] read() throws GuacamoleException {
+
+        GuacamoleInstruction filteredInstruction = readInstruction();
+        if (filteredInstruction == null)
+            return null;
+
+        return filteredInstruction.toString().toCharArray();
+        
+    }
+
+    @Override
+    public GuacamoleInstruction readInstruction() throws GuacamoleException {
+
+        GuacamoleInstruction filteredInstruction;
+
+        // Read and filter instructions until no instructions are dropped
+        do {
+
+            // Read next instruction
+            GuacamoleInstruction unfilteredInstruction = reader.readInstruction();
+            if (unfilteredInstruction == null)
+                return null;
+
+            // Apply filter
+            filteredInstruction = filter.filter(unfilteredInstruction);
+
+        } while (filteredInstruction == null);
+
+        return filteredInstruction;
+        
+    }
+
+}
diff --git a/guacamole-common/src/main/java/org/glyptodon/guacamole/protocol/FilteredGuacamoleSocket.java b/guacamole-common/src/main/java/org/glyptodon/guacamole/protocol/FilteredGuacamoleSocket.java
new file mode 100644
index 0000000..b6b9635
--- /dev/null
+++ b/guacamole-common/src/main/java/org/glyptodon/guacamole/protocol/FilteredGuacamoleSocket.java
@@ -0,0 +1,102 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.protocol;
+
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.io.GuacamoleReader;
+import org.glyptodon.guacamole.io.GuacamoleWriter;
+import org.glyptodon.guacamole.net.GuacamoleSocket;
+
+/**
+ * Implementation of GuacamoleSocket which allows individual instructions to be
+ * intercepted, overridden, etc.
+ *
+ * @author Michael Jumper
+ */
+public class FilteredGuacamoleSocket implements GuacamoleSocket {
+
+    /**
+     * Wrapped GuacamoleSocket.
+     */
+    private final GuacamoleSocket socket;
+
+    /**
+     * A reader for the wrapped GuacamoleSocket which may be filtered.
+     */
+    private final GuacamoleReader reader;
+    
+    /**
+     * A writer for the wrapped GuacamoleSocket which may be filtered.
+     */
+    private final GuacamoleWriter writer;
+    
+    /**
+     * Creates a new FilteredGuacamoleSocket which uses the given filters to
+     * determine whether instructions read/written are allowed through,
+     * modified, etc. If reads or writes should be unfiltered, simply specify
+     * null rather than a particular filter.
+     *
+     * @param socket The GuacamoleSocket to wrap.
+     * @param readFilter The GuacamoleFilter to apply to all read instructions,
+     *                   if any.
+     * @param writeFilter The GuacamoleFilter to apply to all written 
+     *                    instructions, if any.
+     */
+    public FilteredGuacamoleSocket(GuacamoleSocket socket, GuacamoleFilter readFilter, GuacamoleFilter writeFilter) {
+        this.socket = socket;
+
+        // Apply filter to reader
+        if (readFilter != null)
+            reader = new FilteredGuacamoleReader(socket.getReader(), readFilter);
+        else
+            reader = socket.getReader();
+
+        // Apply filter to writer
+        if (writeFilter != null)
+            writer = new FilteredGuacamoleWriter(socket.getWriter(), writeFilter);
+        else
+            writer = socket.getWriter();
+
+    }
+    
+    @Override
+    public GuacamoleReader getReader() {
+        return reader;
+    }
+
+    @Override
+    public GuacamoleWriter getWriter() {
+        return writer;
+    }
+
+    @Override
+    public void close() throws GuacamoleException {
+        socket.close();
+    }
+
+    @Override
+    public boolean isOpen() {
+        return socket.isOpen();
+    }
+    
+}
diff --git a/guacamole-common/src/main/java/org/glyptodon/guacamole/protocol/FilteredGuacamoleWriter.java b/guacamole-common/src/main/java/org/glyptodon/guacamole/protocol/FilteredGuacamoleWriter.java
new file mode 100644
index 0000000..26d8ac6
--- /dev/null
+++ b/guacamole-common/src/main/java/org/glyptodon/guacamole/protocol/FilteredGuacamoleWriter.java
@@ -0,0 +1,107 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.protocol;
+
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.GuacamoleServerException;
+import org.glyptodon.guacamole.io.GuacamoleWriter;
+
+/**
+ * GuacamoleWriter which applies a given GuacamoleFilter to observe or alter
+ * all written instructions. Instructions may also be dropped or denied by
+ * the filter.
+ *
+ * @author Michael Jumper
+ */
+public class FilteredGuacamoleWriter implements GuacamoleWriter {
+
+    /**
+     * The wrapped GuacamoleWriter.
+     */
+    private final GuacamoleWriter writer;
+
+    /**
+     * The filter to apply when writing instructions.
+     */
+    private final GuacamoleFilter filter;
+
+    /**
+     * Parser for reading instructions prior to writing, such that they can be
+     * passed on to the filter.
+     */
+    private final GuacamoleParser parser = new GuacamoleParser();
+    
+    /**
+     * Wraps the given GuacamoleWriter, applying the given filter to all written 
+     * instructions. Future writes will only write instructions which pass
+     * the filter.
+     *
+     * @param writer The GuacamoleWriter to wrap.
+     * @param filter The filter which dictates which instructions are written,
+     *               and how.
+     */
+    public FilteredGuacamoleWriter(GuacamoleWriter writer, GuacamoleFilter filter) {
+        this.writer = writer;
+        this.filter = filter;
+    }
+ 
+    @Override
+    public void write(char[] chunk, int offset, int length) throws GuacamoleException {
+
+        // Write all data in chunk
+        while (length > 0) {
+
+            // Pass as much data through the parser as possible
+            int parsed;
+            while ((parsed = parser.append(chunk, offset, length)) != 0) {
+                offset += parsed;
+                length -= parsed;
+            }
+
+            // If no instruction is available, it must be incomplete
+            if (!parser.hasNext())
+                throw new GuacamoleServerException("Filtered write() contained an incomplete instruction.");
+
+            // Write single instruction through filter
+            writeInstruction(parser.next());
+
+        }
+        
+    }
+
+    @Override
+    public void write(char[] chunk) throws GuacamoleException {
+        write(chunk, 0, chunk.length);
+    }
+
+    @Override
+    public void writeInstruction(GuacamoleInstruction instruction) throws GuacamoleException {
+
+        // Write instruction only if not dropped
+        GuacamoleInstruction filteredInstruction = filter.filter(instruction);
+        if (filteredInstruction != null)
+            writer.writeInstruction(filteredInstruction);
+
+    }
+
+}
diff --git a/guacamole-common/src/main/java/org/glyptodon/guacamole/protocol/GuacamoleClientInformation.java b/guacamole-common/src/main/java/org/glyptodon/guacamole/protocol/GuacamoleClientInformation.java
index 6cb10a2..3302fab 100644
--- a/guacamole-common/src/main/java/org/glyptodon/guacamole/protocol/GuacamoleClientInformation.java
+++ b/guacamole-common/src/main/java/org/glyptodon/guacamole/protocol/GuacamoleClientInformation.java
@@ -1,41 +1,27 @@
-
-package org.glyptodon.guacamole.protocol;
-
-/* ***** BEGIN LICENSE BLOCK *****
- * Version: MPL 1.1/GPL 2.0/LGPL 2.1
- *
- * The contents of this file are subject to the Mozilla Public License Version
- * 1.1 (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.mozilla.org/MPL/
- *
- * Software distributed under the License is distributed on an "AS IS" basis,
- * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- * for the specific language governing rights and limitations under the
- * License.
+/*
+ * Copyright (C) 2013 Glyptodon LLC
  *
- * The Original Code is guacamole-common.
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
  *
- * The Initial Developer of the Original Code is
- * Michael Jumper.
- * Portions created by the Initial Developer are Copyright (C) 2010
- * the Initial Developer. All Rights Reserved.
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
  *
- * Contributor(s):
- *
- * Alternatively, the contents of this file may be used under the terms of
- * either the GNU General Public License Version 2 or later (the "GPL"), or
- * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
- * in which case the provisions of the GPL or the LGPL are applicable instead
- * of those above. If you wish to allow use of your version of this file only
- * under the terms of either the GPL or the LGPL, and not to allow others to
- * use your version of this file under the terms of the MPL, indicate your
- * decision by deleting the provisions above and replace them with the notice
- * and other provisions required by the GPL or the LGPL. If you do not delete
- * the provisions above, a recipient may use your version of this file under
- * the terms of any one of the MPL, the GPL or the LGPL.
- *
- * ***** END LICENSE BLOCK ***** */
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.protocol;
+
 
 import java.util.ArrayList;
 import java.util.List;
@@ -59,14 +45,24 @@ public class GuacamoleClientInformation {
     private int optimalScreenHeight = 768;
 
     /**
-     * The list of audio mimetypes reported by the client to be supported.
+     * The resolution of the optimal dimensions given, in DPI.
      */
-    private List<String> audioMimetypes = new ArrayList<String>();
+    private int optimalResolution = 96;
 
     /**
      * The list of audio mimetypes reported by the client to be supported.
      */
-    private List<String> videoMimetypes = new ArrayList<String>();
+    private final List<String> audioMimetypes = new ArrayList<String>();
+
+    /**
+     * The list of video mimetypes reported by the client to be supported.
+     */
+    private final List<String> videoMimetypes = new ArrayList<String>();
+
+    /**
+     * The list of image mimetypes reported by the client to be supported.
+     */
+    private final List<String> imageMimetypes = new ArrayList<String>();
 
     /**
      * Returns the optimal screen width requested by the client, in pixels.
@@ -101,6 +97,26 @@ public class GuacamoleClientInformation {
     }
 
     /**
+     * Returns the resolution of the screen if the optimal width and height are
+     * used, in DPI.
+     * 
+     * @return The optimal screen resolution.
+     */
+    public int getOptimalResolution() {
+        return optimalResolution;
+    }
+
+    /**
+     * Sets the resolution of the screen if the optimal width and height are
+     * used, in DPI.
+     * 
+     * @param optimalResolution The optimal screen resolution in DPI.
+     */
+    public void setOptimalResolution(int optimalResolution) {
+        this.optimalResolution = optimalResolution;
+    }
+
+    /**
      * Returns the list of audio mimetypes supported by the client. To add or
      * removed supported mimetypes, the list returned by this function can be
      * modified.
@@ -122,4 +138,16 @@ public class GuacamoleClientInformation {
         return videoMimetypes;
     }
 
+    /**
+     * Returns the list of image mimetypes supported by the client. To add or
+     * removed supported mimetypes, the list returned by this function can be
+     * modified.
+     *
+     * @return
+     *     The set of image mimetypes supported by the client.
+     */
+    public List<String> getImageMimetypes() {
+        return imageMimetypes;
+    }
+
 }
diff --git a/guacamole-common/src/main/java/org/glyptodon/guacamole/protocol/GuacamoleConfiguration.java b/guacamole-common/src/main/java/org/glyptodon/guacamole/protocol/GuacamoleConfiguration.java
index 5b15a25..1a034b3 100644
--- a/guacamole-common/src/main/java/org/glyptodon/guacamole/protocol/GuacamoleConfiguration.java
+++ b/guacamole-common/src/main/java/org/glyptodon/guacamole/protocol/GuacamoleConfiguration.java
@@ -1,41 +1,27 @@
-
-package org.glyptodon.guacamole.protocol;
-
-/* ***** BEGIN LICENSE BLOCK *****
- * Version: MPL 1.1/GPL 2.0/LGPL 2.1
- *
- * The contents of this file are subject to the Mozilla Public License Version
- * 1.1 (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.mozilla.org/MPL/
- *
- * Software distributed under the License is distributed on an "AS IS" basis,
- * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- * for the specific language governing rights and limitations under the
- * License.
- *
- * The Original Code is guacamole-common.
+/*
+ * Copyright (C) 2013 Glyptodon LLC
  *
- * The Initial Developer of the Original Code is
- * Michael Jumper.
- * Portions created by the Initial Developer are Copyright (C) 2010
- * the Initial Developer. All Rights Reserved.
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
  *
- * Contributor(s):
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
  *
- * Alternatively, the contents of this file may be used under the terms of
- * either the GNU General Public License Version 2 or later (the "GPL"), or
- * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
- * in which case the provisions of the GPL or the LGPL are applicable instead
- * of those above. If you wish to allow use of your version of this file only
- * under the terms of either the GPL or the LGPL, and not to allow others to
- * use your version of this file under the terms of the MPL, indicate your
- * decision by deleting the provisions above and replace them with the notice
- * and other provisions required by the GPL or the LGPL. If you do not delete
- * the provisions above, a recipient may use your version of this file under
- * the terms of any one of the MPL, the GPL or the LGPL.
- *
- * ***** END LICENSE BLOCK ***** */
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.protocol;
+
 
 import java.io.Serializable;
 import java.util.Collections;
@@ -57,6 +43,12 @@ public class GuacamoleConfiguration implements Serializable {
     private static final long serialVersionUID = 1L;
 
     /**
+     * The ID of the connection being joined. If this value is present,
+     * the protocol need not be specified.
+     */
+    private String connectionID;
+    
+    /**
      * The name of the protocol associated with this configuration.
      */
     private String protocol;
@@ -64,7 +56,55 @@ public class GuacamoleConfiguration implements Serializable {
     /**
      * Map of all associated parameter values, indexed by parameter name.
      */
-    private Map<String, String> parameters = new HashMap<String, String>();
+    private final Map<String, String> parameters = new HashMap<String, String>();
+
+    /**
+     * Creates a new, blank GuacamoleConfiguration with its protocol, connection
+     * ID, and parameters unset.
+     */
+    public GuacamoleConfiguration() {
+    }
+
+    /**
+     * Copies the given GuacamoleConfiguration, creating a new, indepedent
+     * GuacamoleConfiguration containing the same protocol, connection ID,
+     * and parameter values, if any.
+     *
+     * @param config The GuacamoleConfiguration to copy.
+     */
+    public GuacamoleConfiguration(GuacamoleConfiguration config) {
+
+        // Copy protocol and connection ID
+        protocol = config.getProtocol();
+        connectionID = config.getConnectionID();
+
+        // Copy parameter values
+        for (String name : config.getParameterNames())
+            parameters.put(name, config.getParameter(name));
+
+    }
+
+    /**
+     * Returns the ID of the connection being joined, if any. If no connection
+     * is being joined, this returns null, and the protocol must be set.
+     *
+     * @return The ID of the connection being joined, or null if no connection
+     *         is being joined.
+     */
+    public String getConnectionID() {
+        return connectionID;
+    }
+
+    /**
+     * Sets the ID of the connection being joined, if any. If no connection
+     * is being joined, this value must be omitted, and the protocol must be
+     * set instead.
+     *
+     * @param connectionID The ID of the connection being joined.
+     */
+    public void setConnectionID(String connectionID) {
+        this.connectionID = connectionID;
+    }
 
     /**
      * Returns the name of the protocol to be used.
@@ -122,4 +162,31 @@ public class GuacamoleConfiguration implements Serializable {
         return Collections.unmodifiableSet(parameters.keySet());
     }
 
+    /**
+     * Returns a map which contains parameter name/value pairs as key/value
+     * pairs. Changes to this map will affect the parameters stored within
+     * this configuration.
+     *
+     * @return
+     *     A map which contains all parameter name/value pairs as key/value
+     *     pairs.
+     */
+    public Map<String, String> getParameters() {
+        return parameters;
+    }
+
+    /**
+     * Replaces all current parameters with the parameters defined within the
+     * given map. Key/value pairs within the map represent parameter name/value
+     * pairs.
+     *
+     * @param parameters
+     *     A map which contains all parameter name/value pairs as key/value
+     *     pairs.
+     */
+    public void setParameters(Map<String, String> parameters) {
+        this.parameters.clear();
+        this.parameters.putAll(parameters);
+    }
+
 }
diff --git a/guacamole-common/src/main/java/org/glyptodon/guacamole/protocol/GuacamoleFilter.java b/guacamole-common/src/main/java/org/glyptodon/guacamole/protocol/GuacamoleFilter.java
new file mode 100644
index 0000000..334e104
--- /dev/null
+++ b/guacamole-common/src/main/java/org/glyptodon/guacamole/protocol/GuacamoleFilter.java
@@ -0,0 +1,51 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.protocol;
+
+import org.glyptodon.guacamole.GuacamoleException;
+
+/**
+ * Interface which provides for the filtering of individual instructions. Each
+ * filtered instruction may be allowed through untouched, modified, replaced,
+ * dropped, or explicitly denied.
+ *
+ * @author Michael Jumper
+ */
+public interface GuacamoleFilter {
+
+    /**
+     * Applies the filter to the given instruction, returning the original
+     * instruction, a modified version of the original, or null, depending
+     * on the implementation.
+     *
+     * @param instruction The instruction to filter.
+     * @return The original instruction, if the instruction is to be allowed,
+     *         a modified version of the instruction, if the instruction is
+     *         to be overridden, or null, if the instruction is to be dropped.
+     * @throws GuacamoleException If an error occurs filtering the instruction,
+     *                            or if the instruction must be explicitly
+     *                            denied.
+     */
+    public GuacamoleInstruction filter(GuacamoleInstruction instruction) throws GuacamoleException;
+    
+}
diff --git a/guacamole-common/src/main/java/org/glyptodon/guacamole/protocol/GuacamoleInstruction.java b/guacamole-common/src/main/java/org/glyptodon/guacamole/protocol/GuacamoleInstruction.java
index 8b94fa1..2c050d4 100644
--- a/guacamole-common/src/main/java/org/glyptodon/guacamole/protocol/GuacamoleInstruction.java
+++ b/guacamole-common/src/main/java/org/glyptodon/guacamole/protocol/GuacamoleInstruction.java
@@ -1,41 +1,27 @@
-
-package org.glyptodon.guacamole.protocol;
-
-/* ***** BEGIN LICENSE BLOCK *****
- * Version: MPL 1.1/GPL 2.0/LGPL 2.1
- *
- * The contents of this file are subject to the Mozilla Public License Version
- * 1.1 (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.mozilla.org/MPL/
- *
- * Software distributed under the License is distributed on an "AS IS" basis,
- * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- * for the specific language governing rights and limitations under the
- * License.
- *
- * The Original Code is guacamole-common.
+/*
+ * Copyright (C) 2013 Glyptodon LLC
  *
- * The Initial Developer of the Original Code is
- * Michael Jumper.
- * Portions created by the Initial Developer are Copyright (C) 2010
- * the Initial Developer. All Rights Reserved.
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
  *
- * Contributor(s):
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
  *
- * Alternatively, the contents of this file may be used under the terms of
- * either the GNU General Public License Version 2 or later (the "GPL"), or
- * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
- * in which case the provisions of the GPL or the LGPL are applicable instead
- * of those above. If you wish to allow use of your version of this file only
- * under the terms of either the GPL or the LGPL, and not to allow others to
- * use your version of this file under the terms of the MPL, indicate your
- * decision by deleting the provisions above and replace them with the notice
- * and other provisions required by the GPL or the LGPL. If you do not delete
- * the provisions above, a recipient may use your version of this file under
- * the terms of any one of the MPL, the GPL or the LGPL.
- *
- * ***** END LICENSE BLOCK ***** */
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.protocol;
+
 
 import java.util.Arrays;
 import java.util.Collections;
@@ -73,6 +59,20 @@ public class GuacamoleInstruction {
     }
 
     /**
+     * Creates a new GuacamoleInstruction having the given Operation and
+     * list of arguments values. The list given will be used to back the
+     * internal list of arguments and the list returned by getArgs().
+     *
+     * @param opcode The opcode of the instruction to create.
+     * @param args The list of argument values to provide in the new
+     *             instruction if any.
+     */
+    public GuacamoleInstruction(String opcode, List<String> args) {
+        this.opcode = opcode;
+        this.args = Collections.unmodifiableList(args);
+    }
+
+    /**
      * Returns the opcode associated with this GuacamoleInstruction.
      * @return The opcode associated with this GuacamoleInstruction.
      */
diff --git a/guacamole-common/src/main/java/org/glyptodon/guacamole/protocol/GuacamoleParser.java b/guacamole-common/src/main/java/org/glyptodon/guacamole/protocol/GuacamoleParser.java
new file mode 100644
index 0000000..b061d6b
--- /dev/null
+++ b/guacamole-common/src/main/java/org/glyptodon/guacamole/protocol/GuacamoleParser.java
@@ -0,0 +1,244 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.protocol;
+
+import java.util.Arrays;
+import java.util.Iterator;
+import java.util.List;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.GuacamoleServerException;
+
+/**
+ * Parser for the Guacamole protocol. Arbitrary instruction data is appended,
+ * and instructions are returned as a result. Invalid instructions result in
+ * exceptions.
+ *
+ * @author Michael Jumper
+ */
+public class GuacamoleParser implements Iterator<GuacamoleInstruction> {
+
+    /**
+     * The maximum number of characters per instruction.
+     */
+    public static final int INSTRUCTION_MAX_LENGTH = 8192;
+
+    /**
+     * The maximum number of digits to allow per length prefix.
+     */
+    public static final int INSTRUCTION_MAX_DIGITS = 5;
+
+    /**
+     * The maximum number of elements per instruction, including the opcode.
+     */
+    public static final int INSTRUCTION_MAX_ELEMENTS = 64;
+
+    /**
+     * All possible states of the instruction parser.
+     */ 
+    private enum State {
+
+        /**
+         * The parser is currently waiting for data to complete the length prefix
+         * of the current element of the instruction.
+         */
+        PARSING_LENGTH,
+
+        /**
+         * The parser has finished reading the length prefix and is currently
+         * waiting for data to complete the content of the instruction.
+         */
+        PARSING_CONTENT,
+
+        /**
+         * The instruction has been fully parsed.
+         */
+        COMPLETE,
+
+        /**
+         * The instruction cannot be parsed because of a protocol error.
+         */
+        ERROR
+            
+    }
+
+    /**
+     * The latest parsed instruction, if any.
+     */
+    private GuacamoleInstruction parsedInstruction;
+
+    /**
+     * The parse state of the instruction.
+     */
+    private State state = State.PARSING_LENGTH;
+
+    /**
+     * The length of the current element, if known.
+     */
+    private int elementLength = 0;
+
+    /**
+     * The number of elements currently parsed.
+     */
+    private int elementCount = 0;
+
+    /**
+     * All currently parsed elements.
+     */
+    private final String elements[] = new String[INSTRUCTION_MAX_ELEMENTS];
+
+    /**
+     * Appends data from the given buffer to the current instruction.
+     * 
+     * @param chunk The buffer containing the data to append.
+     * @param offset The offset within the buffer where the data begins.
+     * @param length The length of the data to append.
+     * @return The number of characters appended, or 0 if complete instructions
+     *         have already been parsed and must be read via next() before
+     *         more data can be appended.
+     * @throws GuacamoleException If an error occurs while parsing the new data.
+     */
+    public int append(char chunk[], int offset, int length) throws GuacamoleException {
+
+        int charsParsed = 0;
+
+        // Do not exceed maximum number of elements
+        if (elementCount == INSTRUCTION_MAX_ELEMENTS && state != State.COMPLETE) {
+            state = State.ERROR;
+            throw new GuacamoleServerException("Instruction contains too many elements.");
+        }
+
+        // Parse element length
+        if (state == State.PARSING_LENGTH) {
+
+            int parsedLength = elementLength;
+            while (charsParsed < length) {
+
+                // Pull next character
+                char c = chunk[offset + charsParsed++];
+
+                // If digit, add to length
+                if (c >= '0' && c <= '9')
+                    parsedLength = parsedLength*10 + c - '0';
+
+                // If period, switch to parsing content
+                else if (c == '.') {
+                    state = State.PARSING_CONTENT;
+                    break;
+                }
+
+                // If not digit, parse error
+                else {
+                    state = State.ERROR;
+                    throw new GuacamoleServerException("Non-numeric character in element length.");
+                }
+
+            }
+
+            // If too long, parse error
+            if (parsedLength > INSTRUCTION_MAX_LENGTH) {
+                state = State.ERROR;
+                throw new GuacamoleServerException("Instruction exceeds maximum length.");
+            }
+
+            // Save length
+            elementLength = parsedLength;
+
+        } // end parse length
+
+        // Parse element content, if available
+        if (state == State.PARSING_CONTENT && charsParsed + elementLength + 1 <= length) {
+
+            // Read element
+            String element = new String(chunk, offset + charsParsed, elementLength);
+            charsParsed += elementLength;
+            elementLength = 0;
+
+            // Read terminator char following element
+            char terminator = chunk[offset + charsParsed++];
+
+            // Add element to currently parsed elements
+            elements[elementCount++] = element;
+            
+            // If semicolon, store end-of-instruction
+            if (terminator == ';') {
+                state = State.COMPLETE;
+                parsedInstruction = new GuacamoleInstruction(elements[0],
+                        Arrays.asList(elements).subList(1, elementCount));
+            }
+
+            // If comma, move on to next element
+            else if (terminator == ',')
+                state = State.PARSING_LENGTH;
+
+            // Otherwise, parse error
+            else {
+                state = State.ERROR;
+                throw new GuacamoleServerException("Element terminator of instruction was not ';' nor ','");
+            }
+
+        } // end parse content
+
+        return charsParsed;
+
+    }
+
+    /**
+     * Appends data from the given buffer to the current instruction.
+     * 
+     * @param chunk The data to append.
+     * @return The number of characters appended, or 0 if complete instructions
+     *         have already been parsed and must be read via next() before
+     *         more data can be appended.
+     * @throws GuacamoleException If an error occurs while parsing the new data.
+     */   
+    public int append(char chunk[]) throws GuacamoleException {
+        return append(chunk, 0, chunk.length);
+    }
+
+    @Override
+    public boolean hasNext() {
+        return state == State.COMPLETE;
+    }
+
+    @Override
+    public GuacamoleInstruction next() {
+
+        // No instruction to return if not yet complete
+        if (state != State.COMPLETE)
+            return null;
+        
+        // Reset for next instruction.
+        state = State.PARSING_LENGTH;
+        elementCount = 0;
+        elementLength = 0;
+        
+        return parsedInstruction;
+
+    }
+
+    @Override
+    public void remove() {
+        throw new UnsupportedOperationException("GuacamoleParser does not support remove().");
+    }
+
+}
diff --git a/guacamole-common/src/main/java/org/glyptodon/guacamole/protocol/GuacamoleStatus.java b/guacamole-common/src/main/java/org/glyptodon/guacamole/protocol/GuacamoleStatus.java
new file mode 100644
index 0000000..2ccefb0
--- /dev/null
+++ b/guacamole-common/src/main/java/org/glyptodon/guacamole/protocol/GuacamoleStatus.java
@@ -0,0 +1,173 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC.
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.protocol;
+
+/**
+ * All possible statuses returned by various Guacamole instructions, each having
+ * a corresponding code.
+ * 
+ * @author Michael Jumper
+ */
+public enum GuacamoleStatus {
+
+    /**
+     * The operation succeeded.
+     */
+    SUCCESS(200, 1000, 0x0000),
+
+    /**
+     * The requested operation is unsupported.
+     */
+    UNSUPPORTED(501, 1011, 0x0100),
+
+    /**
+     * The operation could not be performed due to an internal failure.
+     */
+    SERVER_ERROR(500, 1011, 0x0200),
+
+    /**
+     * The operation could not be performed as the server is busy.
+     */
+    SERVER_BUSY(503, 1008, 0x0201),
+
+    /**
+     * The operation could not be performed because the upstream server is not
+     * responding.
+     */
+    UPSTREAM_TIMEOUT(504, 1011, 0x0202),
+
+    /**
+     * The operation was unsuccessful due to an error or otherwise unexpected
+     * condition of the upstream server.
+     */
+    UPSTREAM_ERROR(502, 1011, 0x0203),
+
+    /**
+     * The operation could not be performed as the requested resource does not
+     * exist.
+     */
+    RESOURCE_NOT_FOUND(404, 1002, 0x0204),
+
+    /**
+     * The operation could not be performed as the requested resource is already
+     * in use.
+     */
+    RESOURCE_CONFLICT(409, 1008, 0x0205),
+
+    /**
+     * The operation could not be performed because bad parameters were given.
+     */
+    CLIENT_BAD_REQUEST(400, 1002, 0x0300),
+
+    /**
+     * Permission was denied to perform the operation, as the user is not yet
+     * authorized (not yet logged in, for example). As HTTP 401 has implications
+     * for HTTP-specific authorization schemes, this status continues to map to
+     * HTTP 403 ("Forbidden"). To do otherwise would risk unintended effects.
+     */
+    CLIENT_UNAUTHORIZED(403, 1008, 0x0301),
+
+    /**
+     * Permission was denied to perform the operation, and this operation will
+     * not be granted even if the user is authorized.
+     */
+    CLIENT_FORBIDDEN(403, 1008, 0x0303),
+
+    /**
+     * The client took too long to respond.
+     */
+    CLIENT_TIMEOUT(408, 1002, 0x0308),
+
+    /**
+     * The client sent too much data.
+     */
+    CLIENT_OVERRUN(413, 1009, 0x030D),
+
+    /**
+     * The client sent data of an unsupported or unexpected type.
+     */
+    CLIENT_BAD_TYPE(415, 1003, 0x030F),
+
+    /**
+     * The operation failed because the current client is already using too
+     * many resources.
+     */
+    CLIENT_TOO_MANY(429, 1008, 0x031D);
+
+    /**
+     * The most applicable HTTP error code.
+     */
+    private final int http_code;
+
+    /**
+     * The most applicable WebSocket error code.
+     */
+    private final int websocket_code;
+    
+    /**
+     * The Guacamole protocol status code.
+     */
+    private final int guac_code;
+
+    /**
+     * Initializes a GuacamoleStatusCode with the given HTTP and Guacamole
+     * status/error code values.
+     * 
+     * @param http_code The most applicable HTTP error code.
+     * @param websocket_code The most applicable WebSocket error code.
+     * @param guac_code The Guacamole protocol status code.
+     */
+    private GuacamoleStatus(int http_code, int websocket_code, int guac_code) {
+        this.http_code = http_code;
+        this.websocket_code = websocket_code;
+        this.guac_code = guac_code;
+    }
+
+    /**
+     * Returns the most applicable HTTP error code.
+     * 
+     * @return The most applicable HTTP error code.
+     */
+    public int getHttpStatusCode() {
+        return http_code;
+    }
+
+    /**
+     * Returns the most applicable HTTP error code.
+     * 
+     * @return The most applicable HTTP error code.
+     */
+    public int getWebSocketCode() {
+        return websocket_code;
+    }
+
+    /**
+     * Returns the corresponding Guacamole protocol status code.
+     * 
+     * @return The corresponding Guacamole protocol status code.
+     */
+    public int getGuacamoleStatusCode() {
+        return guac_code;
+    }
+    
+}
diff --git a/guacamole-common/src/main/java/org/glyptodon/guacamole/protocol/package-info.java b/guacamole-common/src/main/java/org/glyptodon/guacamole/protocol/package-info.java
index 7b2b962..00eb6ef 100644
--- a/guacamole-common/src/main/java/org/glyptodon/guacamole/protocol/package-info.java
+++ b/guacamole-common/src/main/java/org/glyptodon/guacamole/protocol/package-info.java
@@ -1,3 +1,24 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
 
 /**
  * Classes relating directly to the Guacamole protocol.
diff --git a/guacamole-common/src/main/java/org/glyptodon/guacamole/servlet/GuacamoleHTTPTunnel.java b/guacamole-common/src/main/java/org/glyptodon/guacamole/servlet/GuacamoleHTTPTunnel.java
new file mode 100644
index 0000000..40f47ea
--- /dev/null
+++ b/guacamole-common/src/main/java/org/glyptodon/guacamole/servlet/GuacamoleHTTPTunnel.java
@@ -0,0 +1,78 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.servlet;
+
+import org.glyptodon.guacamole.net.DelegatingGuacamoleTunnel;
+import org.glyptodon.guacamole.net.GuacamoleTunnel;
+
+/**
+ * Tracks the last time a particular GuacamoleTunnel was accessed. This
+ * information is not necessary for tunnels associated with WebSocket
+ * connections, as each WebSocket connection has its own read thread which
+ * continuously checks the state of the tunnel and which will automatically
+ * timeout when the underlying socket times out, but the HTTP tunnel has no
+ * such thread. Because the HTTP tunnel requires the stream to be split across
+ * multiple requests, tracking of activity on the tunnel must be performed
+ * independently of the HTTP requests.
+ *
+ * @author Michael Jumper
+ */
+class GuacamoleHTTPTunnel extends DelegatingGuacamoleTunnel {
+
+    /**
+     * The last time this tunnel was accessed.
+     */
+    private long lastAccessedTime;
+
+    /**
+     * Creates a new GuacamoleHTTPTunnel which wraps the given tunnel.
+     * Absolutely all function calls on this new GuacamoleHTTPTunnel will be
+     * delegated to the underlying GuacamoleTunnel.
+     *
+     * @param wrappedTunnel
+     *     The GuacamoleTunnel to wrap within this GuacamoleHTTPTunnel.
+     */
+    public GuacamoleHTTPTunnel(GuacamoleTunnel wrappedTunnel) {
+        super(wrappedTunnel);
+    }
+
+    /**
+     * Updates this tunnel, marking it as recently accessed.
+     */
+    public void access() {
+        lastAccessedTime = System.currentTimeMillis();
+    }
+
+    /**
+     * Returns the time this tunnel was last accessed, as the number of
+     * milliseconds since midnight January 1, 1970 GMT. Tunnel access must
+     * be explicitly marked through calls to the access() function.
+     *
+     * @return
+     *     The time this tunnel was last accessed.
+     */
+    public long getLastAccessedTime() {
+        return lastAccessedTime;
+    }
+
+}
diff --git a/guacamole-common/src/main/java/org/glyptodon/guacamole/servlet/GuacamoleHTTPTunnelMap.java b/guacamole-common/src/main/java/org/glyptodon/guacamole/servlet/GuacamoleHTTPTunnelMap.java
new file mode 100644
index 0000000..184d933
--- /dev/null
+++ b/guacamole-common/src/main/java/org/glyptodon/guacamole/servlet/GuacamoleHTTPTunnelMap.java
@@ -0,0 +1,213 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.servlet;
+
+import java.util.Iterator;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.net.GuacamoleTunnel;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Map-style object which tracks in-use HTTP tunnels, automatically removing
+ * and closing tunnels which have not been used recently. This class is
+ * intended for use only within the GuacamoleHTTPTunnelServlet implementation,
+ * and has no real utility outside that implementation.
+ *
+ * @author Michael Jumper
+ */
+class GuacamoleHTTPTunnelMap {
+
+    /**
+     * Logger for this class.
+     */
+    private static final Logger logger = LoggerFactory.getLogger(GuacamoleHTTPTunnelMap.class);
+
+    /**
+     * The number of seconds to wait between tunnel accesses before timing out
+     * Note that this will be enforced only within a factor of 2. If a tunnel
+     * is unused, it will take between TUNNEL_TIMEOUT and TUNNEL_TIMEOUT*2
+     * seconds before that tunnel is closed and removed.
+     */
+    private static final int TUNNEL_TIMEOUT = 15;
+
+    /**
+     * Executor service which runs the periodic tunnel timeout task.
+     */
+    private final ScheduledExecutorService executor = Executors.newScheduledThreadPool(1);
+
+    /**
+     * Map of all tunnels that are using HTTP, indexed by tunnel UUID.
+     */
+    private final ConcurrentMap<String, GuacamoleHTTPTunnel> tunnelMap =
+            new ConcurrentHashMap<String, GuacamoleHTTPTunnel>();
+
+    /**
+     * Creates a new GuacamoleHTTPTunnelMap which automatically closes and
+     * removes HTTP tunnels which are no longer in use.
+     */
+    public GuacamoleHTTPTunnelMap() {
+
+        // Check for unused tunnels every few seconds
+        executor.scheduleAtFixedRate(
+            new TunnelTimeoutTask(TUNNEL_TIMEOUT * 1000l),
+            TUNNEL_TIMEOUT, TUNNEL_TIMEOUT, TimeUnit.SECONDS);
+
+    }
+
+    /**
+     * Task which iterates through all registered tunnels, removing and those
+     * tunnels which have not been accessed for a given number of milliseconds.
+     */
+    private class TunnelTimeoutTask implements Runnable {
+
+        /**
+         * The maximum amount of time to allow between accesses to any one
+         * HTTP tunnel, in milliseconds.
+         */
+        private final long tunnelTimeout;
+
+        /**
+         * Creates a new task which automatically closes and removes tunnels
+         * which have not been accessed for at least the given number of
+         * milliseconds.
+         *
+         * @param tunnelTimeout
+         *     The maximum amount of time to allow between separate tunnel
+         *     read/write requests, in milliseconds.
+         */
+        public TunnelTimeoutTask(long tunnelTimeout) {
+            this.tunnelTimeout = tunnelTimeout;
+        }
+
+        @Override
+        public void run() {
+
+            // Get current time
+            long now = System.currentTimeMillis();
+
+            // For each tunnel, close and remove any tunnels which have expired
+            Iterator<Map.Entry<String, GuacamoleHTTPTunnel>> entries = tunnelMap.entrySet().iterator();
+            while (entries.hasNext()) {
+
+                Map.Entry<String, GuacamoleHTTPTunnel> entry = entries.next();
+                GuacamoleHTTPTunnel tunnel = entry.getValue();
+
+                // Get elapsed time since last access
+                long age = now - tunnel.getLastAccessedTime();
+
+                // If tunnel is too old, close and remove it
+                if (age >= tunnelTimeout) {
+
+                    // Remove old entry
+                    logger.debug("HTTP tunnel \"{}\" has timed out.", entry.getKey());
+                    entries.remove();
+
+                    // Attempt to close tunnel
+                    try {
+                        tunnel.close();
+                    }
+                    catch (GuacamoleException e) {
+                        logger.debug("Unable to close expired HTTP tunnel.", e);
+                    }
+
+                }
+
+            } // end for each tunnel
+
+        } // end timeout task run()
+
+    }
+
+    /**
+     * Returns the GuacamoleTunnel having the given UUID, wrapped within a
+     * GuacamoleHTTPTunnel. If the no tunnel having the given UUID is
+     * available, null is returned.
+     *
+     * @param uuid
+     *     The UUID of the tunnel to retrieve.
+     *
+     * @return
+     *     The GuacamoleTunnel having the given UUID, wrapped within a
+     *     GuacamoleHTTPTunnel, if such a tunnel exists, or null if there is no
+     *     such tunnel.
+     */
+    public GuacamoleHTTPTunnel get(String uuid) {
+
+        // Update the last access time
+        GuacamoleHTTPTunnel tunnel = tunnelMap.get(uuid);
+        if (tunnel != null)
+            tunnel.access();
+
+        // Return tunnel, if any
+        return tunnel;
+
+    }
+
+    /**
+     * Registers that a new connection has been established using HTTP via the
+     * given GuacamoleTunnel.
+     *
+     * @param uuid
+     *     The UUID of the tunnel being added (registered).
+     *
+     * @param tunnel
+     *     The GuacamoleTunnel being registered, its associated connection
+     *     having just been established via HTTP.
+     */
+    public void put(String uuid, GuacamoleTunnel tunnel) {
+        tunnelMap.put(uuid, new GuacamoleHTTPTunnel(tunnel));
+    }
+
+    /**
+     * Removes the GuacamoleTunnel having the given UUID, if such a tunnel
+     * exists. The original tunnel is returned wrapped within a
+     * GuacamoleHTTPTunnel.
+     *
+     * @param uuid
+     *     The UUID of the tunnel to remove (deregister).
+     *
+     * @return
+     *     The GuacamoleTunnel having the given UUID, if such a tunnel exists,
+     *     wrapped within a GuacamoleHTTPTunnel, or null if no such tunnel
+     *     exists and no removal was performed.
+     */
+    public GuacamoleHTTPTunnel remove(String uuid) {
+        return tunnelMap.remove(uuid);
+    }
+
+    /**
+     * Shuts down this tunnel map, disallowing future tunnels from being
+     * registered and reclaiming any resources.
+     */
+    public void shutdown() {
+        executor.shutdownNow();
+    }
+
+}
diff --git a/guacamole-common/src/main/java/org/glyptodon/guacamole/servlet/GuacamoleHTTPTunnelServlet.java b/guacamole-common/src/main/java/org/glyptodon/guacamole/servlet/GuacamoleHTTPTunnelServlet.java
index 05e7781..6dc6fc0 100644
--- a/guacamole-common/src/main/java/org/glyptodon/guacamole/servlet/GuacamoleHTTPTunnelServlet.java
+++ b/guacamole-common/src/main/java/org/glyptodon/guacamole/servlet/GuacamoleHTTPTunnelServlet.java
@@ -1,40 +1,26 @@
-package org.glyptodon.guacamole.servlet;
-
-/* ***** BEGIN LICENSE BLOCK *****
- * Version: MPL 1.1/GPL 2.0/LGPL 2.1
- *
- * The contents of this file are subject to the Mozilla Public License Version
- * 1.1 (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.mozilla.org/MPL/
- *
- * Software distributed under the License is distributed on an "AS IS" basis,
- * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- * for the specific language governing rights and limitations under the
- * License.
- *
- * The Original Code is guacamole-common.
- *
- * The Initial Developer of the Original Code is
- * Michael Jumper.
- * Portions created by the Initial Developer are Copyright (C) 2010
- * the Initial Developer. All Rights Reserved.
+/*
+ * Copyright (C) 2015 Glyptodon LLC
  *
- * Contributor(s):
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
  *
- * Alternatively, the contents of this file may be used under the terms of
- * either the GNU General Public License Version 2 or later (the "GPL"), or
- * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
- * in which case the provisions of the GPL or the LGPL are applicable instead
- * of those above. If you wish to allow use of your version of this file only
- * under the terms of either the GPL or the LGPL, and not to allow others to
- * use your version of this file under the terms of the MPL, indicate your
- * decision by deleting the provisions above and replace them with the notice
- * and other provisions required by the GPL or the LGPL. If you do not delete
- * the provisions above, a recipient may use your version of this file under
- * the terms of any one of the MPL, the GPL or the LGPL.
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
  *
- * ***** END LICENSE BLOCK ***** */
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.servlet;
 
 import java.io.BufferedWriter;
 import java.io.IOException;
@@ -46,15 +32,15 @@ import javax.servlet.ServletException;
 import javax.servlet.http.HttpServlet;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
-import javax.servlet.http.HttpSession;
 import org.glyptodon.guacamole.GuacamoleClientException;
+import org.glyptodon.guacamole.GuacamoleConnectionClosedException;
 import org.glyptodon.guacamole.GuacamoleException;
 import org.glyptodon.guacamole.GuacamoleResourceNotFoundException;
-import org.glyptodon.guacamole.GuacamoleSecurityException;
 import org.glyptodon.guacamole.GuacamoleServerException;
 import org.glyptodon.guacamole.io.GuacamoleReader;
 import org.glyptodon.guacamole.io.GuacamoleWriter;
 import org.glyptodon.guacamole.net.GuacamoleTunnel;
+import org.glyptodon.guacamole.protocol.GuacamoleStatus;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -69,7 +55,12 @@ public abstract class GuacamoleHTTPTunnelServlet extends HttpServlet {
     /**
      * Logger for this class.
      */
-    private Logger logger = LoggerFactory.getLogger(GuacamoleHTTPTunnelServlet.class);
+    private final Logger logger = LoggerFactory.getLogger(GuacamoleHTTPTunnelServlet.class);
+
+    /**
+     * Map of absolutely all active tunnels using HTTP, indexed by tunnel UUID.
+     */
+    private final GuacamoleHTTPTunnelMap tunnels = new GuacamoleHTTPTunnelMap();
 
     /**
      * The prefix of the query string which denotes a tunnel read operation.
@@ -96,6 +87,56 @@ public abstract class GuacamoleHTTPTunnelServlet extends HttpServlet {
      */
     private static final int UUID_LENGTH = 36;
 
+    /**
+     * Registers the given tunnel such that future read/write requests to that
+     * tunnel will be properly directed.
+     *
+     * @param tunnel
+     *     The tunnel to register.
+     */
+    protected void registerTunnel(GuacamoleTunnel tunnel) {
+        tunnels.put(tunnel.getUUID().toString(), tunnel);
+        logger.debug("Registered tunnel \"{}\".", tunnel.getUUID());
+    }
+
+    /**
+     * Deregisters the given tunnel such that future read/write requests to
+     * that tunnel will be rejected.
+     *
+     * @param tunnel
+     *     The tunnel to deregister.
+     */
+    protected void deregisterTunnel(GuacamoleTunnel tunnel) {
+        tunnels.remove(tunnel.getUUID().toString());
+        logger.debug("Deregistered tunnel \"{}\".", tunnel.getUUID());
+    }
+
+    /**
+     * Returns the tunnel with the given UUID, if it has been registered with
+     * registerTunnel() and not yet deregistered with deregisterTunnel().
+     *
+     * @param tunnelUUID
+     *     The UUID of registered tunnel.
+     *
+     * @return
+     *     The tunnel corresponding to the given UUID.
+     *
+     * @throws GuacamoleException
+     *     If the requested tunnel does not exist because it has not yet been
+     *     registered or it has been deregistered.
+     */
+    protected GuacamoleTunnel getTunnel(String tunnelUUID)
+            throws GuacamoleException {
+
+        // Pull tunnel from map
+        GuacamoleTunnel tunnel = tunnels.get(tunnelUUID);
+        if (tunnel == null)
+            throw new GuacamoleResourceNotFoundException("No such tunnel.");
+
+        return tunnel;
+
+    }
+
     @Override
     protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException {
         handleTunnelRequest(request, response);
@@ -107,21 +148,33 @@ public abstract class GuacamoleHTTPTunnelServlet extends HttpServlet {
     }
 
     /**
-     * Sends an error on the given HTTP response with the given integer error
-     * code.
+     * Sends an error on the given HTTP response using the information within
+     * the given GuacamoleStatus.
+     *
+     * @param response
+     *     The HTTP response to use to send the error.
+     *
+     * @param guacStatus
+     *     The status to send
      *
-     * @param response The HTTP response to use to send the error.
-     * @param code The HTTP status code of the error.
-     * @throws ServletException If an error prevents sending of the error
-     *                          code.
+     * @param message
+     *     A human-readable message that can be presented to the user.
+     *
+     * @throws ServletException
+     *     If an error prevents sending of the error code.
      */
-    private void sendError(HttpServletResponse response, int code) throws ServletException {
+    protected void sendError(HttpServletResponse response,
+            GuacamoleStatus guacStatus, String message)
+            throws ServletException {
 
         try {
 
-            // If response not committed, send error code
-            if (!response.isCommitted())
-                response.sendError(code);
+            // If response not committed, send error code and message
+            if (!response.isCommitted()) {
+                response.addHeader("Guacamole-Status-Code", Integer.toString(guacStatus.getGuacamoleStatusCode()));
+                response.addHeader("Guacamole-Error-Message", message);
+                response.sendError(guacStatus.getHttpStatusCode());
+            }
 
         }
         catch (IOException ioe) {
@@ -134,19 +187,23 @@ public abstract class GuacamoleHTTPTunnelServlet extends HttpServlet {
 
     }
 
-
-
     /**
      * Dispatches every HTTP GET and POST request to the appropriate handler
      * function based on the query string.
      *
-     * @param request The HttpServletRequest associated with the GET or POST
-     *                request received.
-     * @param response The HttpServletResponse associated with the GET or POST
-     *                 request received.
-     * @throws ServletException If an error occurs while servicing the request.
+     * @param request
+     *     The HttpServletRequest associated with the GET or POST request
+     *     received.
+     *
+     * @param response
+     *     The HttpServletResponse associated with the GET or POST request
+     *     received.
+     *
+     * @throws ServletException
+     *     If an error occurs while servicing the request.
      */
-    protected void handleTunnelRequest(HttpServletRequest request, HttpServletResponse response) throws ServletException {
+    protected void handleTunnelRequest(HttpServletRequest request,
+            HttpServletResponse response) throws ServletException {
 
         try {
 
@@ -161,14 +218,8 @@ public abstract class GuacamoleHTTPTunnelServlet extends HttpServlet {
                 GuacamoleTunnel tunnel = doConnect(request);
                 if (tunnel != null) {
 
-                    // Get session
-                    HttpSession httpSession = request.getSession(true);
-                    GuacamoleSession session = new GuacamoleSession(httpSession);
-
-                    // Attach tunnel to session
-                    session.attachTunnel(tunnel);
-
-                    logger.info("Connection from {} succeeded.", request.getRemoteAddr());
+                    // Register newly-created tunnel
+                    registerTunnel(tunnel);
 
                     try {
                         // Ensure buggy browsers do not cache response
@@ -184,10 +235,8 @@ public abstract class GuacamoleHTTPTunnelServlet extends HttpServlet {
                 }
 
                 // Failed to connect
-                else {
-                    logger.info("Connection from {} failed.", request.getRemoteAddr());
+                else
                     throw new GuacamoleResourceNotFoundException("No tunnel created.");
-                }
 
             }
 
@@ -212,69 +261,67 @@ public abstract class GuacamoleHTTPTunnelServlet extends HttpServlet {
 
         // Catch any thrown guacamole exception and attempt to pass within the
         // HTTP response, logging each error appropriately.
-        catch (GuacamoleSecurityException e) {
-            logger.warn("Authorization failed.", e);
-            sendError(response, HttpServletResponse.SC_FORBIDDEN);
-        }
-        catch (GuacamoleResourceNotFoundException e) {
-            logger.debug("Resource not found.", e);
-            sendError(response, HttpServletResponse.SC_NOT_FOUND);
-        }
         catch (GuacamoleClientException e) {
-            logger.warn("Error in client request.", e);
-            sendError(response, HttpServletResponse.SC_BAD_REQUEST);
+            logger.warn("HTTP tunnel request rejected: {}", e.getMessage());
+            sendError(response, e.getStatus(), e.getMessage());
         }
         catch (GuacamoleException e) {
-            logger.error("Server error in tunnel", e);
-            sendError(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
+            logger.error("HTTP tunnel request failed: {}", e.getMessage());
+            logger.debug("Internal error in HTTP tunnel.", e);
+            sendError(response, e.getStatus(), "Internal server error.");
         }
 
     }
 
     /**
      * Called whenever the JavaScript Guacamole client makes a connection
-     * request. It it up to the implementor of this function to define what
-     * conditions must be met for a tunnel to be configured and returned as a
-     * result of this connection request (whether some sort of credentials must
-     * be specified, for example).
+     * request via HTTP. It it up to the implementor of this function to define
+     * what conditions must be met for a tunnel to be configured and returned
+     * as a result of this connection request (whether some sort of credentials
+     * must be specified, for example).
+     *
+     * @param request
+     *     The HttpServletRequest associated with the connection request
+     *     received. Any parameters specified along with the connection request
+     *     can be read from this object.
+     *
+     * @return
+     *     A newly constructed GuacamoleTunnel if successful, null otherwise.
      *
-     * @param request The HttpServletRequest associated with the connection
-     *                request received. Any parameters specified along with
-     *                the connection request can be read from this object.
-     * @return A newly constructed GuacamoleTunnel if successful,
-     *         null otherwise.
-     * @throws GuacamoleException If an error occurs while constructing the
-     *                            GuacamoleTunnel, or if the conditions
-     *                            required for connection are not met.
+     * @throws GuacamoleException
+     *     If an error occurs while constructing the GuacamoleTunnel, or if the
+     *     conditions required for connection are not met.
      */
-    protected abstract GuacamoleTunnel doConnect(HttpServletRequest request) throws GuacamoleException;
+    protected abstract GuacamoleTunnel doConnect(HttpServletRequest request)
+            throws GuacamoleException;
 
     /**
      * Called whenever the JavaScript Guacamole client makes a read request.
      * This function should in general not be overridden, as it already
      * contains a proper implementation of the read operation.
      *
-     * @param request The HttpServletRequest associated with the read request
-     *                received.
-     * @param response The HttpServletResponse associated with the write request
-     *                 received. Any data to be sent to the client in response
-     *                 to the write request should be written to the response
-     *                 body of this HttpServletResponse.
-     * @param tunnelUUID The UUID of the tunnel to read from, as specified in
-     *                   the write request. This tunnel must be attached to
-     *                   the Guacamole session.
-     * @throws GuacamoleException If an error occurs while handling the read
-     *                            request.
+     * @param request
+     *     The HttpServletRequest associated with the read request received.
+     *
+     * @param response
+     *     The HttpServletResponse associated with the write request received.
+     *     Any data to be sent to the client in response to the write request
+     *     should be written to the response body of this HttpServletResponse.
+     *
+     * @param tunnelUUID
+     *     The UUID of the tunnel to read from, as specified in the write
+     *     request. This tunnel must have been created by a previous call to
+     *     doConnect().
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while handling the read request.
      */
-    protected void doRead(HttpServletRequest request, HttpServletResponse response, String tunnelUUID) throws GuacamoleException {
-
-        HttpSession httpSession = request.getSession(false);
-        GuacamoleSession session = new GuacamoleSession(httpSession);
+    protected void doRead(HttpServletRequest request,
+            HttpServletResponse response, String tunnelUUID)
+            throws GuacamoleException {
 
         // Get tunnel, ensure tunnel exists
-        GuacamoleTunnel tunnel = session.getTunnel(tunnelUUID);
-        if (tunnel == null)
-            throw new GuacamoleResourceNotFoundException("No such tunnel.");
+        GuacamoleTunnel tunnel = getTunnel(tunnelUUID);
 
         // Ensure tunnel is open
         if (!tunnel.isOpen())
@@ -298,11 +345,11 @@ public abstract class GuacamoleHTTPTunnelServlet extends HttpServlet {
             // Stream data to response, ensuring output stream is closed
             try {
 
-                // Detach tunnel and throw error if EOF (and we haven't sent any
-                // data yet.
+                // Deregister tunnel and throw error if we reach EOF without
+                // having ever sent any data
                 char[] message = reader.read();
                 if (message == null)
-                    throw new GuacamoleResourceNotFoundException("Tunnel reached end of stream.");
+                    throw new GuacamoleConnectionClosedException("Tunnel reached end of stream.");
 
                 // For all messages, until another stream is ready (we send at least one message)
                 do {
@@ -323,13 +370,38 @@ public abstract class GuacamoleHTTPTunnelServlet extends HttpServlet {
                 } while (tunnel.isOpen() && (message = reader.read()) != null);
 
                 // Close tunnel immediately upon EOF
-                if (message == null)
+                if (message == null) {
+                    deregisterTunnel(tunnel);
                     tunnel.close();
+                }
+
+                // End-of-instructions marker
+                out.write("0.;");
+                out.flush();
+                response.flushBuffer();
+            }
+
+            // Send end-of-stream marker and close tunnel if connection is closed
+            catch (GuacamoleConnectionClosedException e) {
+
+                // Deregister and close
+                deregisterTunnel(tunnel);
+                tunnel.close();
 
                 // End-of-instructions marker
                 out.write("0.;");
                 out.flush();
                 response.flushBuffer();
+
+            }
+
+            catch (GuacamoleException e) {
+
+                // Deregister and close
+                deregisterTunnel(tunnel);
+                tunnel.close();
+
+                throw e;
             }
 
             // Always close output stream
@@ -338,21 +410,13 @@ public abstract class GuacamoleHTTPTunnelServlet extends HttpServlet {
             }
 
         }
-        catch (GuacamoleException e) {
-
-            // Detach and close
-            session.detachTunnel(tunnel);
-            tunnel.close();
-
-            throw e;
-        }
         catch (IOException e) {
 
             // Log typically frequent I/O error if desired
             logger.debug("Error writing to servlet output stream", e);
 
-            // Detach and close
-            session.detachTunnel(tunnel);
+            // Deregister and close
+            deregisterTunnel(tunnel);
             tunnel.close();
 
         }
@@ -367,25 +431,27 @@ public abstract class GuacamoleHTTPTunnelServlet extends HttpServlet {
      * This function should in general not be overridden, as it already
      * contains a proper implementation of the write operation.
      *
-     * @param request The HttpServletRequest associated with the write request
-     *                received. Any data to be written will be specified within
-     *                the body of this request.
-     * @param response The HttpServletResponse associated with the write request
-     *                 received.
-     * @param tunnelUUID The UUID of the tunnel to write to, as specified in
-     *                   the write request. This tunnel must be attached to
-     *                   the Guacamole session.
-     * @throws GuacamoleException If an error occurs while handling the write
-     *                            request.
+     * @param request
+     *     The HttpServletRequest associated with the write request received.
+     *     Any data to be written will be specified within the body of this
+     *     request.
+     *
+     * @param response
+     *     The HttpServletResponse associated with the write request received.
+     *
+     * @param tunnelUUID
+     *     The UUID of the tunnel to write to, as specified in the write
+     *     request. This tunnel must have been created by a previous call to
+     *     doConnect().
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while handling the write request.
      */
-    protected void doWrite(HttpServletRequest request, HttpServletResponse response, String tunnelUUID) throws GuacamoleException {
+    protected void doWrite(HttpServletRequest request,
+            HttpServletResponse response, String tunnelUUID)
+            throws GuacamoleException {
 
-        HttpSession httpSession = request.getSession(false);
-        GuacamoleSession session = new GuacamoleSession(httpSession);
-
-        GuacamoleTunnel tunnel = session.getTunnel(tunnelUUID);
-        if (tunnel == null)
-            throw new GuacamoleResourceNotFoundException("No such tunnel.");
+        GuacamoleTunnel tunnel = getTunnel(tunnelUUID);
 
         // We still need to set the content type to avoid the default of
         // text/html, as such a content type would cause some browsers to
@@ -426,10 +492,13 @@ public abstract class GuacamoleHTTPTunnelServlet extends HttpServlet {
             }
 
         }
+        catch (GuacamoleConnectionClosedException e) {
+            logger.debug("Connection to guacd closed.", e);
+        }
         catch (IOException e) {
 
-            // Detach and close
-            session.detachTunnel(tunnel);
+            // Deregister and close
+            deregisterTunnel(tunnel);
             tunnel.close();
 
             throw new GuacamoleServerException("I/O Error sending data to server: " + e.getMessage(), e);
@@ -440,6 +509,11 @@ public abstract class GuacamoleHTTPTunnelServlet extends HttpServlet {
 
     }
 
+    @Override
+    public void destroy() {
+        tunnels.shutdown();
+    }
+
 }
 
 /**
diff --git a/guacamole-common/src/main/java/org/glyptodon/guacamole/servlet/GuacamoleSession.java b/guacamole-common/src/main/java/org/glyptodon/guacamole/servlet/GuacamoleSession.java
index 1d8641f..7099cc8 100644
--- a/guacamole-common/src/main/java/org/glyptodon/guacamole/servlet/GuacamoleSession.java
+++ b/guacamole-common/src/main/java/org/glyptodon/guacamole/servlet/GuacamoleSession.java
@@ -1,47 +1,28 @@
-
-package org.glyptodon.guacamole.servlet;
-
-/* ***** BEGIN LICENSE BLOCK *****
- * Version: MPL 1.1/GPL 2.0/LGPL 2.1
- *
- * The contents of this file are subject to the Mozilla Public License Version
- * 1.1 (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.mozilla.org/MPL/
- *
- * Software distributed under the License is distributed on an "AS IS" basis,
- * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- * for the specific language governing rights and limitations under the
- * License.
- *
- * The Original Code is guacamole-common.
+/*
+ * Copyright (C) 2015 Glyptodon LLC
  *
- * The Initial Developer of the Original Code is
- * Michael Jumper.
- * Portions created by the Initial Developer are Copyright (C) 2010
- * the Initial Developer. All Rights Reserved.
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
  *
- * Contributor(s):
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
  *
- * Alternatively, the contents of this file may be used under the terms of
- * either the GNU General Public License Version 2 or later (the "GPL"), or
- * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
- * in which case the provisions of the GPL or the LGPL are applicable instead
- * of those above. If you wish to allow use of your version of this file only
- * under the terms of either the GPL or the LGPL, and not to allow others to
- * use your version of this file under the terms of the MPL, indicate your
- * decision by deleting the provisions above and replace them with the notice
- * and other provisions required by the GPL or the LGPL. If you do not delete
- * the provisions above, a recipient may use your version of this file under
- * the terms of any one of the MPL, the GPL or the LGPL.
- *
- * ***** END LICENSE BLOCK ***** */
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.servlet;
 
-import java.util.concurrent.ConcurrentHashMap;
-import java.util.concurrent.ConcurrentMap;
 import javax.servlet.http.HttpSession;
-import org.glyptodon.guacamole.GuacamoleException;
-import org.glyptodon.guacamole.GuacamoleSecurityException;
 import org.glyptodon.guacamole.net.GuacamoleTunnel;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -51,75 +32,70 @@ import org.slf4j.LoggerFactory;
  *
  * @author Michael Jumper
  */
+ at Deprecated
 public class GuacamoleSession {
 
     /**
      * Logger for this class.
      */
-    private Logger logger = LoggerFactory.getLogger(GuacamoleSession.class);
+    private final Logger logger = LoggerFactory.getLogger(GuacamoleSession.class);
 
     /**
-     * Map of all currently attached tunnels, indexed by tunnel UUID.
-     */
-    private ConcurrentMap<String, GuacamoleTunnel> tunnels;
-
-    /**
-     * Creates a new GuacamoleSession, storing and retrieving tunnels from the
-     * given HttpSession. Note that the true Guacamole session is tied to the
-     * HttpSession provided, thus creating a new GuacamoleSession does not
-     * create a new Guacamole session; it merely creates a new object for
-     * accessing the tunnels of an existing Guacamole session represented by
-     * the provided HttpSession.
+     * Creates a new GuacamoleSession. In prior versions of Guacamole, the
+     * GuacamoleSession object stored the tunnels associated with a particular
+     * user's use of the HTTP tunnel. The HTTP tunnel now stores all of these
+     * tunnels itself, and thus this class is no longer necessary. Its use will
+     * result in a warning being logged, and its functions will have no effect.
      *
-     * @param session The HttpSession to use as tunnel storage.
-     * @throws GuacamoleException If session is null.
+     * @param session
+     *     The HttpSession that older versions of Guacamole would use as tunnel
+     *     storage. This parameter is now ignored, and the GuacamoleSession
+     *     class overall is deprecated.
      */
-    @SuppressWarnings("unchecked")
-    public GuacamoleSession(HttpSession session) throws GuacamoleException {
-
-        if (session == null)
-            throw new GuacamoleSecurityException("User has no session.");
-
-        synchronized (session) {
-
-            tunnels = (ConcurrentMap<String, GuacamoleTunnel>) session.getAttribute("GUAC_TUNNELS");
-            if (tunnels == null) {
-                tunnels = new ConcurrentHashMap<String, GuacamoleTunnel>();
-                session.setAttribute("GUAC_TUNNELS", tunnels);
-            }
-
-        }
-
+    public GuacamoleSession(HttpSession session) {
+        logger.warn("GuacamoleSession is deprecated. It is no longer "
+                  + "necessary and its use will have no effect.");
     }
 
     /**
-     * Attaches the given tunnel to this GuacamoleSession.
-     * @param tunnel The tunnel to attach to this GucacamoleSession.
+     * Attaches the given tunnel to this GuacamoleSession. The GuacamoleSession
+     * class is now deprecated, and this function has no effect.
+     *
+     * @param tunnel
+     *     The tunnel to attach to this GucacamoleSession.
      */
     public void attachTunnel(GuacamoleTunnel tunnel) {
-        tunnels.put(tunnel.getUUID().toString(), tunnel);
-        logger.debug("Attached tunnel {}.", tunnel.getUUID());
+        // Deprecated - no effect
     }
 
     /**
-     * Detaches the given tunnel to this GuacamoleSession.
-     * @param tunnel The tunnel to detach to this GucacamoleSession.
+     * Detaches the given tunnel to this GuacamoleSession. The GuacamoleSession
+     * class is now deprecated, and this function has no effect.
+     *
+     * @param tunnel
+     *     The tunnel to detach to this GucacamoleSession.
      */
     public void detachTunnel(GuacamoleTunnel tunnel) {
-        tunnels.remove(tunnel.getUUID().toString());
-        logger.debug("Detached tunnel {}.", tunnel.getUUID());
+        // Deprecated - no effect
     }
 
     /**
      * Returns the tunnel with the given UUID attached to this GuacamoleSession,
-     * if any.
+     * if any. The GuacamoleSession class is now deprecated, and this function
+     * has no effect. It will ALWAYS return null.
+     *
+     * @param tunnelUUID
+     *     The UUID of an attached tunnel.
      *
-     * @param tunnelUUID The UUID of an attached tunnel.
-     * @return The tunnel corresponding to the given UUID, if attached, or null
-     *         if no such tunnel is attached.
+     * @return
+     *     The tunnel corresponding to the given UUID, if attached, or null if
+     *     if no such tunnel is attached.
      */
     public GuacamoleTunnel getTunnel(String tunnelUUID) {
-        return tunnels.get(tunnelUUID);
+
+        // Deprecated - no effect
+        return null;
+
     }
 
 }
diff --git a/guacamole-common/src/main/java/org/glyptodon/guacamole/servlet/package-info.java b/guacamole-common/src/main/java/org/glyptodon/guacamole/servlet/package-info.java
index 21e25a6..ef62c8b 100644
--- a/guacamole-common/src/main/java/org/glyptodon/guacamole/servlet/package-info.java
+++ b/guacamole-common/src/main/java/org/glyptodon/guacamole/servlet/package-info.java
@@ -1,3 +1,24 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
 
 /**
  * Classes which build upon the Java Servlet API, providing an HTTP-based
diff --git a/guacamole-common/src/main/java/org/glyptodon/guacamole/websocket/GuacamoleWebSocketTunnelEndpoint.java b/guacamole-common/src/main/java/org/glyptodon/guacamole/websocket/GuacamoleWebSocketTunnelEndpoint.java
new file mode 100644
index 0000000..2a85145
--- /dev/null
+++ b/guacamole-common/src/main/java/org/glyptodon/guacamole/websocket/GuacamoleWebSocketTunnelEndpoint.java
@@ -0,0 +1,246 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.websocket;
+
+import java.io.IOException;
+import javax.websocket.CloseReason;
+import javax.websocket.CloseReason.CloseCode;
+import javax.websocket.Endpoint;
+import javax.websocket.EndpointConfig;
+import javax.websocket.MessageHandler;
+import javax.websocket.OnClose;
+import javax.websocket.OnMessage;
+import javax.websocket.OnOpen;
+import javax.websocket.RemoteEndpoint;
+import javax.websocket.Session;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.io.GuacamoleReader;
+import org.glyptodon.guacamole.io.GuacamoleWriter;
+import org.glyptodon.guacamole.net.GuacamoleTunnel;
+import org.glyptodon.guacamole.GuacamoleClientException;
+import org.glyptodon.guacamole.GuacamoleConnectionClosedException;
+import org.glyptodon.guacamole.protocol.GuacamoleStatus;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * A WebSocket implementation of GuacamoleTunnel functionality, compatible with
+ * the Guacamole.WebSocketTunnel object included with the JavaScript API.
+ * Messages sent/received are simply chunks of the Guacamole protocol
+ * instruction stream.
+ *
+ * @author Michael Jumper
+ */
+public abstract class GuacamoleWebSocketTunnelEndpoint extends Endpoint {
+
+    /**
+     * The default, minimum buffer size for instructions.
+     */
+    private static final int BUFFER_SIZE = 8192;
+
+    /**
+     * Logger for this class.
+     */
+    private final Logger logger = LoggerFactory.getLogger(GuacamoleWebSocketTunnelEndpoint.class);
+
+    /**
+     * The underlying GuacamoleTunnel. WebSocket reads/writes will be handled
+     * as reads/writes to this tunnel.
+     */
+    private GuacamoleTunnel tunnel;
+    
+    /**
+     * Sends the given status on the given WebSocket connection and closes the
+     * connection.
+     *
+     * @param session The outbound WebSocket connection to close.
+     * @param guac_status The status to send.
+     */
+    private void closeConnection(Session session, GuacamoleStatus guac_status) {
+
+        try {
+            CloseCode code = CloseReason.CloseCodes.getCloseCode(guac_status.getWebSocketCode());
+            String message = Integer.toString(guac_status.getGuacamoleStatusCode());
+            session.close(new CloseReason(code, message));
+        }
+        catch (IOException e) {
+            logger.debug("Unable to close WebSocket connection.", e);
+        }
+
+    }
+
+    /**
+     * Returns a new tunnel for the given session. How this tunnel is created
+     * or retrieved is implementation-dependent.
+     *
+     * @param session The session associated with the active WebSocket
+     *                connection.
+     * @param config Configuration information associated with the instance of
+     *               the endpoint created for handling this single connection.
+     * @return A connected tunnel, or null if no such tunnel exists.
+     * @throws GuacamoleException If an error occurs while retrieving the
+     *                            tunnel, or if access to the tunnel is denied.
+     */
+    protected abstract GuacamoleTunnel createTunnel(Session session, EndpointConfig config)
+            throws GuacamoleException;
+
+    @Override
+    @OnOpen
+    public void onOpen(final Session session, EndpointConfig config) {
+
+        try {
+
+            // Get tunnel
+            tunnel = createTunnel(session, config);
+            if (tunnel == null) {
+                closeConnection(session, GuacamoleStatus.RESOURCE_NOT_FOUND);
+                return;
+            }
+
+        }
+        catch (GuacamoleException e) {
+            logger.error("Creation of WebSocket tunnel to guacd failed: {}", e.getMessage());
+            logger.debug("Error connecting WebSocket tunnel.", e);
+            closeConnection(session, e.getStatus());
+            return;
+        }
+
+        // Manually register message handler
+        session.addMessageHandler(new MessageHandler.Whole<String>() {
+
+            @Override
+            public void onMessage(String message) {
+                GuacamoleWebSocketTunnelEndpoint.this.onMessage(message);
+            }
+
+        });
+
+        // Prepare read transfer thread
+        Thread readThread = new Thread() {
+
+            /**
+             * Remote (client) side of this connection
+             */
+            private final RemoteEndpoint.Basic remote = session.getBasicRemote();
+                
+            @Override
+            public void run() {
+
+                StringBuilder buffer = new StringBuilder(BUFFER_SIZE);
+                GuacamoleReader reader = tunnel.acquireReader();
+                char[] readMessage;
+
+                try {
+
+                    try {
+
+                        // Attempt to read
+                        while ((readMessage = reader.read()) != null) {
+
+                            // Buffer message
+                            buffer.append(readMessage);
+
+                            // Flush if we expect to wait or buffer is getting full
+                            if (!reader.available() || buffer.length() >= BUFFER_SIZE) {
+                                remote.sendText(buffer.toString());
+                                buffer.setLength(0);
+                            }
+
+                        }
+
+                        // No more data
+                        closeConnection(session, GuacamoleStatus.SUCCESS);
+
+                    }
+
+                    // Catch any thrown guacamole exception and attempt
+                    // to pass within the WebSocket connection, logging
+                    // each error appropriately.
+                    catch (GuacamoleClientException e) {
+                        logger.info("WebSocket connection terminated: {}", e.getMessage());
+                        logger.debug("WebSocket connection terminated due to client error.", e);
+                        closeConnection(session, e.getStatus());
+                    }
+                    catch (GuacamoleConnectionClosedException e) {
+                        logger.debug("Connection to guacd closed.", e);
+                        closeConnection(session, GuacamoleStatus.SUCCESS);
+                    }
+                    catch (GuacamoleException e) {
+                        logger.error("Connection to guacd terminated abnormally: {}", e.getMessage());
+                        logger.debug("Internal error during connection to guacd.", e);
+                        closeConnection(session, e.getStatus());
+                    }
+
+                }
+                catch (IOException e) {
+                    logger.debug("I/O error prevents further reads.", e);
+                }
+
+            }
+
+        };
+
+        readThread.start();
+
+    }
+
+    @OnMessage
+    public void onMessage(String message) {
+
+        // Ignore inbound messages if there is no associated tunnel
+        if (tunnel == null)
+            return;
+
+        GuacamoleWriter writer = tunnel.acquireWriter();
+
+        try {
+            // Write received message
+            writer.write(message.toCharArray());
+        }
+        catch (GuacamoleConnectionClosedException e) {
+            logger.debug("Connection to guacd closed.", e);
+        }
+        catch (GuacamoleException e) {
+            logger.debug("WebSocket tunnel write failed.", e);
+        }
+
+        tunnel.releaseWriter();
+
+    }
+    
+    @Override
+    @OnClose
+    public void onClose(Session session, CloseReason closeReason) {
+
+        try {
+            if (tunnel != null)
+                tunnel.close();
+        }
+        catch (GuacamoleException e) {
+            logger.debug("Unable to close WebSocket tunnel.", e);
+        }
+        
+    }
+
+}
+
diff --git a/guacamole-common/src/test/java/org/glyptodon/guacamole/io/ReaderGuacamoleReaderTest.java b/guacamole-common/src/test/java/org/glyptodon/guacamole/io/ReaderGuacamoleReaderTest.java
new file mode 100644
index 0000000..569cb38
--- /dev/null
+++ b/guacamole-common/src/test/java/org/glyptodon/guacamole/io/ReaderGuacamoleReaderTest.java
@@ -0,0 +1,91 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.io;
+
+import java.io.StringReader;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.protocol.GuacamoleInstruction;
+import org.junit.Test;
+import static org.junit.Assert.*;
+
+/**
+ * Tests the ReaderGuacamoleReader implementation of GuacamoleReader, validating
+ * that instructions are parsed correctly.
+ *
+ * @author Michael Jumper
+ */
+public class ReaderGuacamoleReaderTest {
+
+    /**
+     * Test of ReaderGuacamoleReader parsing.
+     * 
+     * @throws GuacamoleException If a parse error occurs while parsing the
+     *                            known-good test string.
+     */
+    @Test
+    public void testReader() throws GuacamoleException {
+
+        // Test string
+        final String test = "1.a,2.bc,3.def,10.helloworld;4.test,5.test2;0.;3.foo;";
+
+        GuacamoleReader reader = new ReaderGuacamoleReader(new StringReader(test));
+
+        GuacamoleInstruction instruction;
+
+        // Validate first test instruction
+        instruction = reader.readInstruction();
+        assertNotNull(instruction);
+        assertEquals(3, instruction.getArgs().size());
+        assertEquals("a", instruction.getOpcode());
+        assertEquals("bc", instruction.getArgs().get(0));
+        assertEquals("def", instruction.getArgs().get(1));
+        assertEquals("helloworld", instruction.getArgs().get(2));
+
+        // Validate second test instruction
+        instruction = reader.readInstruction();
+        assertNotNull(instruction);
+        assertEquals(1, instruction.getArgs().size());
+        assertEquals("test", instruction.getOpcode());
+        assertEquals("test2", instruction.getArgs().get(0));
+
+        // Validate third test instruction
+        instruction = reader.readInstruction();
+        assertNotNull(instruction);
+        assertEquals(0, instruction.getArgs().size());
+        assertEquals("", instruction.getOpcode());
+
+        // Validate fourth test instruction
+        instruction = reader.readInstruction();
+        assertNotNull(instruction);
+        assertEquals(0, instruction.getArgs().size());
+        assertEquals("foo", instruction.getOpcode());
+
+        // There should be no more instructions
+        instruction = reader.readInstruction();
+        assertNull(instruction);
+
+    }
+
+
+   
+}
diff --git a/guacamole-common/src/test/java/org/glyptodon/guacamole/protocol/FilteredGuacamoleReaderTest.java b/guacamole-common/src/test/java/org/glyptodon/guacamole/protocol/FilteredGuacamoleReaderTest.java
new file mode 100644
index 0000000..40d6e03
--- /dev/null
+++ b/guacamole-common/src/test/java/org/glyptodon/guacamole/protocol/FilteredGuacamoleReaderTest.java
@@ -0,0 +1,95 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.protocol;
+
+import java.io.StringReader;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.io.GuacamoleReader;
+import org.glyptodon.guacamole.io.ReaderGuacamoleReader;
+import static org.junit.Assert.*;
+import org.junit.Test;
+
+/**
+ * Test which validates filtering of Guacamole instructions with
+ * FilteredGuacamoleReader.
+ *
+ * @author Michael Jumper
+ */
+public class FilteredGuacamoleReaderTest {
+
+    /**
+     * Filter which allows through "yes" instructions but drops all others.
+     */
+    private static class TestFilter implements GuacamoleFilter {
+
+        @Override
+        public GuacamoleInstruction filter(GuacamoleInstruction instruction) throws GuacamoleException {
+
+            if (instruction.getOpcode().equals("yes"))
+                return instruction;
+
+            return null;
+            
+        }
+
+    }
+    
+    @Test
+    public void testFilter() throws Exception {
+
+        // Test string
+        final String test = "3.yes,1.A;2.no,1.B;3.yes,1.C;3.yes,1.D;4.nope,1.E;";
+
+        GuacamoleReader reader = new FilteredGuacamoleReader(new ReaderGuacamoleReader(new StringReader(test)),
+                                                             new TestFilter());
+
+        GuacamoleInstruction instruction;
+
+        // Validate first instruction
+        instruction = reader.readInstruction();
+        assertNotNull(instruction);
+        assertEquals("yes", instruction.getOpcode());
+        assertEquals(1, instruction.getArgs().size());
+        assertEquals("A", instruction.getArgs().get(0));
+
+        // Validate second instruction
+        instruction = reader.readInstruction();
+        assertNotNull(instruction);
+        assertEquals("yes", instruction.getOpcode());
+        assertEquals(1, instruction.getArgs().size());
+        assertEquals("C", instruction.getArgs().get(0));
+
+        // Validate third instruction
+        instruction = reader.readInstruction();
+        assertNotNull(instruction);
+        assertEquals("yes", instruction.getOpcode());
+        assertEquals(1, instruction.getArgs().size());
+        assertEquals("D", instruction.getArgs().get(0));
+
+        // Should be done now
+        instruction = reader.readInstruction();
+        assertNull(instruction);
+
+    }
+    
+}
diff --git a/guacamole-common/src/test/java/org/glyptodon/guacamole/protocol/FilteredGuacamoleWriterTest.java b/guacamole-common/src/test/java/org/glyptodon/guacamole/protocol/FilteredGuacamoleWriterTest.java
new file mode 100644
index 0000000..4e7bd7d
--- /dev/null
+++ b/guacamole-common/src/test/java/org/glyptodon/guacamole/protocol/FilteredGuacamoleWriterTest.java
@@ -0,0 +1,73 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.protocol;
+
+import java.io.StringWriter;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.io.GuacamoleWriter;
+import org.glyptodon.guacamole.io.WriterGuacamoleWriter;
+import static org.junit.Assert.*;
+import org.junit.Test;
+
+/**
+ * Test which validates filtering of Guacamole instructions with
+ * FilteredGuacamoleWriter.
+ *
+ * @author Michael Jumper
+ */
+public class FilteredGuacamoleWriterTest {
+
+    /**
+     * Filter which allows through "yes" instructions but drops all others.
+     */
+    private static class TestFilter implements GuacamoleFilter {
+
+        @Override
+        public GuacamoleInstruction filter(GuacamoleInstruction instruction) throws GuacamoleException {
+
+            if (instruction.getOpcode().equals("yes"))
+                return instruction;
+
+            return null;
+            
+        }
+
+    }
+    
+    @Test
+    public void testFilter() throws Exception {
+
+        StringWriter stringWriter = new StringWriter();
+        GuacamoleWriter writer = new FilteredGuacamoleWriter(new WriterGuacamoleWriter(stringWriter),
+                                                             new TestFilter());
+
+        // Write a few chunks of complete instructions
+        writer.write("3.yes,1.A;2.no,1.B;3.yes,1.C;3.yes,1.D;4.nope,1.E;".toCharArray());
+        writer.write("1.n,3.abc;3.yes,5.hello;2.no,4.test;3.yes,5.world;".toCharArray());
+
+        // Validate filtered results
+        assertEquals("3.yes,1.A;3.yes,1.C;3.yes,1.D;3.yes,5.hello;3.yes,5.world;", stringWriter.toString());
+
+    }
+    
+}
diff --git a/guacamole-common/src/test/java/org/glyptodon/guacamole/protocol/GuacamoleParserTest.java b/guacamole-common/src/test/java/org/glyptodon/guacamole/protocol/GuacamoleParserTest.java
new file mode 100644
index 0000000..61088d1
--- /dev/null
+++ b/guacamole-common/src/test/java/org/glyptodon/guacamole/protocol/GuacamoleParserTest.java
@@ -0,0 +1,126 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.protocol;
+
+import org.glyptodon.guacamole.GuacamoleException;
+import static org.junit.Assert.*;
+import org.junit.Test;
+
+/**
+ * Unit test for GuacamoleParser. Verifies that parsing of the Guacamole
+ * protocol works as required.
+ *
+ * @author Michael Jumper
+ */
+public class GuacamoleParserTest {
+
+    /**
+     * The GuacamoleParser instance being tested.
+     */
+    private final GuacamoleParser parser = new GuacamoleParser();
+
+    /**
+     * Test of append method, of class GuacamoleParser.
+     * 
+     * @throws GuacamoleException If a parse error occurs while parsing the
+     *                            known-good test string.
+     */
+    @Test
+    public void testParser() throws GuacamoleException {
+
+        // Test string
+        char buffer[] = "1.a,2.bc,3.def,10.helloworld;4.test,5.test2;0.;3.foo;".toCharArray();
+        int offset = 0;
+        int length = buffer.length;
+
+        GuacamoleInstruction instruction;
+        int parsed;
+
+        // Parse more data
+        while (length > 0 && (parsed = parser.append(buffer, offset, length)) != 0) {
+            offset += parsed;
+            length -= parsed;
+        }
+
+        // Validate first test instruction
+        assertTrue(parser.hasNext());
+        instruction = parser.next();
+        assertNotNull(instruction);
+        assertEquals(3, instruction.getArgs().size());
+        assertEquals("a", instruction.getOpcode());
+        assertEquals("bc", instruction.getArgs().get(0));
+        assertEquals("def", instruction.getArgs().get(1));
+        assertEquals("helloworld", instruction.getArgs().get(2));
+
+        // Parse more data
+        while (length > 0 && (parsed = parser.append(buffer, offset, length)) != 0) {
+            offset += parsed;
+            length -= parsed;
+        }
+
+        // Validate second test instruction
+        assertTrue(parser.hasNext());
+        instruction = parser.next();
+        assertNotNull(instruction);
+        assertEquals(1, instruction.getArgs().size());
+        assertEquals("test", instruction.getOpcode());
+        assertEquals("test2", instruction.getArgs().get(0));
+
+        // Parse more data
+        while (length > 0 && (parsed = parser.append(buffer, offset, length)) != 0) {
+            offset += parsed;
+            length -= parsed;
+        }
+
+        // Validate third test instruction
+        assertTrue(parser.hasNext());
+        instruction = parser.next();
+        assertNotNull(instruction);
+        assertEquals(0, instruction.getArgs().size());
+        assertEquals("", instruction.getOpcode());
+
+        // Parse more data
+        while (length > 0 && (parsed = parser.append(buffer, offset, length)) != 0) {
+            offset += parsed;
+            length -= parsed;
+        }
+
+        // Validate fourth test instruction
+        assertTrue(parser.hasNext());
+        instruction = parser.next();
+        assertNotNull(instruction);
+        assertEquals(0, instruction.getArgs().size());
+        assertEquals("foo", instruction.getOpcode());
+
+        // Parse more data
+        while (length > 0 && (parsed = parser.append(buffer, offset, length)) != 0) {
+            offset += parsed;
+            length -= parsed;
+        }
+
+        // There should be no more instructions
+        assertFalse(parser.hasNext());
+
+    }
+
+}
diff --git a/guacamole-ext/LICENSE b/guacamole-ext/LICENSE
index 7714141..540cdcf 100644
--- a/guacamole-ext/LICENSE
+++ b/guacamole-ext/LICENSE
@@ -1,470 +1,19 @@
-                          MOZILLA PUBLIC LICENSE
-                                Version 1.1
-
-                              ---------------
-
-1. Definitions.
-
-     1.0.1. "Commercial Use" means distribution or otherwise making the
-     Covered Code available to a third party.
-
-     1.1. "Contributor" means each entity that creates or contributes to
-     the creation of Modifications.
-
-     1.2. "Contributor Version" means the combination of the Original
-     Code, prior Modifications used by a Contributor, and the Modifications
-     made by that particular Contributor.
-
-     1.3. "Covered Code" means the Original Code or Modifications or the
-     combination of the Original Code and Modifications, in each case
-     including portions thereof.
-
-     1.4. "Electronic Distribution Mechanism" means a mechanism generally
-     accepted in the software development community for the electronic
-     transfer of data.
-
-     1.5. "Executable" means Covered Code in any form other than Source
-     Code.
-
-     1.6. "Initial Developer" means the individual or entity identified
-     as the Initial Developer in the Source Code notice required by Exhibit
-     A.
-
-     1.7. "Larger Work" means a work which combines Covered Code or
-     portions thereof with code not governed by the terms of this License.
-
-     1.8. "License" means this document.
-
-     1.8.1. "Licensable" means having the right to grant, to the maximum
-     extent possible, whether at the time of the initial grant or
-     subsequently acquired, any and all of the rights conveyed herein.
-
-     1.9. "Modifications" means any addition to or deletion from the
-     substance or structure of either the Original Code or any previous
-     Modifications. When Covered Code is released as a series of files, a
-     Modification is:
-          A. Any addition to or deletion from the contents of a file
-          containing Original Code or previous Modifications.
-
-          B. Any new file that contains any part of the Original Code or
-          previous Modifications.
-
-     1.10. "Original Code" means Source Code of computer software code
-     which is described in the Source Code notice required by Exhibit A as
-     Original Code, and which, at the time of its release under this
-     License is not already Covered Code governed by this License.
-
-     1.10.1. "Patent Claims" means any patent claim(s), now owned or
-     hereafter acquired, including without limitation,  method, process,
-     and apparatus claims, in any patent Licensable by grantor.
-
-     1.11. "Source Code" means the preferred form of the Covered Code for
-     making modifications to it, including all modules it contains, plus
-     any associated interface definition files, scripts used to control
-     compilation and installation of an Executable, or source code
-     differential comparisons against either the Original Code or another
-     well known, available Covered Code of the Contributor's choice. The
-     Source Code can be in a compressed or archival form, provided the
-     appropriate decompression or de-archiving software is widely available
-     for no charge.
-
-     1.12. "You" (or "Your")  means an individual or a legal entity
-     exercising rights under, and complying with all of the terms of, this
-     License or a future version of this License issued under Section 6.1.
-     For legal entities, "You" includes any entity which controls, is
-     controlled by, or is under common control with You. For purposes of
-     this definition, "control" means (a) the power, direct or indirect,
-     to cause the direction or management of such entity, whether by
-     contract or otherwise, or (b) ownership of more than fifty percent
-     (50%) of the outstanding shares or beneficial ownership of such
-     entity.
-
-2. Source Code License.
-
-     2.1. The Initial Developer Grant.
-     The Initial Developer hereby grants You a world-wide, royalty-free,
-     non-exclusive license, subject to third party intellectual property
-     claims:
-          (a)  under intellectual property rights (other than patent or
-          trademark) Licensable by Initial Developer to use, reproduce,
-          modify, display, perform, sublicense and distribute the Original
-          Code (or portions thereof) with or without Modifications, and/or
-          as part of a Larger Work; and
-
-          (b) under Patents Claims infringed by the making, using or
-          selling of Original Code, to make, have made, use, practice,
-          sell, and offer for sale, and/or otherwise dispose of the
-          Original Code (or portions thereof).
-
-          (c) the licenses granted in this Section 2.1(a) and (b) are
-          effective on the date Initial Developer first distributes
-          Original Code under the terms of this License.
-
-          (d) Notwithstanding Section 2.1(b) above, no patent license is
-          granted: 1) for code that You delete from the Original Code; 2)
-          separate from the Original Code;  or 3) for infringements caused
-          by: i) the modification of the Original Code or ii) the
-          combination of the Original Code with other software or devices.
-
-     2.2. Contributor Grant.
-     Subject to third party intellectual property claims, each Contributor
-     hereby grants You a world-wide, royalty-free, non-exclusive license
-
-          (a)  under intellectual property rights (other than patent or
-          trademark) Licensable by Contributor, to use, reproduce, modify,
-          display, perform, sublicense and distribute the Modifications
-          created by such Contributor (or portions thereof) either on an
-          unmodified basis, with other Modifications, as Covered Code
-          and/or as part of a Larger Work; and
-
-          (b) under Patent Claims infringed by the making, using, or
-          selling of  Modifications made by that Contributor either alone
-          and/or in combination with its Contributor Version (or portions
-          of such combination), to make, use, sell, offer for sale, have
-          made, and/or otherwise dispose of: 1) Modifications made by that
-          Contributor (or portions thereof); and 2) the combination of
-          Modifications made by that Contributor with its Contributor
-          Version (or portions of such combination).
-
-          (c) the licenses granted in Sections 2.2(a) and 2.2(b) are
-          effective on the date Contributor first makes Commercial Use of
-          the Covered Code.
-
-          (d)    Notwithstanding Section 2.2(b) above, no patent license is
-          granted: 1) for any code that Contributor has deleted from the
-          Contributor Version; 2)  separate from the Contributor Version;
-          3)  for infringements caused by: i) third party modifications of
-          Contributor Version or ii)  the combination of Modifications made
-          by that Contributor with other software  (except as part of the
-          Contributor Version) or other devices; or 4) under Patent Claims
-          infringed by Covered Code in the absence of Modifications made by
-          that Contributor.
-
-3. Distribution Obligations.
-
-     3.1. Application of License.
-     The Modifications which You create or to which You contribute are
-     governed by the terms of this License, including without limitation
-     Section 2.2. The Source Code version of Covered Code may be
-     distributed only under the terms of this License or a future version
-     of this License released under Section 6.1, and You must include a
-     copy of this License with every copy of the Source Code You
-     distribute. You may not offer or impose any terms on any Source Code
-     version that alters or restricts the applicable version of this
-     License or the recipients' rights hereunder. However, You may include
-     an additional document offering the additional rights described in
-     Section 3.5.
-
-     3.2. Availability of Source Code.
-     Any Modification which You create or to which You contribute must be
-     made available in Source Code form under the terms of this License
-     either on the same media as an Executable version or via an accepted
-     Electronic Distribution Mechanism to anyone to whom you made an
-     Executable version available; and if made available via Electronic
-     Distribution Mechanism, must remain available for at least twelve (12)
-     months after the date it initially became available, or at least six
-     (6) months after a subsequent version of that particular Modification
-     has been made available to such recipients. You are responsible for
-     ensuring that the Source Code version remains available even if the
-     Electronic Distribution Mechanism is maintained by a third party.
-
-     3.3. Description of Modifications.
-     You must cause all Covered Code to which You contribute to contain a
-     file documenting the changes You made to create that Covered Code and
-     the date of any change. You must include a prominent statement that
-     the Modification is derived, directly or indirectly, from Original
-     Code provided by the Initial Developer and including the name of the
-     Initial Developer in (a) the Source Code, and (b) in any notice in an
-     Executable version or related documentation in which You describe the
-     origin or ownership of the Covered Code.
-
-     3.4. Intellectual Property Matters
-          (a) Third Party Claims.
-          If Contributor has knowledge that a license under a third party's
-          intellectual property rights is required to exercise the rights
-          granted by such Contributor under Sections 2.1 or 2.2,
-          Contributor must include a text file with the Source Code
-          distribution titled "LEGAL" which describes the claim and the
-          party making the claim in sufficient detail that a recipient will
-          know whom to contact. If Contributor obtains such knowledge after
-          the Modification is made available as described in Section 3.2,
-          Contributor shall promptly modify the LEGAL file in all copies
-          Contributor makes available thereafter and shall take other steps
-          (such as notifying appropriate mailing lists or newsgroups)
-          reasonably calculated to inform those who received the Covered
-          Code that new knowledge has been obtained.
-
-          (b) Contributor APIs.
-          If Contributor's Modifications include an application programming
-          interface and Contributor has knowledge of patent licenses which
-          are reasonably necessary to implement that API, Contributor must
-          also include this information in the LEGAL file.
-
-               (c)    Representations.
-          Contributor represents that, except as disclosed pursuant to
-          Section 3.4(a) above, Contributor believes that Contributor's
-          Modifications are Contributor's original creation(s) and/or
-          Contributor has sufficient rights to grant the rights conveyed by
-          this License.
-
-     3.5. Required Notices.
-     You must duplicate the notice in Exhibit A in each file of the Source
-     Code.  If it is not possible to put such notice in a particular Source
-     Code file due to its structure, then You must include such notice in a
-     location (such as a relevant directory) where a user would be likely
-     to look for such a notice.  If You created one or more Modification(s)
-     You may add your name as a Contributor to the notice described in
-     Exhibit A.  You must also duplicate this License in any documentation
-     for the Source Code where You describe recipients' rights or ownership
-     rights relating to Covered Code.  You may choose to offer, and to
-     charge a fee for, warranty, support, indemnity or liability
-     obligations to one or more recipients of Covered Code. However, You
-     may do so only on Your own behalf, and not on behalf of the Initial
-     Developer or any Contributor. You must make it absolutely clear than
-     any such warranty, support, indemnity or liability obligation is
-     offered by You alone, and You hereby agree to indemnify the Initial
-     Developer and every Contributor for any liability incurred by the
-     Initial Developer or such Contributor as a result of warranty,
-     support, indemnity or liability terms You offer.
-
-     3.6. Distribution of Executable Versions.
-     You may distribute Covered Code in Executable form only if the
-     requirements of Section 3.1-3.5 have been met for that Covered Code,
-     and if You include a notice stating that the Source Code version of
-     the Covered Code is available under the terms of this License,
-     including a description of how and where You have fulfilled the
-     obligations of Section 3.2. The notice must be conspicuously included
-     in any notice in an Executable version, related documentation or
-     collateral in which You describe recipients' rights relating to the
-     Covered Code. You may distribute the Executable version of Covered
-     Code or ownership rights under a license of Your choice, which may
-     contain terms different from this License, provided that You are in
-     compliance with the terms of this License and that the license for the
-     Executable version does not attempt to limit or alter the recipient's
-     rights in the Source Code version from the rights set forth in this
-     License. If You distribute the Executable version under a different
-     license You must make it absolutely clear that any terms which differ
-     from this License are offered by You alone, not by the Initial
-     Developer or any Contributor. You hereby agree to indemnify the
-     Initial Developer and every Contributor for any liability incurred by
-     the Initial Developer or such Contributor as a result of any such
-     terms You offer.
-
-     3.7. Larger Works.
-     You may create a Larger Work by combining Covered Code with other code
-     not governed by the terms of this License and distribute the Larger
-     Work as a single product. In such a case, You must make sure the
-     requirements of this License are fulfilled for the Covered Code.
-
-4. Inability to Comply Due to Statute or Regulation.
-
-     If it is impossible for You to comply with any of the terms of this
-     License with respect to some or all of the Covered Code due to
-     statute, judicial order, or regulation then You must: (a) comply with
-     the terms of this License to the maximum extent possible; and (b)
-     describe the limitations and the code they affect. Such description
-     must be included in the LEGAL file described in Section 3.4 and must
-     be included with all distributions of the Source Code. Except to the
-     extent prohibited by statute or regulation, such description must be
-     sufficiently detailed for a recipient of ordinary skill to be able to
-     understand it.
-
-5. Application of this License.
-
-     This License applies to code to which the Initial Developer has
-     attached the notice in Exhibit A and to related Covered Code.
-
-6. Versions of the License.
-
-     6.1. New Versions.
-     Netscape Communications Corporation ("Netscape") may publish revised
-     and/or new versions of the License from time to time. Each version
-     will be given a distinguishing version number.
-
-     6.2. Effect of New Versions.
-     Once Covered Code has been published under a particular version of the
-     License, You may always continue to use it under the terms of that
-     version. You may also choose to use such Covered Code under the terms
-     of any subsequent version of the License published by Netscape. No one
-     other than Netscape has the right to modify the terms applicable to
-     Covered Code created under this License.
-
-     6.3. Derivative Works.
-     If You create or use a modified version of this License (which you may
-     only do in order to apply it to code which is not already Covered Code
-     governed by this License), You must (a) rename Your license so that
-     the phrases "Mozilla", "MOZILLAPL", "MOZPL", "Netscape",
-     "MPL", "NPL" or any confusingly similar phrase do not appear in your
-     license (except to note that your license differs from this License)
-     and (b) otherwise make it clear that Your version of the license
-     contains terms which differ from the Mozilla Public License and
-     Netscape Public License. (Filling in the name of the Initial
-     Developer, Original Code or Contributor in the notice described in
-     Exhibit A shall not of themselves be deemed to be modifications of
-     this License.)
-
-7. DISCLAIMER OF WARRANTY.
-
-     COVERED CODE IS PROVIDED UNDER THIS LICENSE ON AN "AS IS" BASIS,
-     WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING,
-     WITHOUT LIMITATION, WARRANTIES THAT THE COVERED CODE IS FREE OF
-     DEFECTS, MERCHANTABLE, FIT FOR A PARTICULAR PURPOSE OR NON-INFRINGING.
-     THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE COVERED CODE
-     IS WITH YOU. SHOULD ANY COVERED CODE PROVE DEFECTIVE IN ANY RESPECT,
-     YOU (NOT THE INITIAL DEVELOPER OR ANY OTHER CONTRIBUTOR) ASSUME THE
-     COST OF ANY NECESSARY SERVICING, REPAIR OR CORRECTION. THIS DISCLAIMER
-     OF WARRANTY CONSTITUTES AN ESSENTIAL PART OF THIS LICENSE. NO USE OF
-     ANY COVERED CODE IS AUTHORIZED HEREUNDER EXCEPT UNDER THIS DISCLAIMER.
-
-8. TERMINATION.
-
-     8.1.  This License and the rights granted hereunder will terminate
-     automatically if You fail to comply with terms herein and fail to cure
-     such breach within 30 days of becoming aware of the breach. All
-     sublicenses to the Covered Code which are properly granted shall
-     survive any termination of this License. Provisions which, by their
-     nature, must remain in effect beyond the termination of this License
-     shall survive.
-
-     8.2.  If You initiate litigation by asserting a patent infringement
-     claim (excluding declatory judgment actions) against Initial Developer
-     or a Contributor (the Initial Developer or Contributor against whom
-     You file such action is referred to as "Participant")  alleging that:
-
-     (a)  such Participant's Contributor Version directly or indirectly
-     infringes any patent, then any and all rights granted by such
-     Participant to You under Sections 2.1 and/or 2.2 of this License
-     shall, upon 60 days notice from Participant terminate prospectively,
-     unless if within 60 days after receipt of notice You either: (i)
-     agree in writing to pay Participant a mutually agreeable reasonable
-     royalty for Your past and future use of Modifications made by such
-     Participant, or (ii) withdraw Your litigation claim with respect to
-     the Contributor Version against such Participant.  If within 60 days
-     of notice, a reasonable royalty and payment arrangement are not
-     mutually agreed upon in writing by the parties or the litigation claim
-     is not withdrawn, the rights granted by Participant to You under
-     Sections 2.1 and/or 2.2 automatically terminate at the expiration of
-     the 60 day notice period specified above.
-
-     (b)  any software, hardware, or device, other than such Participant's
-     Contributor Version, directly or indirectly infringes any patent, then
-     any rights granted to You by such Participant under Sections 2.1(b)
-     and 2.2(b) are revoked effective as of the date You first made, used,
-     sold, distributed, or had made, Modifications made by that
-     Participant.
-
-     8.3.  If You assert a patent infringement claim against Participant
-     alleging that such Participant's Contributor Version directly or
-     indirectly infringes any patent where such claim is resolved (such as
-     by license or settlement) prior to the initiation of patent
-     infringement litigation, then the reasonable value of the licenses
-     granted by such Participant under Sections 2.1 or 2.2 shall be taken
-     into account in determining the amount or value of any payment or
-     license.
-
-     8.4.  In the event of termination under Sections 8.1 or 8.2 above,
-     all end user license agreements (excluding distributors and resellers)
-     which have been validly granted by You or any distributor hereunder
-     prior to termination shall survive termination.
-
-9. LIMITATION OF LIABILITY.
-
-     UNDER NO CIRCUMSTANCES AND UNDER NO LEGAL THEORY, WHETHER TORT
-     (INCLUDING NEGLIGENCE), CONTRACT, OR OTHERWISE, SHALL YOU, THE INITIAL
-     DEVELOPER, ANY OTHER CONTRIBUTOR, OR ANY DISTRIBUTOR OF COVERED CODE,
-     OR ANY SUPPLIER OF ANY OF SUCH PARTIES, BE LIABLE TO ANY PERSON FOR
-     ANY INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES OF ANY
-     CHARACTER INCLUDING, WITHOUT LIMITATION, DAMAGES FOR LOSS OF GOODWILL,
-     WORK STOPPAGE, COMPUTER FAILURE OR MALFUNCTION, OR ANY AND ALL OTHER
-     COMMERCIAL DAMAGES OR LOSSES, EVEN IF SUCH PARTY SHALL HAVE BEEN
-     INFORMED OF THE POSSIBILITY OF SUCH DAMAGES. THIS LIMITATION OF
-     LIABILITY SHALL NOT APPLY TO LIABILITY FOR DEATH OR PERSONAL INJURY
-     RESULTING FROM SUCH PARTY'S NEGLIGENCE TO THE EXTENT APPLICABLE LAW
-     PROHIBITS SUCH LIMITATION. SOME JURISDICTIONS DO NOT ALLOW THE
-     EXCLUSION OR LIMITATION OF INCIDENTAL OR CONSEQUENTIAL DAMAGES, SO
-     THIS EXCLUSION AND LIMITATION MAY NOT APPLY TO YOU.
-
-10. U.S. GOVERNMENT END USERS.
-
-     The Covered Code is a "commercial item," as that term is defined in
-     48 C.F.R. 2.101 (Oct. 1995), consisting of "commercial computer
-     software" and "commercial computer software documentation," as such
-     terms are used in 48 C.F.R. 12.212 (Sept. 1995). Consistent with 48
-     C.F.R. 12.212 and 48 C.F.R. 227.7202-1 through 227.7202-4 (June 1995),
-     all U.S. Government End Users acquire Covered Code with only those
-     rights set forth herein.
-
-11. MISCELLANEOUS.
-
-     This License represents the complete agreement concerning subject
-     matter hereof. If any provision of this License is held to be
-     unenforceable, such provision shall be reformed only to the extent
-     necessary to make it enforceable. This License shall be governed by
-     California law provisions (except to the extent applicable law, if
-     any, provides otherwise), excluding its conflict-of-law provisions.
-     With respect to disputes in which at least one party is a citizen of,
-     or an entity chartered or registered to do business in the United
-     States of America, any litigation relating to this License shall be
-     subject to the jurisdiction of the Federal Courts of the Northern
-     District of California, with venue lying in Santa Clara County,
-     California, with the losing party responsible for costs, including
-     without limitation, court costs and reasonable attorneys' fees and
-     expenses. The application of the United Nations Convention on
-     Contracts for the International Sale of Goods is expressly excluded.
-     Any law or regulation which provides that the language of a contract
-     shall be construed against the drafter shall not apply to this
-     License.
-
-12. RESPONSIBILITY FOR CLAIMS.
-
-     As between Initial Developer and the Contributors, each party is
-     responsible for claims and damages arising, directly or indirectly,
-     out of its utilization of rights under this License and You agree to
-     work with Initial Developer and Contributors to distribute such
-     responsibility on an equitable basis. Nothing herein is intended or
-     shall be deemed to constitute any admission of liability.
-
-13. MULTIPLE-LICENSED CODE.
-
-     Initial Developer may designate portions of the Covered Code as
-     "Multiple-Licensed".  "Multiple-Licensed" means that the Initial
-     Developer permits you to utilize portions of the Covered Code under
-     Your choice of the NPL or the alternative licenses, if any, specified
-     by the Initial Developer in the file described in Exhibit A.
-
-EXHIBIT A -Mozilla Public License.
-
-     ``The contents of this file are subject to the Mozilla Public License
-     Version 1.1 (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.mozilla.org/MPL/
-
-     Software distributed under the License is distributed on an "AS IS"
-     basis, WITHOUT WARRANTY OF ANY KIND, either express or implied. See the
-     License for the specific language governing rights and limitations
-     under the License.
-
-     The Original Code is ______________________________________.
-
-     The Initial Developer of the Original Code is ________________________.
-     Portions created by ______________________ are Copyright (C) ______
-     _______________________. All Rights Reserved.
-
-     Contributor(s): ______________________________________.
-
-     Alternatively, the contents of this file may be used under the terms
-     of the _____ license (the  "[___] License"), in which case the
-     provisions of [______] License are applicable instead of those
-     above.  If you wish to allow use of your version of this file only
-     under the terms of the [____] License and not to allow others to use
-     your version of this file under the MPL, indicate your decision by
-     deleting  the provisions above and replace  them with the notice and
-     other provisions required by the [___] License.  If you do not delete
-     the provisions above, a recipient may use your version of this file
-     under either the MPL or the [___] License."
-
-     [NOTE: The text of this Exhibit A may differ slightly from the text of
-     the notices in the Source Code files of the Original Code. You should
-     use the text of this Exhibit A rather than the text found in the
-     Original Code Source Code for Your Modifications.]
-
+Copyright (C) 2013 Glyptodon LLC
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/guacamole-ext/pom.xml b/guacamole-ext/pom.xml
index 4ea7647..0c22a2d 100644
--- a/guacamole-ext/pom.xml
+++ b/guacamole-ext/pom.xml
@@ -5,7 +5,7 @@
     <groupId>org.glyptodon.guacamole</groupId>
     <artifactId>guacamole-ext</artifactId>
     <packaging>jar</packaging>
-    <version>0.8.1</version>
+    <version>0.9.9</version>
     <name>guacamole-ext</name>
     <url>http://guac-dev.org/</url>
 
@@ -17,18 +17,8 @@
     <!-- All applicable licenses -->
     <licenses>
         <license>
-            <name>Mozilla Public License Version 1.1</name>
-            <url>http://www.mozilla.org/MPL/1.1/</url>
-            <distribution>repo</distribution>
-        </license>
-        <license>
-            <name>GNU General Public License, version 2</name>
-            <url>http://www.gnu.org/licenses/gpl-2.0.html</url>
-            <distribution>repo</distribution>
-        </license>
-        <license>
-            <name>GNU Lesser General Public License, version 2.1</name>
-            <url>http://www.gnu.org/licenses/lgpl-2.1.html</url>
+            <name>The MIT License</name>
+            <url>http://www.opensource.org/licenses/mit-license.php</url>
             <distribution>repo</distribution>
         </license>
     </licenses>
@@ -64,9 +54,15 @@
             <plugin>
                 <groupId>org.apache.maven.plugins</groupId>
                 <artifactId>maven-compiler-plugin</artifactId>
+                <version>3.3</version>
                 <configuration>
                     <source>1.6</source>
                     <target>1.6</target>
+                    <compilerArgs>
+                        <arg>-Xlint:all</arg>
+                        <arg>-Werror</arg>
+                    </compilerArgs>
+                    <fork>true</fork>
                 </configuration>
             </plugin>
 
@@ -74,6 +70,7 @@
             <plugin>
                 <groupId>org.apache.maven.plugins</groupId>
                 <artifactId>maven-source-plugin</artifactId>
+                <version>2.4</version>
                 <executions>
                     <execution>
                         <id>attach-sources</id>
@@ -88,6 +85,7 @@
             <plugin>
                 <groupId>org.apache.maven.plugins</groupId>
                 <artifactId>maven-javadoc-plugin</artifactId>
+                <version>2.10.3</version>
                 <configuration>
                     <detectOfflineLinks>false</detectOfflineLinks>
                 </configuration>
@@ -118,10 +116,25 @@
         <dependency>
             <groupId>org.glyptodon.guacamole</groupId>
             <artifactId>guacamole-common</artifactId>
-            <version>0.8.0</version>
+            <version>0.9.9</version>
             <scope>compile</scope>
         </dependency>
 
+        <!-- JUnit -->
+        <dependency>
+            <groupId>junit</groupId>
+            <artifactId>junit</artifactId>
+            <version>4.10</version>
+            <scope>test</scope>
+        </dependency>
+
+        <!-- Jackson for JSON support -->
+        <dependency>
+            <groupId>org.codehaus.jackson</groupId>
+            <artifactId>jackson-mapper-asl</artifactId>
+            <version>1.9.2</version>
+        </dependency>
+
     </dependencies>
 
 </project>
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/environment/Environment.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/environment/Environment.java
new file mode 100644
index 0000000..3882cba
--- /dev/null
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/environment/Environment.java
@@ -0,0 +1,154 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ * 
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ * 
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ * 
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.environment;
+
+import java.io.File;
+import java.util.Map;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.properties.BooleanGuacamoleProperty;
+import org.glyptodon.guacamole.properties.GuacamoleProperty;
+import org.glyptodon.guacamole.properties.IntegerGuacamoleProperty;
+import org.glyptodon.guacamole.properties.StringGuacamoleProperty;
+import org.glyptodon.guacamole.protocols.ProtocolInfo;
+
+/**
+ * The environment of an arbitrary Guacamole instance, describing available
+ * protocols, configuration parameters, and the GUACAMOLE_HOME directory.
+ *
+ * @author Michael Jumper
+ */
+public interface Environment {
+
+    /**
+     * The hostname of the server where guacd (the Guacamole proxy server) is
+     * running.
+     */
+    public static final StringGuacamoleProperty GUACD_HOSTNAME = new StringGuacamoleProperty() {
+
+        @Override
+        public String getName() { return "guacd-hostname"; }
+
+    };
+
+    /**
+     * The port that guacd (the Guacamole proxy server) is listening on.
+     */
+    public static final IntegerGuacamoleProperty GUACD_PORT = new IntegerGuacamoleProperty() {
+
+        @Override
+        public String getName() { return "guacd-port"; }
+
+    };
+
+    /**
+     * Whether guacd requires SSL/TLS on connections.
+     */
+    public static final BooleanGuacamoleProperty GUACD_SSL = new BooleanGuacamoleProperty() {
+
+        @Override
+        public String getName() { return "guacd-ssl"; }
+
+    };
+
+    /**
+     * Returns the Guacamole home directory as determined when this Environment
+     * object was created. The Guacamole home directory is found by checking, in
+     * order: the guacamole.home system property, the GUACAMOLE_HOME environment
+     * variable, and finally the .guacamole directory in the home directory of
+     * the user running the servlet container.
+     *
+     * @return The File representing the Guacamole home directory, which may
+     *         or may not exist, and may turn out to not be a directory.
+     */
+    public File getGuacamoleHome();
+
+    /**
+     * Returns a map of all available protocols, where each key is the name of
+     * that protocol as would be passed to guacd during connection.
+     *
+     * @return A map of all available protocols.
+     */
+    public Map<String, ProtocolInfo> getProtocols();
+
+    /**
+     * Returns the protocol having the given name. The name must be the
+     * protocol name as would be passed to guacd during connection.
+     *
+     * @param name The name of the protocol.
+     * @return The protocol having the given name, or null if no such
+     *         protocol is registered.
+     */
+    public ProtocolInfo getProtocol(String name);
+
+    /**
+     * Given a GuacamoleProperty, parses and returns the value set for that
+     * property in guacamole.properties, if any.
+     *
+     * @param <Type> The type that the given property is parsed into.
+     * @param property The property to read from guacamole.properties.
+     * @return The parsed value of the property as read from
+     *         guacamole.properties.
+     * @throws GuacamoleException If an error occurs while parsing the value
+     *                            for the given property in
+     *                            guacamole.properties.
+     */
+    public <Type> Type getProperty(GuacamoleProperty<Type> property)
+            throws GuacamoleException;
+
+    /**
+     * Given a GuacamoleProperty, parses and returns the value set for that
+     * property in guacamole.properties, if any. If no value is found, the
+     * provided default value is returned.
+     *
+     * @param <Type> The type that the given property is parsed into.
+     * @param property The property to read from guacamole.properties.
+     * @param defaultValue The value to return if no value was given in
+     *                     guacamole.properties.
+     * @return The parsed value of the property as read from
+     *         guacamole.properties, or the provided default value if no value
+     *         was found.
+     * @throws GuacamoleException If an error occurs while parsing the value
+     *                            for the given property in
+     *                            guacamole.properties.
+     */
+    public <Type> Type getProperty(GuacamoleProperty<Type> property,
+            Type defaultValue) throws GuacamoleException;
+
+    /**
+     * Given a GuacamoleProperty, parses and returns the value set for that
+     * property in guacamole.properties. An exception is thrown if the value
+     * is not provided.
+     *
+     * @param <Type> The type that the given property is parsed into.
+     * @param property The property to read from guacamole.properties.
+     * @return The parsed value of the property as read from
+     *         guacamole.properties.
+     * @throws GuacamoleException If an error occurs while parsing the value
+     *                            for the given property in
+     *                            guacamole.properties, or if the property is
+     *                            not specified.
+     */
+    public <Type> Type getRequiredProperty(GuacamoleProperty<Type> property)
+            throws GuacamoleException;
+
+}
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/environment/LocalEnvironment.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/environment/LocalEnvironment.java
new file mode 100644
index 0000000..07fdbab
--- /dev/null
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/environment/LocalEnvironment.java
@@ -0,0 +1,321 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ * 
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ * 
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ * 
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.environment;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FilenameFilter;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Properties;
+import org.codehaus.jackson.map.ObjectMapper;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.GuacamoleServerException;
+import org.glyptodon.guacamole.properties.GuacamoleProperty;
+import org.glyptodon.guacamole.protocols.ProtocolInfo;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The environment of the locally-running Guacamole instance, describing
+ * available protocols, configuration parameters, and the GUACAMOLE_HOME
+ * directory.
+ *
+ * @author Michael Jumper
+ */
+public class LocalEnvironment implements Environment {
+
+    /**
+     * Logger for this class.
+     */
+    private static final Logger logger = LoggerFactory.getLogger(LocalEnvironment.class);
+
+    /**
+     * Array of all known protocol names.
+     */
+    private static final String[] KNOWN_PROTOCOLS = new String[]{
+        "vnc", "rdp", "ssh", "telnet"};
+
+    /**
+     * All properties read from guacamole.properties.
+     */
+    private final Properties properties;
+
+    /**
+     * The location of GUACAMOLE_HOME, which may not truly exist.
+     */
+    private final File guacHome;
+
+    /**
+     * The map of all available protocols.
+     */
+    private final Map<String, ProtocolInfo> availableProtocols;
+
+    /**
+     * The Jackson parser for parsing JSON files.
+     */
+    private static final ObjectMapper mapper = new ObjectMapper();
+
+    /**
+     * Creates a new Environment, initializing that environment based on the
+     * location of GUACAMOLE_HOME and the contents of guacamole.properties.
+     * 
+     * @throws GuacamoleException If an error occurs while determining the
+     *                            environment of this Guacamole instance.
+     */
+    public LocalEnvironment() throws GuacamoleException {
+
+        // Determine location of GUACAMOLE_HOME
+        guacHome = findGuacamoleHome();
+
+        // Read properties
+        properties = new Properties();
+        try {
+
+            InputStream stream = null;
+
+            // If not a directory, load from classpath
+            if (!guacHome.isDirectory())
+                stream = LocalEnvironment.class.getResourceAsStream("/guacamole.properties");
+
+            // Otherwise, try to load from file
+            else {
+                File propertiesFile = new File(guacHome, "guacamole.properties");
+                if (propertiesFile.exists())
+                    stream = new FileInputStream(propertiesFile);
+            }
+
+            // Load properties from stream, if any, always closing stream when done
+            if (stream != null) {
+                try { properties.load(stream); }
+                finally { stream.close(); }
+            }
+
+            // Notify if we're proceeding without guacamole.properties
+            else
+                logger.info("No guacamole.properties file found within GUACAMOLE_HOME or the classpath. Using defaults.");
+
+        }
+        catch (IOException e) {
+            logger.warn("The guacamole.properties file within GUACAMOLE_HOME cannot be read: {}", e.getMessage());
+            logger.debug("Error reading guacamole.properties.", e);
+        }
+
+        // Read all protocols
+        availableProtocols = readProtocols();
+
+    }
+
+    /**
+     * Locates the Guacamole home directory by checking, in order:
+     * the guacamole.home system property, the GUACAMOLE_HOME environment
+     * variable, and finally the .guacamole directory in the home directory of
+     * the user running the servlet container.
+     *
+     * @return The File representing the Guacamole home directory, which may
+     *         or may not exist, and may turn out to not be a directory.
+     */
+    private static File findGuacamoleHome() {
+
+        // Attempt to find Guacamole home
+        File guacHome;
+
+        // Use system property by default
+        String desiredDir = System.getProperty("guacamole.home");
+
+        // Failing that, try the GUACAMOLE_HOME environment variable
+        if (desiredDir == null) desiredDir = System.getenv("GUACAMOLE_HOME");
+
+        // If successful, use explicitly specified directory
+        if (desiredDir != null)
+            guacHome = new File(desiredDir);
+
+        // If not explicitly specified, use ~/.guacamole
+        else
+            guacHome = new File(System.getProperty("user.home"), ".guacamole");
+
+        // Return discovered directory
+        return guacHome;
+
+    }
+
+    /**
+     * Parses the given JSON file, returning the parsed ProtocolInfo. The JSON
+     * format is conveniently and intentionally identical to a serialized
+     * ProtocolInfo object, which is identical to the JSON format used by the
+     * protocol REST service built into the Guacamole web application.
+     *
+     * @param input
+     *     An input stream containing JSON describing the forms and parameters
+     *     associated with a protocol supported by Guacamole.
+     *
+     * @return
+     *     A new ProtocolInfo object which contains the forms and parameters
+     *     described by the JSON file parsed.
+     *
+     * @throws IOException
+     *     If an error occurs while parsing the JSON file.
+     */
+    private ProtocolInfo readProtocol(InputStream input)
+            throws IOException {
+        return mapper.readValue(input, ProtocolInfo.class);
+    }
+
+    /**
+     * Reads through all pre-defined protocols and any protocols within the
+     * "protocols" subdirectory of GUACAMOLE_HOME, returning a map containing
+     * each of these protocols. The key of each entry will be the name of that
+     * protocol, as would be passed to guacd during connection.
+     *
+     * @return
+     *     A map of all available protocols.
+     */
+    private Map<String, ProtocolInfo> readProtocols() {
+
+        // Map of all available protocols
+        Map<String, ProtocolInfo> protocols = new HashMap<String, ProtocolInfo>();
+
+        // Get protcols directory
+        File protocol_directory = new File(getGuacamoleHome(), "protocols");
+
+        // Read protocols from directory if it exists
+        if (protocol_directory.isDirectory()) {
+
+            // Get all JSON files
+            File[] files = protocol_directory.listFiles(
+                new FilenameFilter() {
+
+                    @Override
+                    public boolean accept(File file, String string) {
+                        return string.endsWith(".json");
+                    }
+
+                }
+            );
+
+            // Warn if directory contents are not available
+            if (files == null) {
+                logger.error("Unable to read contents of \"{}\".", protocol_directory.getAbsolutePath());
+                files = new File[0];
+            }
+            
+            // Load each protocol from each file
+            for (File file : files) {
+
+                try {
+
+                    // Parse protocol
+                    FileInputStream stream = new FileInputStream(file);
+                    ProtocolInfo protocol = readProtocol(stream);
+                    stream.close();
+
+                    // Store protocol
+                    protocols.put(protocol.getName(), protocol);
+
+                }
+                catch (IOException e) {
+                    logger.error("Unable to read connection parameter information from \"{}\": {}", file.getAbsolutePath(), e.getMessage());
+                    logger.debug("Error reading protocol JSON.", e);
+                }
+
+            }
+
+        }
+
+        // If known protocols are not already defined, read from classpath
+        for (String protocol : KNOWN_PROTOCOLS) {
+
+            // If protocol not defined yet, attempt to load from classpath
+            if (!protocols.containsKey(protocol)) {
+
+                InputStream stream = LocalEnvironment.class.getResourceAsStream(
+                        "/org/glyptodon/guacamole/protocols/"
+                        + protocol + ".json");
+
+                // Parse JSON if available
+                if (stream != null) {
+                    try {
+                        protocols.put(protocol, readProtocol(stream));
+                    }
+                    catch (IOException e) {
+                        logger.error("Unable to read pre-defined connection parameter information for protocol \"{}\": {}", protocol, e.getMessage());
+                        logger.debug("Error reading pre-defined protocol JSON.", e);
+                    }
+                }
+
+            }
+
+        }
+
+        // Protocols map now fully populated
+        return protocols;
+
+    }
+
+    @Override
+    public File getGuacamoleHome() {
+        return guacHome;
+    }
+
+    @Override
+    public <Type> Type getProperty(GuacamoleProperty<Type> property) throws GuacamoleException {
+        return property.parseValue(properties.getProperty(property.getName()));
+    }
+
+    @Override
+    public <Type> Type getProperty(GuacamoleProperty<Type> property,
+            Type defaultValue) throws GuacamoleException {
+
+        Type value = getProperty(property);
+        if (value == null)
+            return defaultValue;
+
+        return value;
+
+    }
+
+    @Override
+    public <Type> Type getRequiredProperty(GuacamoleProperty<Type> property)
+            throws GuacamoleException {
+
+        Type value = getProperty(property);
+        if (value == null)
+            throw new GuacamoleServerException("Property " + property.getName() + " is required.");
+
+        return value;
+
+    }
+
+    @Override
+    public Map<String, ProtocolInfo> getProtocols() {
+        return availableProtocols;
+    }
+
+    @Override
+    public ProtocolInfo getProtocol(String name) {
+        return availableProtocols.get(name);
+    }
+
+}
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/form/BooleanField.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/form/BooleanField.java
new file mode 100644
index 0000000..d033a7d
--- /dev/null
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/form/BooleanField.java
@@ -0,0 +1,53 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.form;
+
+import java.util.Collections;
+
+/**
+ * Represents a field with strictly one possible value. It is assumed that the
+ * field may be blank, but that its sole non-blank value is the value provided.
+ * The provided value represents "true" while all other values, including
+ * having no associated value, represent "false".
+ *
+ * @author Michael Jumper
+ */
+public class BooleanField extends Field {
+
+    /**
+     * Creates a new BooleanField with the given name and truth value. The
+     * truth value is the value that, when assigned to this field, means that
+     * this field is "true".
+     *
+     * @param name
+     *     The unique name to associate with this field.
+     *
+     * @param truthValue
+     *     The value to consider "true" for this field. All other values will
+     *     be considered "false".
+     */
+    public BooleanField(String name, String truthValue) {
+        super(name, Field.Type.BOOLEAN, Collections.singletonList(truthValue));
+    }
+
+}
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/form/DateField.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/form/DateField.java
new file mode 100644
index 0000000..20ff257
--- /dev/null
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/form/DateField.java
@@ -0,0 +1,97 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.form;
+
+import java.text.DateFormat;
+import java.text.ParseException;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+
+/**
+ * Represents a date field. The field may contain only date values which
+ * conform to a standard pattern, defined by DateField.FORMAT.
+ *
+ * @author Michael Jumper
+ */
+public class DateField extends Field {
+
+    /**
+     * The date format used by date fields, compatible with SimpleDateFormat.
+     */
+    public static final String FORMAT = "yyyy-MM-dd";
+
+    /**
+     * Creates a new DateField with the given name.
+     *
+     * @param name
+     *     The unique name to associate with this field.
+     */
+    public DateField(String name) {
+        super(name, Field.Type.DATE);
+    }
+
+    /**
+     * Converts the given date into a string which follows the format used by
+     * date fields.
+     *
+     * @param date
+     *     The date value to format, which may be null.
+     *
+     * @return
+     *     The formatted date, or null if the provided time was null.
+     */
+    public static String format(Date date) {
+        DateFormat dateFormat = new SimpleDateFormat(DateField.FORMAT);
+        return date == null ? null : dateFormat.format(date);
+    }
+
+    /**
+     * Parses the given string into a corresponding date. The string must
+     * follow the standard format used by date fields, as defined by FORMAT
+     * and as would be produced by format().
+     *
+     * @param dateString
+     *     The date string to parse, which may be null.
+     *
+     * @return
+     *     The date corresponding to the given date string, or null if the
+     *     provided date string was null or blank.
+     *
+     * @throws ParseException
+     *     If the given date string does not conform to the standard format
+     *     used by date fields.
+     */
+    public static Date parse(String dateString)
+            throws ParseException {
+
+        // Return null if no date provided
+        if (dateString == null || dateString.isEmpty())
+            return null;
+
+        // Parse date according to format
+        DateFormat dateFormat = new SimpleDateFormat(DateField.FORMAT);
+        return dateFormat.parse(dateString);
+
+    }
+
+}
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/form/EnumField.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/form/EnumField.java
new file mode 100644
index 0000000..272b52f
--- /dev/null
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/form/EnumField.java
@@ -0,0 +1,48 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.form;
+
+import java.util.Collection;
+
+/**
+ * Represents an arbitrary field with a finite, enumerated set of possible
+ * values.
+ *
+ * @author Michael Jumper
+ */
+public class EnumField extends Field {
+
+    /**
+     * Creates a new EnumField with the given name and possible values.
+     *
+     * @param name
+     *     The unique name to associate with this field.
+     *
+     * @param options
+     *     All possible legal options for this field.
+     */
+    public EnumField(String name, Collection<String> options) {
+        super(name, Field.Type.ENUM, options);
+    }
+
+}
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/form/Field.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/form/Field.java
new file mode 100644
index 0000000..7e6f525
--- /dev/null
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/form/Field.java
@@ -0,0 +1,222 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.form;
+
+import java.util.Collection;
+import org.codehaus.jackson.map.annotate.JsonSerialize;
+
+/**
+ * Represents an arbitrary field, such as an HTTP parameter, the parameter of a
+ * remote desktop protocol, or an input field within a form. Fields are generic
+ * and typed dynamically through a type string, with the semantics of the field
+ * defined by the type string. The behavior of each field type is defined
+ * either through the web application itself (see FormService.js) or through
+ * extensions.
+ *
+ * @author Michael Jumper
+ */
+ at JsonSerialize(include=JsonSerialize.Inclusion.NON_NULL)
+public class Field {
+
+    /**
+     * All types of fields which are available by default. Additional field
+     * types may be defined by extensions by using a unique field type name and
+     * registering that name with the form service within JavaScript.
+     *
+     * See FormService.js.
+     */
+    public static class Type {
+
+        /**
+         * A text field, accepting arbitrary values.
+         */
+        public static String TEXT = "TEXT";
+
+        /**
+         * A username field. This field type generally behaves identically to
+         * arbitrary text fields, but has semantic differences.
+         */
+        public static String USERNAME = "USERNAME";
+
+        /**
+         * A password field, whose value is sensitive and must be hidden.
+         */
+        public static String PASSWORD = "PASSWORD";
+
+        /**
+         * A numeric field, whose value must contain only digits.
+         */
+        public static String NUMERIC = "NUMERIC";
+
+        /**
+         * A boolean field, whose value is either blank or "true".
+         */
+        public static String BOOLEAN = "BOOLEAN";
+
+        /**
+         * An enumerated field, whose legal values are fully enumerated by a
+         * provided, finite list.
+         */
+        public static String ENUM = "ENUM";
+
+        /**
+         * A text field that can span more than one line.
+         */
+        public static String MULTILINE = "MULTILINE";
+
+        /**
+         * A time zone field whose legal values are only valid time zone IDs,
+         * as dictated by Java within TimeZone.getAvailableIDs().
+         */
+        public static String TIMEZONE = "TIMEZONE";
+
+        /**
+         * A date field whose legal values conform to the pattern "YYYY-MM-DD",
+         * zero-padded.
+         */
+        public static String DATE = "DATE";
+
+        /**
+         * A time field whose legal values conform to the pattern "HH:MM:SS",
+         * zero-padded, 24-hour.
+         */
+        public static String TIME = "TIME";
+
+    }
+
+    /**
+     * The unique name that identifies this field.
+     */
+    private String name;
+
+    /**
+     * The type of this field.
+     */
+    private String type;
+
+    /**
+     * A collection of all legal values of this field.
+     */
+    private Collection<String> options;
+
+    /**
+     * Creates a new Parameter with no associated name or type.
+     */
+    public Field() {
+    }
+
+    /**
+     * Creates a new Field with the given name  and type.
+     *
+     * @param name
+     *     The unique name to associate with this field.
+     *
+     * @param type
+     *     The type of this field.
+     */
+    public Field(String name, String type) {
+        this.name  = name;
+        this.type  = type;
+    }
+
+    /**
+     * Creates a new Field with the given name, type, and possible values.
+     *
+     * @param name
+     *     The unique name to associate with this field.
+     *
+     * @param type
+     *     The type of this field.
+     *
+     * @param options
+     *     A collection of all possible valid options for this field.
+     */
+    public Field(String name, String type, Collection<String> options) {
+        this.name    = name;
+        this.type    = type;
+        this.options = options;
+    }
+
+    /**
+     * Returns the unique name associated with this field.
+     *
+     * @return
+     *     The unique name associated with this field.
+     */
+    public String getName() {
+        return name;
+    }
+
+    /**
+     * Sets the unique name associated with this field.
+     *
+     * @param name
+     *     The unique name to assign to this field.
+     */
+    public void setName(String name) {
+        this.name = name;
+    }
+
+    /**
+     * Returns the type of this field.
+     *
+     * @return
+     *     The type of this field.
+     */
+    public String getType() {
+        return type;
+    }
+
+    /**
+     * Sets the type of this field.
+     *
+     * @param type
+     *     The type of this field.
+     */
+    public void setType(String type) {
+        this.type = type;
+    }
+
+    /**
+     * Returns a mutable collection of field options. Changes to this
+     * collection directly affect the available options.
+     *
+     * @return
+     *     A mutable collection of field options, or null if the field has no
+     *     options.
+     */
+    public Collection<String> getOptions() {
+        return options;
+    }
+
+    /**
+     * Sets the options available as possible values of this field.
+     *
+     * @param options
+     *     The options to associate with this field.
+     */
+    public void setOptions(Collection<String> options) {
+        this.options = options;
+    }
+
+}
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/form/FieldOption.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/form/FieldOption.java
new file mode 100644
index 0000000..499a069
--- /dev/null
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/form/FieldOption.java
@@ -0,0 +1,105 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.form;
+
+import org.codehaus.jackson.map.annotate.JsonSerialize;
+
+/**
+ * Describes an available legal value for an enumerated field.
+ *
+ * @author Michael Jumper
+ */
+ at JsonSerialize(include=JsonSerialize.Inclusion.NON_NULL)
+public class FieldOption {
+
+    /**
+     * The value that will be assigned if this option is chosen.
+     */
+    private String value;
+
+    /**
+     * A human-readable title describing the effect of the value.
+     */
+    private String title;
+
+    /**
+     * Creates a new FieldOption with no associated value or title.
+     */
+    public FieldOption() {
+    }
+
+    /**
+     * Creates a new FieldOption having the given value and title.
+     *
+     * @param value
+     *     The value to assign if this option is chosen.
+     *
+     * @param title
+     *     The human-readable title to associate with this option.
+     */
+    public FieldOption(String value, String title) {
+        this.value = value;
+        this.title = title;
+    }
+
+    /**
+     * Returns the value that will be assigned if this option is chosen.
+     *
+     * @return
+     *     The value that will be assigned if this option is chosen.
+     */
+    public String getValue() {
+        return value;
+    }
+
+    /**
+     * Sets the value that will be assigned if this option is chosen.
+     *
+     * @param value
+     *     The value to assign if this option is chosen.
+     */
+    public void setValue(String value) {
+        this.value = value;
+    }
+
+    /**
+     * Returns the human-readable title describing the effect of this option.
+     *
+     * @return
+     *     The human-readable title describing the effect of this option.
+     */
+    public String getTitle() {
+        return title;
+    }
+
+    /**
+     * Sets the human-readable title describing the effect of this option.
+     *
+     * @param title
+     *     A human-readable title describing the effect of this option.
+     */
+    public void setTitle(String title) {
+        this.title = title;
+    }
+
+}
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/form/Form.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/form/Form.java
new file mode 100644
index 0000000..3052d62
--- /dev/null
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/form/Form.java
@@ -0,0 +1,114 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.form;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import org.codehaus.jackson.map.annotate.JsonSerialize;
+
+/**
+ * Information which describes logical set of fields.
+ *
+ * @author Michael Jumper
+ */
+ at JsonSerialize(include=JsonSerialize.Inclusion.NON_NULL)
+public class Form {
+
+    /**
+     * The name of this form. The form name must identify the form uniquely
+     * from other forms.
+     */
+    private String name;
+
+    /**
+     * All fields associated with this form.
+     */
+    private Collection<Field> fields;
+
+    /**
+     * Creates a new Form object with no associated fields. The name is left
+     * unset as null. If no form name is provided, this form must not be used
+     * in the same context as another unnamed form.
+     */
+    public Form() {
+        fields = new ArrayList<Field>();
+    }
+
+    /**
+     * Creates a new Form object having the given name and containing the given
+     * fields.
+     *
+     * @param name
+     *     A name which uniquely identifies this form.
+     *
+     * @param fields
+     *     The fields to provided within the new Form.
+     */
+    public Form(String name, Collection<Field> fields) {
+        this.name = name;
+        this.fields = fields;
+    }
+
+    /**
+     * Returns a mutable collection of the fields associated with this form.
+     * Changes to this collection affect the fields exposed to the user.
+     *
+     * @return
+     *     A mutable collection of fields.
+     */
+    public Collection<Field> getFields() {
+        return fields;
+    }
+
+    /**
+     * Sets the collection of fields associated with this form.
+     *
+     * @param fields
+     *     The collection of fields to associate with this form.
+     */
+    public void setFields(Collection<Field> fields) {
+        this.fields = fields;
+    }
+
+    /**
+     * Returns the name of this form. Form names must uniquely identify each
+     * form.
+     *
+     * @return
+     *     The name of this form, or null if the form has no name.
+     */
+    public String getName() {
+        return name;
+    }
+
+    /**
+     * Sets the name of this form. Form names must uniquely identify each form.
+     *
+     * @param name
+     *     The name to assign to this form.
+     */
+    public void setName(String name) {
+        this.name = name;
+    }
+
+}
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/form/MultilineField.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/form/MultilineField.java
new file mode 100644
index 0000000..158eec7
--- /dev/null
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/form/MultilineField.java
@@ -0,0 +1,42 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.form;
+
+/**
+ * Represents a field which can contain multiple lines of text.
+ *
+ * @author Michael Jumper
+ */
+public class MultilineField extends Field {
+
+    /**
+     * Creates a new MultilineField with the given name.
+     *
+     * @param name
+     *     The unique name to associate with this field.
+     */
+    public MultilineField(String name) {
+        super(name, Field.Type.MULTILINE);
+    }
+
+}
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/form/NumericField.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/form/NumericField.java
new file mode 100644
index 0000000..12f78ed
--- /dev/null
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/form/NumericField.java
@@ -0,0 +1,88 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.form;
+
+/**
+ * Represents a field which may contain only integer values.
+ *
+ * @author Michael Jumper
+ */
+public class NumericField extends Field {
+
+    /**
+     * Creates a new NumericField with the given name.
+     *
+     * @param name
+     *     The unique name to associate with this field.
+     */
+    public NumericField(String name) {
+        super(name, Field.Type.NUMERIC);
+    }
+
+    /**
+     * Formats the given integer in the format required by a numeric field.
+     *
+     * @param i
+     *     The integer to format, which may be null.
+     *
+     * @return
+     *     A string representation of the given integer, or null if the given
+     *     integer was null.
+     */
+    public static String format(Integer i) {
+
+        // Return null if no value provided
+        if (i == null)
+            return null;
+
+        // Convert to string
+        return i.toString();
+
+    }
+
+    /**
+     * Parses the given string as an integer, where the given string is in the
+     * format required by a numeric field.
+     *
+     * @param str
+     *     The string to parse as an integer, which may be null.
+     *
+     * @return
+     *     The integer representation of the given string, or null if the given
+     *     string was null.
+     *
+     * @throws NumberFormatException
+     *     If the given string is not in a parseable format.
+     */
+    public static Integer parse(String str) throws NumberFormatException {
+
+        // Return null if no value provided
+        if (str == null || str.isEmpty())
+            return null;
+
+        // Parse as integer
+        return new Integer(str);
+
+    }
+
+}
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/form/PasswordField.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/form/PasswordField.java
new file mode 100644
index 0000000..c48a659
--- /dev/null
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/form/PasswordField.java
@@ -0,0 +1,43 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.form;
+
+/**
+ * Represents a field which contains sensitive text information related to
+ * authenticating a user.
+ *
+ * @author Michael Jumper
+ */
+public class PasswordField extends Field {
+
+    /**
+     * Creates a new PasswordField with the given name.
+     *
+     * @param name
+     *     The unique name to associate with this field.
+     */
+    public PasswordField(String name) {
+        super(name, Field.Type.PASSWORD);
+    }
+
+}
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/form/TextField.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/form/TextField.java
new file mode 100644
index 0000000..75cae7b
--- /dev/null
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/form/TextField.java
@@ -0,0 +1,43 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.form;
+
+/**
+ * Represents a basic text field. The field may generally contain any data, but
+ * may not contain multiple lines.
+ *
+ * @author Michael Jumper
+ */
+public class TextField extends Field {
+
+    /**
+     * Creates a new TextField with the given name.
+     *
+     * @param name
+     *     The unique name to associate with this field.
+     */
+    public TextField(String name) {
+        super(name, Field.Type.TEXT);
+    }
+
+}
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/form/TimeField.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/form/TimeField.java
new file mode 100644
index 0000000..3d7ee7d
--- /dev/null
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/form/TimeField.java
@@ -0,0 +1,97 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.form;
+
+import java.text.DateFormat;
+import java.text.ParseException;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+
+/**
+ * Represents a time field. The field may contain only time values which
+ * conform to a standard pattern, defined by TimeField.FORMAT.
+ *
+ * @author Michael Jumper
+ */
+public class TimeField extends Field {
+
+    /**
+     * The time format used by time fields, compatible with SimpleDateFormat.
+     */
+    public static final String FORMAT = "HH:mm:ss";
+
+    /**
+     * Creates a new TimeField with the given name.
+     *
+     * @param name
+     *     The unique name to associate with this field.
+     */
+    public TimeField(String name) {
+        super(name, Field.Type.TIME);
+    }
+
+    /**
+     * Parses the given string into a corresponding time. The string must
+     * follow the standard format used by time fields, as defined by
+     * FORMAT and as would be produced by format().
+     *
+     * @param timeString
+     *     The time string to parse, which may be null.
+     *
+     * @return
+     *     The time corresponding to the given time string, or null if the
+     *     provided time string was null or blank.
+     *
+     * @throws ParseException
+     *     If the given time string does not conform to the standard format
+     *     used by time fields.
+     */
+    public static Date parse(String timeString)
+            throws ParseException {
+
+        // Return null if no time provided
+        if (timeString == null || timeString.isEmpty())
+            return null;
+
+        // Parse time according to format
+        DateFormat timeFormat = new SimpleDateFormat(TimeField.FORMAT);
+        return timeFormat.parse(timeString);
+
+    }
+
+    /**
+     * Converts the given time into a string which follows the format used by
+     * time fields.
+     *
+     * @param time
+     *     The time value to format, which may be null.
+     *
+     * @return
+     *     The formatted time, or null if the provided time was null.
+     */
+    public static String format(Date time) {
+        DateFormat timeFormat = new SimpleDateFormat(TimeField.FORMAT);
+        return time == null ? null : timeFormat.format(time);
+    }
+
+}
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/form/TimeZoneField.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/form/TimeZoneField.java
new file mode 100644
index 0000000..09a5e64
--- /dev/null
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/form/TimeZoneField.java
@@ -0,0 +1,66 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.form;
+
+/**
+ * Represents a time zone field. The field may contain only valid time zone IDs,
+ * as dictated by TimeZone.getAvailableIDs().
+ *
+ * @author Michael Jumper
+ */
+public class TimeZoneField extends Field {
+
+    /**
+     * Creates a new TimeZoneField with the given name.
+     *
+     * @param name
+     *     The unique name to associate with this field.
+     */
+    public TimeZoneField(String name) {
+        super(name, Field.Type.TIMEZONE);
+    }
+
+    /**
+     * Parses the given string into a time zone ID string. As these strings are
+     * equivalent, the only transformation currently performed by this function
+     * is to ensure that a blank time zone string is parsed into null.
+     *
+     * @param timeZone
+     *     The time zone string to parse, which may be null.
+     *
+     * @return
+     *     The ID of the time zone corresponding to the given string, or null
+     *     if the given time zone string was null or blank.
+     */
+    public static String parse(String timeZone) {
+
+        // Return null if no time zone provided
+        if (timeZone == null || timeZone.isEmpty())
+            return null;
+
+        // Otherwise, assume time zone is valid
+        return timeZone;
+
+    }
+
+}
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/form/UsernameField.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/form/UsernameField.java
new file mode 100644
index 0000000..9b2701d
--- /dev/null
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/form/UsernameField.java
@@ -0,0 +1,43 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.form;
+
+/**
+ * Represents a text field which will contain the uniquely-identifying name of
+ * a user.
+ *
+ * @author Michael Jumper
+ */
+public class UsernameField extends Field {
+
+    /**
+     * Creates a new UsernameField with the given name.
+     *
+     * @param name
+     *     The unique name to associate with this field.
+     */
+    public UsernameField(String name) {
+        super(name, Field.Type.USERNAME);
+    }
+
+}
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/form/package-info.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/form/package-info.java
new file mode 100644
index 0000000..f8fe652
--- /dev/null
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/form/package-info.java
@@ -0,0 +1,27 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Provides classes which describe the contents and semantics of forms which
+ * may be presented to the user.
+ */
+package org.glyptodon.guacamole.form;
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/AbstractActiveConnection.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/AbstractActiveConnection.java
new file mode 100644
index 0000000..7ce50aa
--- /dev/null
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/AbstractActiveConnection.java
@@ -0,0 +1,120 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ * 
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ * 
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ * 
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.auth;
+
+import java.util.Date;
+import org.glyptodon.guacamole.net.GuacamoleTunnel;
+
+public abstract class AbstractActiveConnection implements ActiveConnection {
+
+    /**
+     * The identifier of this active connection.
+     */
+    private String identifier;
+
+    /**
+     * The identifier of the associated connection.
+     */
+    private String connectionIdentifier;
+
+    /**
+     * The date and time this active connection began.
+     */
+    private Date startDate;
+
+    /**
+     * The remote host that initiated this connection.
+     */
+    private String remoteHost;
+
+    /**
+     * The username of the user that initiated this connection.
+     */
+    private String username;
+
+    /**
+     * The underlying GuacamoleTunnel.
+     */
+    private GuacamoleTunnel tunnel;
+
+    @Override
+    public String getIdentifier() {
+        return identifier;
+    }
+
+    @Override
+    public void setIdentifier(String identifier) {
+        this.identifier = identifier;
+    }
+ 
+    @Override
+    public String getConnectionIdentifier() {
+        return connectionIdentifier;
+    }
+
+    @Override
+    public void setConnectionIdentifier(String connnectionIdentifier) {
+        this.connectionIdentifier = connnectionIdentifier;
+    }
+
+    @Override
+    public Date getStartDate() {
+        return startDate;
+    }
+
+    @Override
+    public void setStartDate(Date startDate) {
+        this.startDate = startDate;
+    }
+
+    @Override
+    public String getRemoteHost() {
+        return remoteHost;
+    }
+
+    @Override
+    public void setRemoteHost(String remoteHost) {
+        this.remoteHost = remoteHost;
+    }
+
+    @Override
+    public String getUsername() {
+        return username;
+    }
+
+    @Override
+    public void setUsername(String username) {
+        this.username = username;
+    }
+
+    @Override
+    public GuacamoleTunnel getTunnel() {
+        return tunnel;
+    }
+
+    @Override
+    public void setTunnel(GuacamoleTunnel tunnel) {
+        this.tunnel = tunnel;
+    }
+
+}
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/AbstractAuthenticatedUser.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/AbstractAuthenticatedUser.java
new file mode 100644
index 0000000..93256d3
--- /dev/null
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/AbstractAuthenticatedUser.java
@@ -0,0 +1,73 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.auth;
+
+
+/**
+ * Basic implementation of an AuthenticatedUser which uses the username to
+ * determine equality. Username comparison is case-sensitive.
+ *
+ * @author Michael Jumper
+ */
+public abstract class AbstractAuthenticatedUser implements AuthenticatedUser {
+
+    /**
+     * The name of this user.
+     */
+    private String username;
+
+    @Override
+    public String getIdentifier() {
+        return username;
+    }
+
+    @Override
+    public void setIdentifier(String username) {
+        this.username = username;
+    }
+
+    @Override
+    public int hashCode() {
+        if (username == null) return 0;
+        return username.hashCode();
+    }
+
+    @Override
+    public boolean equals(Object obj) {
+
+        // Not equal if null or not a User
+        if (obj == null) return false;
+        if (!(obj instanceof AbstractAuthenticatedUser)) return false;
+
+        // Get username
+        String objUsername = ((AbstractAuthenticatedUser) obj).username;
+
+        // If null, equal only if this username is null
+        if (objUsername == null) return username == null;
+
+        // Otherwise, equal only if strings are identical
+        return objUsername.equals(username);
+
+    }
+
+}
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/AbstractConnection.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/AbstractConnection.java
index e08bdc6..5cf08e8 100644
--- a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/AbstractConnection.java
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/AbstractConnection.java
@@ -1,45 +1,29 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ * 
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ * 
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ * 
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
 
 package org.glyptodon.guacamole.net.auth;
 
-/* ***** BEGIN LICENSE BLOCK *****
- * Version: MPL 1.1/GPL 2.0/LGPL 2.1
- *
- * The contents of this file are subject to the Mozilla Public License Version
- * 1.1 (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.mozilla.org/MPL/
- *
- * Software distributed under the License is distributed on an "AS IS" basis,
- * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- * for the specific language governing rights and limitations under the
- * License.
- *
- * The Original Code is guacamole-ext.
- *
- * The Initial Developer of the Original Code is
- * Michael Jumper.
- * Portions created by the Initial Developer are Copyright (C) 2010
- * the Initial Developer. All Rights Reserved.
- *
- * Contributor(s):
- *
- * Alternatively, the contents of this file may be used under the terms of
- * either the GNU General Public License Version 2 or later (the "GPL"), or
- * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
- * in which case the provisions of the GPL or the LGPL are applicable instead
- * of those above. If you wish to allow use of your version of this file only
- * under the terms of either the GPL or the LGPL, and not to allow others to
- * use your version of this file under the terms of the MPL, indicate your
- * decision by deleting the provisions above and replace them with the notice
- * and other provisions required by the GPL or the LGPL. If you do not delete
- * the provisions above, a recipient may use your version of this file under
- * the terms of any one of the MPL, the GPL or the LGPL.
- *
- * ***** END LICENSE BLOCK ***** */
-
 import org.glyptodon.guacamole.protocol.GuacamoleConfiguration;
 
-
 /**
  * Basic implementation of a Guacamole connection.
  *
@@ -58,6 +42,12 @@ public abstract class AbstractConnection implements Connection {
     private String identifier;
 
     /**
+     * The unique identifier of the parent ConnectionGroup for
+     * this Connection.
+     */
+    private String parentIdentifier;
+
+    /**
      * The GuacamoleConfiguration associated with this connection.
      */
     private GuacamoleConfiguration configuration;
@@ -83,6 +73,16 @@ public abstract class AbstractConnection implements Connection {
     }
 
     @Override
+    public String getParentIdentifier() {
+        return parentIdentifier;
+    }
+
+    @Override
+    public void setParentIdentifier(String parentIdentifier) {
+        this.parentIdentifier = parentIdentifier;
+    }
+
+    @Override
     public GuacamoleConfiguration getConfiguration() {
         return configuration;
     }
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/AbstractConnectionGroup.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/AbstractConnectionGroup.java
index c9b3d31..0719394 100644
--- a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/AbstractConnectionGroup.java
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/AbstractConnectionGroup.java
@@ -1,43 +1,27 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ * 
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ * 
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ * 
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
 
 package org.glyptodon.guacamole.net.auth;
 
-/* ***** BEGIN LICENSE BLOCK *****
- * Version: MPL 1.1/GPL 2.0/LGPL 2.1
- *
- * The contents of this file are subject to the Mozilla Public License Version
- * 1.1 (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.mozilla.org/MPL/
- *
- * Software distributed under the License is distributed on an "AS IS" basis,
- * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- * for the specific language governing rights and limitations under the
- * License.
- *
- * The Original Code is guacamole-ext.
- *
- * The Initial Developer of the Original Code is
- * Michael Jumper.
- * Portions created by the Initial Developer are Copyright (C) 2010
- * the Initial Developer. All Rights Reserved.
- *
- * Contributor(s): James Muehlner
- *
- * Alternatively, the contents of this file may be used under the terms of
- * either the GNU General Public License Version 2 or later (the "GPL"), or
- * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
- * in which case the provisions of the GPL or the LGPL are applicable instead
- * of those above. If you wish to allow use of your version of this file only
- * under the terms of either the GPL or the LGPL, and not to allow others to
- * use your version of this file under the terms of the MPL, indicate your
- * decision by deleting the provisions above and replace them with the notice
- * and other provisions required by the GPL or the LGPL. If you do not delete
- * the provisions above, a recipient may use your version of this file under
- * the terms of any one of the MPL, the GPL or the LGPL.
- *
- * ***** END LICENSE BLOCK ***** */
-
-
 /**
  * Basic implementation of a Guacamole connection group.
  *
@@ -54,6 +38,12 @@ public abstract class AbstractConnectionGroup implements ConnectionGroup {
      * The unique identifier associated with this connection group.
      */
     private String identifier;
+
+    /**
+     * The unique identifier of the parent connection group for
+     * this connection group.
+     */
+    private String parentIdentifier;
     
     /**
      * The type of this connection group.
@@ -79,6 +69,16 @@ public abstract class AbstractConnectionGroup implements ConnectionGroup {
     public void setIdentifier(String identifier) {
         this.identifier = identifier;
     }
+
+    @Override
+    public String getParentIdentifier() {
+        return parentIdentifier;
+    }
+
+    @Override
+    public void setParentIdentifier(String parentIdentifier) {
+        this.parentIdentifier = parentIdentifier;
+    }
     
     @Override
     public ConnectionGroup.Type getType() {
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/AbstractUser.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/AbstractUser.java
index 04d0f2f..d3232b6 100644
--- a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/AbstractUser.java
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/AbstractUser.java
@@ -1,42 +1,27 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ * 
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ * 
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ * 
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
 
 package org.glyptodon.guacamole.net.auth;
 
-/* ***** BEGIN LICENSE BLOCK *****
- * Version: MPL 1.1/GPL 2.0/LGPL 2.1
- *
- * The contents of this file are subject to the Mozilla Public License Version
- * 1.1 (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.mozilla.org/MPL/
- *
- * Software distributed under the License is distributed on an "AS IS" basis,
- * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- * for the specific language governing rights and limitations under the
- * License.
- *
- * The Original Code is guacamole-ext.
- *
- * The Initial Developer of the Original Code is
- * Michael Jumper.
- * Portions created by the Initial Developer are Copyright (C) 2010
- * the Initial Developer. All Rights Reserved.
- *
- * Contributor(s):
- *
- * Alternatively, the contents of this file may be used under the terms of
- * either the GNU General Public License Version 2 or later (the "GPL"), or
- * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
- * in which case the provisions of the GPL or the LGPL are applicable instead
- * of those above. If you wish to allow use of your version of this file only
- * under the terms of either the GPL or the LGPL, and not to allow others to
- * use your version of this file under the terms of the MPL, indicate your
- * decision by deleting the provisions above and replace them with the notice
- * and other provisions required by the GPL or the LGPL. If you do not delete
- * the provisions above, a recipient may use your version of this file under
- * the terms of any one of the MPL, the GPL or the LGPL.
- *
- * ***** END LICENSE BLOCK ***** */
-
 
 /**
  * Basic implementation of a Guacamole user which uses the username to
@@ -59,12 +44,12 @@ public abstract class AbstractUser implements User {
     private String password;
 
     @Override
-    public String getUsername() {
+    public String getIdentifier() {
         return username;
     }
 
     @Override
-    public void setUsername(String username) {
+    public void setIdentifier(String username) {
         this.username = username;
     }
 
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/ActiveConnection.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/ActiveConnection.java
new file mode 100644
index 0000000..1d20ded
--- /dev/null
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/ActiveConnection.java
@@ -0,0 +1,128 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ * 
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ * 
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ * 
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.auth;
+
+import java.util.Date;
+import org.glyptodon.guacamole.net.GuacamoleTunnel;
+
+/**
+ * A pairing of username and GuacamoleTunnel representing an active usage of a
+ * particular connection.
+ *
+ * @author Michael Jumper
+ */
+public interface ActiveConnection extends Identifiable {
+
+    /**
+     * Returns the identifier of the connection being actively used. Unlike the
+     * other information stored in this object, the connection identifier must
+     * be present and MAY NOT be null.
+     *
+     * @return
+     *     The identifier of the connection being actively used.
+     */
+    String getConnectionIdentifier();
+
+    /**
+     * Sets the identifier of the connection being actively used.
+     *
+     * @param connnectionIdentifier
+     *     The identifier of the connection being actively used.
+     */
+    void setConnectionIdentifier(String connnectionIdentifier);
+    
+    /**
+     * Returns the date and time the connection began.
+     *
+     * @return
+     *     The date and time the connection began, or null if this
+     *     information is not available.
+     */
+    Date getStartDate();
+
+    /**
+     * Sets the date and time the connection began.
+     *
+     * @param startDate 
+     *     The date and time the connection began, or null if this
+     *     information is not available.
+     */
+    void setStartDate(Date startDate);
+
+    /**
+     * Returns the hostname or IP address of the remote host that initiated the
+     * connection, if known. If the hostname or IP address is not known, null
+     * is returned.
+     *
+     * @return
+     *     The hostname or IP address of the remote host, or null if this
+     *     information is not available.
+     */
+    String getRemoteHost();
+
+    /**
+     * Sets the hostname or IP address of the remote host that initiated the
+     * connection.
+     * 
+     * @param remoteHost 
+     *     The hostname or IP address of the remote host, or null if this
+     *     information is not available.
+     */
+    void setRemoteHost(String remoteHost);
+
+    /**
+     * Returns the name of the user who is using this connection.
+     *
+     * @return
+     *     The name of the user who is using this connection, or null if this
+     *     information is not available.
+     */
+    String getUsername();
+
+    /**
+     * Sets the name of the user who is using this connection.
+     *
+     * @param username 
+     *     The name of the user who is using this connection, or null if this
+     *     information is not available.
+     */
+    void setUsername(String username);
+
+    /**
+     * Returns the connected GuacamoleTunnel being used. This may be null if
+     * access to the underlying tunnel is denied.
+     *
+     * @return
+     *     The connected GuacamoleTunnel, or null if permission is denied.
+     */
+    GuacamoleTunnel getTunnel();
+
+    /**
+     * Sets the connected GuacamoleTunnel being used.
+     *
+     * @param tunnel
+     *     The connected GuacamoleTunnel, or null if permission is denied.
+     */
+    void setTunnel(GuacamoleTunnel tunnel);
+    
+}
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/AuthenticatedUser.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/AuthenticatedUser.java
new file mode 100644
index 0000000..1073d47
--- /dev/null
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/AuthenticatedUser.java
@@ -0,0 +1,51 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ * 
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ * 
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ * 
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.auth;
+
+
+/**
+ * A user of the Guacamole web application who has been authenticated by an
+ * AuthenticationProvider.
+ *
+ * @author Michael Jumper
+ */
+public interface AuthenticatedUser extends Identifiable {
+
+    /**
+     * Returns the AuthenticationProvider that authenticated this user.
+     *
+     * @return
+     *     The AuthenticationProvider that authenticated this user.
+     */
+    AuthenticationProvider getAuthenticationProvider();
+
+    /**
+     * Returns the credentials that the user provided when they successfully
+     * authenticated.
+     *
+     * @return
+     *     The credentials provided by the user when they authenticated.
+     */
+    Credentials getCredentials();
+
+}
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/AuthenticationProvider.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/AuthenticationProvider.java
index 547628e..077edbd 100644
--- a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/AuthenticationProvider.java
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/AuthenticationProvider.java
@@ -1,86 +1,148 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ * 
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ * 
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ * 
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
 
 package org.glyptodon.guacamole.net.auth;
 
-/* ***** BEGIN LICENSE BLOCK *****
- * Version: MPL 1.1/GPL 2.0/LGPL 2.1
- *
- * The contents of this file are subject to the Mozilla Public License Version
- * 1.1 (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.mozilla.org/MPL/
- *
- * Software distributed under the License is distributed on an "AS IS" basis,
- * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- * for the specific language governing rights and limitations under the
- * License.
- *
- * The Original Code is guacamole-ext.
- *
- * The Initial Developer of the Original Code is
- * Michael Jumper.
- * Portions created by the Initial Developer are Copyright (C) 2010
- * the Initial Developer. All Rights Reserved.
- *
- * Contributor(s):
- *
- * Alternatively, the contents of this file may be used under the terms of
- * either the GNU General Public License Version 2 or later (the "GPL"), or
- * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
- * in which case the provisions of the GPL or the LGPL are applicable instead
- * of those above. If you wish to allow use of your version of this file only
- * under the terms of either the GPL or the LGPL, and not to allow others to
- * use your version of this file under the terms of the MPL, indicate your
- * decision by deleting the provisions above and replace them with the notice
- * and other provisions required by the GPL or the LGPL. If you do not delete
- * the provisions above, a recipient may use your version of this file under
- * the terms of any one of the MPL, the GPL or the LGPL.
- *
- * ***** END LICENSE BLOCK ***** */
-
 import org.glyptodon.guacamole.GuacamoleException;
 
-
 /**
- * Provides means of accessing and managing the available
- * GuacamoleConfiguration objects and User objects. Access to each configuration
- * and each user is limited by a given Credentials object.
+ * Provides means of authorizing users and for accessing and managing data
+ * associated with those users. Access to such data is limited according to the
+ * AuthenticationProvider implementation.
  *
  * @author Michael Jumper
  */
 public interface AuthenticationProvider {
 
     /**
-     * Returns the UserContext of the user authorized by the given credentials.
+     * Returns the identifier which uniquely and consistently identifies this
+     * AuthenticationProvider implementation. This identifier may not be null
+     * and must be unique across all AuthenticationProviders loaded by the
+     * Guacamole web application.
+     *
+     * @return
+     *     The unique identifier assigned to this AuthenticationProvider, which
+     *     may not be null.
+     */
+    String getIdentifier();
+
+    /**
+     * Returns an AuthenticatedUser representing the user authenticated by the
+     * given credentials, if any.
+     *
+     * @param credentials
+     *     The credentials to use for authentication.
      *
-     * @param credentials The credentials to use to retrieve the environment.
-     * @return The UserContext of the user authorized by the given credentials,
-     *         or null if the credentials are not authorized.
+     * @return
+     *     An AuthenticatedUser representing the user authenticated by the
+     *     given credentials, if any, or null if the credentials are invalid.
      *
-     * @throws GuacamoleException If an error occurs while creating the
-     *                            UserContext.
+     * @throws GuacamoleException
+     *     If an error occurs while authenticating the user, or if access is
+     *     temporarily, permanently, or conditionally denied, such as if the
+     *     supplied credentials are insufficient or invalid.
      */
-    UserContext getUserContext(Credentials credentials)
+    AuthenticatedUser authenticateUser(Credentials credentials)
             throws GuacamoleException;
 
     /**
-     * Returns a new or updated UserContext for the user authorized by the
-     * give credentials and having the given existing UserContext. Note that
-     * because this function will be called for all future requests after
-     * initial authentication, including tunnel requests, care must be taken
-     * to avoid using functions of HttpServletRequest which invalidate the
-     * entire request body, such as getParameter().
-     * 
-     * @param context The existing UserContext belonging to the user in
-     *                question.
-     * @param credentials The credentials to use to retrieve or update the
-     *                    environment.
-     * @return The updated UserContext, which need not be the same as the
-     *         UserContext given, or null if the user is no longer authorized.
-     *         
-     * @throws GuacamoleException If an error occurs while updating the
-     *                            UserContext.
+     * Returns a new or updated AuthenticatedUser for the given credentials
+     * already having produced the given AuthenticatedUser. Note that because
+     * this function will be called for all future requests after initial
+     * authentication, including tunnel requests, care must be taken to avoid
+     * using functions of HttpServletRequest which invalidate the entire request
+     * body, such as getParameter(). Doing otherwise may cause the
+     * GuacamoleHTTPTunnelServlet to fail.
+      *
+     * @param credentials
+     *     The credentials to use for authentication.
+     *
+     * @param authenticatedUser
+     *     An AuthenticatedUser object representing the user authenticated by
+     *     an arbitrary set of credentials. The AuthenticatedUser may come from
+     *     this AuthenticationProvider or any other installed
+     *     AuthenticationProvider.
+     *
+     * @return
+     *     An updated AuthenticatedUser representing the user authenticated by
+     *     the given credentials, if any, or null if the credentials are
+     *     invalid.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while updating the AuthenticatedUser.
      */
-    UserContext updateUserContext(UserContext context, Credentials credentials)
+    AuthenticatedUser updateAuthenticatedUser(AuthenticatedUser authenticatedUser,
+            Credentials credentials) throws GuacamoleException;
+
+    /**
+     * Returns the UserContext of the user authenticated by the given
+     * credentials.
+     *
+     * @param authenticatedUser
+     *     An AuthenticatedUser object representing the user authenticated by
+     *     an arbitrary set of credentials. The AuthenticatedUser may come from
+     *     this AuthenticationProvider or any other installed
+     *     AuthenticationProvider.
+     *
+     * @return
+     *     A UserContext describing the permissions, connection, connection
+     *     groups, etc. accessible or associated with the given authenticated
+     *     user, or null if this AuthenticationProvider refuses to provide any
+     *     such data.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while creating the UserContext.
+     */
+    UserContext getUserContext(AuthenticatedUser authenticatedUser)
             throws GuacamoleException;
+
+    /**
+     * Returns a new or updated UserContext for the given AuthenticatedUser
+     * already having the given UserContext. Note that because this function
+     * will be called for all future requests after initial authentication,
+     * including tunnel requests, care must be taken to avoid using functions
+     * of HttpServletRequest which invalidate the entire request body, such as
+     * getParameter(). Doing otherwise may cause the GuacamoleHTTPTunnelServlet
+     * to fail.
+      *
+     * @param context
+     *     The existing UserContext belonging to the user in question.
+     *
+     * @param authenticatedUser
+     *     An AuthenticatedUser object representing the user authenticated by
+     *     an arbitrary set of credentials. The AuthenticatedUser may come from
+     *     this AuthenticationProvider or any other installed
+     *     AuthenticationProvider.
+     *
+     * @return
+     *     An updated UserContext describing the permissions, connection,
+     *     connection groups, etc. accessible or associated with the given
+     *     authenticated user, or null if this AuthenticationProvider refuses
+     *     to provide any such data.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while updating the UserContext.
+     */
+    UserContext updateUserContext(UserContext context,
+            AuthenticatedUser authenticatedUser) throws GuacamoleException;
     
 }
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/Connectable.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/Connectable.java
new file mode 100644
index 0000000..d81a679
--- /dev/null
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/Connectable.java
@@ -0,0 +1,63 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ * 
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ * 
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ * 
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.auth;
+
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.net.GuacamoleTunnel;
+import org.glyptodon.guacamole.protocol.GuacamoleClientInformation;
+
+/**
+ * An object which Guacamole can connect to.
+ *
+ * @author Michael Jumper
+ */
+public interface Connectable {
+
+    /**
+     * Establishes a connection to guacd using the information associated with
+     * this object. The connection will be provided the given client
+     * information.
+     *
+     * @param info
+     *     Information associated with the connecting client.
+     *
+     * @return
+     *     A fully-established GuacamoleTunnel.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while connecting to guacd, or if permission to
+     *     connect is denied.
+     */
+    public GuacamoleTunnel connect(GuacamoleClientInformation info)
+            throws GuacamoleException;
+
+    /**
+     * Returns the number of active connections associated with this object.
+     * Implementations may simply return 0 if this value is not tracked.
+     *
+     * @return
+     *     The number of active connections associated with this object.
+     */
+    public int getActiveConnections();
+    
+}
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/Connection.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/Connection.java
index 42bdd39..9de591b 100644
--- a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/Connection.java
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/Connection.java
@@ -1,49 +1,32 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ * 
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ * 
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ * 
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
 
 package org.glyptodon.guacamole.net.auth;
 
-/* ***** BEGIN LICENSE BLOCK *****
- * Version: MPL 1.1/GPL 2.0/LGPL 2.1
- *
- * The contents of this file are subject to the Mozilla Public License Version
- * 1.1 (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.mozilla.org/MPL/
- *
- * Software distributed under the License is distributed on an "AS IS" basis,
- * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- * for the specific language governing rights and limitations under the
- * License.
- *
- * The Original Code is guacamole-ext.
- *
- * The Initial Developer of the Original Code is
- * Michael Jumper.
- * Portions created by the Initial Developer are Copyright (C) 2010
- * the Initial Developer. All Rights Reserved.
- *
- * Contributor(s):
- *
- * Alternatively, the contents of this file may be used under the terms of
- * either the GNU General Public License Version 2 or later (the "GPL"), or
- * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
- * in which case the provisions of the GPL or the LGPL are applicable instead
- * of those above. If you wish to allow use of your version of this file only
- * under the terms of either the GPL or the LGPL, and not to allow others to
- * use your version of this file under the terms of the MPL, indicate your
- * decision by deleting the provisions above and replace them with the notice
- * and other provisions required by the GPL or the LGPL. If you do not delete
- * the provisions above, a recipient may use your version of this file under
- * the terms of any one of the MPL, the GPL or the LGPL.
- *
- * ***** END LICENSE BLOCK ***** */
-
 import java.util.List;
+import java.util.Map;
 import org.glyptodon.guacamole.GuacamoleException;
-import org.glyptodon.guacamole.net.GuacamoleSocket;
-import org.glyptodon.guacamole.protocol.GuacamoleClientInformation;
 import org.glyptodon.guacamole.protocol.GuacamoleConfiguration;
 
-
 /**
  * Represents a pairing of a GuacamoleConfiguration with a unique,
  * human-readable identifier, and abstracts the connection process. The
@@ -52,7 +35,7 @@ import org.glyptodon.guacamole.protocol.GuacamoleConfiguration;
  *
  * @author Michael Jumper
  */
-public interface Connection {
+public interface Connection extends Identifiable, Connectable {
 
     /**
      * Returns the name assigned to this Connection.
@@ -63,22 +46,27 @@ public interface Connection {
     /**
      * Sets the name assigned to this Connection.
      *
-     * @param identifier The name to assign.
+     * @param name The name to assign.
      */
     public void setName(String name);
 
     /**
-     * Returns the unique identifier assigned to this Connection.
-     * @return The unique identifier assigned to this Connection.
+     * Returns the unique identifier of the parent ConnectionGroup for
+     * this Connection.
+     * 
+     * @return The unique identifier of the parent ConnectionGroup for
+     * this Connection.
      */
-    public String getIdentifier();
+    public String getParentIdentifier();
 
     /**
-     * Sets the identifier assigned to this Connection.
-     *
-     * @param identifier The identifier to assign.
+     * Sets the unique identifier of the parent ConnectionGroup for
+     * this Connection.
+     * 
+     * @param parentIdentifier The unique identifier of the parent 
+     * ConnectionGroup for this Connection.
      */
-    public void setIdentifier(String identifier);
+    public void setParentIdentifier(String parentIdentifier);
 
     /**
      * Returns the GuacamoleConfiguration associated with this Connection. Note
@@ -98,19 +86,25 @@ public interface Connection {
     public void setConfiguration(GuacamoleConfiguration config);
 
     /**
-     * Establishes a connection to guacd using the GuacamoleConfiguration
-     * associated with this Connection, and returns the resulting, connected
-     * GuacamoleSocket. The GuacamoleSocket will be pre-configured and will
-     * already have passed the handshake stage.
+     * Returns all attributes associated with this connection. The returned map
+     * may not be modifiable.
      *
-     * @param info Information associated with the connecting client.
-     * @return A fully-established GuacamoleSocket.
+     * @return
+     *     A map of all attribute identifiers to their corresponding values,
+     *     for all attributes associated with this connection, which may not be
+     *     modifiable.
+     */
+    Map<String, String> getAttributes();
+
+    /**
+     * Sets the given attributes. If an attribute within the map is not
+     * supported, it will simply be dropped. Any attributes not within the
+     * given map will be left untouched.
      *
-     * @throws GuacamoleException If an error occurs while connecting to guacd,
-     *                            or if permission to connect is denied.
+     * @param attributes
+     *     A map of all attribute identifiers to their corresponding values.
      */
-    public GuacamoleSocket connect(GuacamoleClientInformation info)
-            throws GuacamoleException;
+    void setAttributes(Map<String, String> attributes);
 
     /**
      * Returns a list of ConnectionRecords representing the usage history
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/ConnectionGroup.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/ConnectionGroup.java
index 35bc4b9..0b20f23 100644
--- a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/ConnectionGroup.java
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/ConnectionGroup.java
@@ -1,46 +1,30 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ * 
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ * 
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ * 
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
 
 package org.glyptodon.guacamole.net.auth;
 
-/* ***** BEGIN LICENSE BLOCK *****
- * Version: MPL 1.1/GPL 2.0/LGPL 2.1
- *
- * The contents of this file are subject to the Mozilla Public License Version
- * 1.1 (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.mozilla.org/MPL/
- *
- * Software distributed under the License is distributed on an "AS IS" basis,
- * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- * for the specific language governing rights and limitations under the
- * License.
- *
- * The Original Code is guacamole-ext.
- *
- * The Initial Developer of the Original Code is
- * Michael Jumper.
- * Portions created by the Initial Developer are Copyright (C) 2010
- * the Initial Developer. All Rights Reserved.
- *
- * Contributor(s): James Muehlner
- *
- * Alternatively, the contents of this file may be used under the terms of
- * either the GNU General Public License Version 2 or later (the "GPL"), or
- * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
- * in which case the provisions of the GPL or the LGPL are applicable instead
- * of those above. If you wish to allow use of your version of this file only
- * under the terms of either the GPL or the LGPL, and not to allow others to
- * use your version of this file under the terms of the MPL, indicate your
- * decision by deleting the provisions above and replace them with the notice
- * and other provisions required by the GPL or the LGPL. If you do not delete
- * the provisions above, a recipient may use your version of this file under
- * the terms of any one of the MPL, the GPL or the LGPL.
- *
- * ***** END LICENSE BLOCK ***** */
-
+import java.util.Map;
+import java.util.Set;
 import org.glyptodon.guacamole.GuacamoleException;
-import org.glyptodon.guacamole.net.GuacamoleSocket;
-import org.glyptodon.guacamole.protocol.GuacamoleClientInformation;
-
 
 /**
  * Represents a connection group, which can contain both other connection groups
@@ -48,10 +32,29 @@ import org.glyptodon.guacamole.protocol.GuacamoleClientInformation;
  *
  * @author James Muehlner
  */
-public interface ConnectionGroup {
-    
+public interface ConnectionGroup extends Identifiable, Connectable {
+  
+    /**
+     * All legal types of connection group.
+     */
     public enum Type {
-        ORGANIZATIONAL, BALANCING
+
+        /**
+         * A connection group that purely organizes other connections or
+         * connection groups, serving only as a container. An organizational
+         * connection group is analogous to a directory or folder in a
+         * filesystem.
+         */
+        ORGANIZATIONAL,
+
+        /**
+         * A connection group that acts as a load balancer. A balancing
+         * connection group can be connected to in the same manner as a
+         * connection, and will transparently route to the least-used
+         * underlying connection.
+         */
+        BALANCING
+
     };
 
     /**
@@ -63,22 +66,27 @@ public interface ConnectionGroup {
     /**
      * Sets the name assigned to this ConnectionGroup.
      *
-     * @param identifier The name to assign.
+     * @param name The name to assign.
      */
     public void setName(String name);
 
     /**
-     * Returns the unique identifier assigned to this ConnectionGroup.
-     * @return The unique identifier assigned to this ConnectionGroup.
+     * Returns the unique identifier of the parent ConnectionGroup for
+     * this ConnectionGroup.
+     * 
+     * @return The unique identifier of the parent ConnectionGroup for
+     * this ConnectionGroup.
      */
-    public String getIdentifier();
+    public String getParentIdentifier();
 
     /**
-     * Sets the identifier assigned to this ConnectionGroup.
-     *
-     * @param identifier The identifier to assign.
+     * Sets the unique identifier of the parent ConnectionGroup for
+     * this ConnectionGroup.
+     * 
+     * @param parentIdentifier The unique identifier of the parent 
+     * ConnectionGroup for this ConnectionGroup.
      */
-    public void setIdentifier(String identifier);
+    public void setParentIdentifier(String parentIdentifier);
     
     /**
      * Set the type of this ConnectionGroup.
@@ -94,45 +102,52 @@ public interface ConnectionGroup {
     public Type getType();
 
     /**
-     * Retrieves a Directory which can be used to view and manipulate
-     * connections and their configurations, but only as allowed by the
-     * permissions given to the user.
+     * Returns the identifiers of all readable connections that are children
+     * of this connection group.
      *
-     * @return A Directory whose operations are bound by the permissions of 
-     *         the user.
+     * @return
+     *     The set of identifiers of all readable connections that are children
+     *     of this connection group.
      *
-     * @throws GuacamoleException If an error occurs while creating the
-     *                            Directory.
+     * @throws GuacamoleException
+     *     If an error occurs while retrieving the identifiers.
      */
-    Directory<String, Connection> getConnectionDirectory()
-            throws GuacamoleException;
+    public Set<String> getConnectionIdentifiers() throws GuacamoleException;
 
     /**
-     * Retrieves a Directory which can be used to view and manipulate
-     * connection groups and their members, but only as allowed by the
-     * permissions given to the user.
+     * Returns the identifiers of all readable connection groups that are
+     * children of this connection group.
      *
-     * @return A Directory whose operations are bound by the permissions of
-     *         the user.
+     * @return
+     *     The set of identifiers of all readable connection groups that are
+     *     children of this connection group.
      *
-     * @throws GuacamoleException If an error occurs while creating the
-     *                            Directory.
+     * @throws GuacamoleException
+     *     If an error occurs while retrieving the identifiers.
      */
-    Directory<String, ConnectionGroup> getConnectionGroupDirectory()
+
+    public Set<String> getConnectionGroupIdentifiers()
             throws GuacamoleException;
-    
+
     /**
-     * Establishes a connection to guacd using a connection chosen from among
-     * the connections in this ConnectionGroup, and returns the resulting, 
-     * connected GuacamoleSocket.
+     * Returns all attributes associated with this connection group. The
+     * returned map may not be modifiable.
      *
-     * @param info Information associated with the connecting client.
-     * @return A fully-established GuacamoleSocket.
+     * @return
+     *     A map of all attribute identifiers to their corresponding values,
+     *     for all attributes associated with this connection group, which may
+     *     not be modifiable.
+     */
+    Map<String, String> getAttributes();
+
+    /**
+     * Sets the given attributes. If an attribute within the map is not
+     * supported, it will simply be dropped. Any attributes not within the
+     * given map will be left untouched.
      *
-     * @throws GuacamoleException If an error occurs while connecting to guacd,
-     *                            or if permission to connect is denied.
+     * @param attributes
+     *     A map of all attribute identifiers to their corresponding values.
      */
-    public GuacamoleSocket connect(GuacamoleClientInformation info)
-            throws GuacamoleException;
+    void setAttributes(Map<String, String> attributes);
 
 }
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/ConnectionRecord.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/ConnectionRecord.java
index 5284560..f5533cf 100644
--- a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/ConnectionRecord.java
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/ConnectionRecord.java
@@ -1,42 +1,27 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ * 
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ * 
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ * 
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
 
 package org.glyptodon.guacamole.net.auth;
 
-/* ***** BEGIN LICENSE BLOCK *****
- * Version: MPL 1.1/GPL 2.0/LGPL 2.1
- *
- * The contents of this file are subject to the Mozilla Public License Version
- * 1.1 (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.mozilla.org/MPL/
- *
- * Software distributed under the License is distributed on an "AS IS" basis,
- * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- * for the specific language governing rights and limitations under the
- * License.
- *
- * The Original Code is guacamole-auth-mock.
- *
- * The Initial Developer of the Original Code is
- * Michael Jumper.
- * Portions created by the Initial Developer are Copyright (C) 2010
- * the Initial Developer. All Rights Reserved.
- *
- * Contributor(s):
- *
- * Alternatively, the contents of this file may be used under the terms of
- * either the GNU General Public License Version 2 or later (the "GPL"), or
- * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
- * in which case the provisions of the GPL or the LGPL are applicable instead
- * of those above. If you wish to allow use of your version of this file only
- * under the terms of either the GPL or the LGPL, and not to allow others to
- * use your version of this file under the terms of the MPL, indicate your
- * decision by deleting the provisions above and replace them with the notice
- * and other provisions required by the GPL or the LGPL. If you do not delete
- * the provisions above, a recipient may use your version of this file under
- * the terms of any one of the MPL, the GPL or the LGPL.
- *
- * ***** END LICENSE BLOCK ***** */
-
 import java.util.Date;
 
 /**
@@ -48,6 +33,25 @@ import java.util.Date;
 public interface ConnectionRecord {
 
     /**
+     * Returns the identifier of the connection associated with this
+     * connection record.
+     *
+     * @return
+     *     The identifier of the connection associated with this connection
+     *     record.
+     */
+    public String getConnectionIdentifier();
+    
+    /**
+     * Returns the name of the connection associated with this connection
+     * record.
+     *
+     * @return
+     *     The name of the connection associated with this connection record.
+     */
+    public String getConnectionName();
+
+    /**
      * Returns the date and time the connection began.
      *
      * @return The date and time the connection began.
@@ -63,6 +67,17 @@ public interface ConnectionRecord {
     public Date getEndDate();
 
     /**
+     * Returns the hostname or IP address of the remote host that used the
+     * connection associated with this record, if known. If the hostname or IP
+     * address is not known, null is returned.
+     *
+     * @return
+     *     The hostname or IP address of the remote host, or null if this
+     *     information is not available.
+     */
+    public String getRemoteHost();
+
+    /**
      * Returns the name of the user who used or is using the connection at the
      * times given by this connection record.
      *
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/ConnectionRecordSet.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/ConnectionRecordSet.java
new file mode 100644
index 0000000..6917644
--- /dev/null
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/ConnectionRecordSet.java
@@ -0,0 +1,131 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.auth;
+
+import java.util.Collection;
+import org.glyptodon.guacamole.GuacamoleException;
+
+/**
+ * The set of all available connection records, or a subset of those records.
+ *
+ * @author James Muehlner
+ * @author Michael Jumper
+ */
+public interface ConnectionRecordSet {
+
+    /**
+     * All properties of connection records which can be used as sorting
+     * criteria.
+     */
+    enum SortableProperty {
+
+        /**
+         * The date and time when the connection associated with the
+         * connection record began.
+         */
+        START_DATE
+
+    };
+
+    /**
+     * Returns all connection records within this set as a standard Collection.
+     *
+     * @return
+     *      A collection containing all connection records within this set.
+     *
+     * @throws GuacamoleException
+     *      If an error occurs while retrieving the connection records within
+     *      this set.
+     */
+    Collection<ConnectionRecord> asCollection() throws GuacamoleException;
+
+    /**
+     * Returns the subset of connection records to only those where the
+     * connection name, user identifier, or any associated date field contain
+     * the given value. This function may also affect the contents of the
+     * current ConnectionRecordSet. The contents of the current
+     * ConnectionRecordSet should NOT be relied upon after this function is
+     * called.
+     *
+     * @param value
+     *     The value which all connection records within the resulting subset
+     *     should contain within their associated connection name or user
+     *     identifier.
+     *
+     * @return
+     *     The subset of connection history records which contain the specified
+     *     value within their associated connection name or user identifier.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while restricting the current subset.
+     */
+    ConnectionRecordSet contains(String value) throws GuacamoleException;
+
+    /**
+     * Returns the subset of connection history records containing only the
+     * first <code>limit</code> records. If the subset has fewer than
+     * <code>limit</code> records, then this function has no effect. This
+     * function may also affect the contents of the current
+     * ConnectionRecordSet. The contents of the current ConnectionRecordSet
+     * should NOT be relied upon after this function is called.
+     *
+     * @param limit
+     *     The maximum number of records that the new subset should contain.
+     *
+     * @return
+     *     The subset of connection history records that containing only the
+     *     first <code>limit</code> records.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while limiting the current subset.
+     */
+    ConnectionRecordSet limit(int limit) throws GuacamoleException;
+
+    /**
+     * Returns a ConnectionRecordSet containing identically the records within
+     * this set, sorted according to the specified criteria. The sort operation
+     * performed is guaranteed to be stable with respect to any past call to
+     * sort(). This function may also affect the contents of the current
+     * ConnectionRecordSet. The contents of the current ConnectionRecordSet
+     * should NOT be relied upon after this function is called.
+     *
+     * @param property
+     *     The property by which the connection records within the resulting
+     *     set should be sorted.
+     *
+     * @param desc
+     *     Whether the records should be sorted according to the specified
+     *     property in descending order. If false, records will be sorted
+     *     according to the specified property in ascending order.
+     *
+     * @return
+     *     The ConnnectionRecordSet, sorted according to the specified
+     *     criteria.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while sorting the current subset.
+     */
+    ConnectionRecordSet sort(SortableProperty property, boolean desc)
+            throws GuacamoleException;
+
+}
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/Credentials.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/Credentials.java
index 63b5e89..469b77c 100644
--- a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/Credentials.java
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/Credentials.java
@@ -1,44 +1,31 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ * 
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ * 
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ * 
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
 package org.glyptodon.guacamole.net.auth;
 
 import java.io.Serializable;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpSession;
 
-/* ***** BEGIN LICENSE BLOCK *****
- * Version: MPL 1.1/GPL 2.0/LGPL 2.1
- *
- * The contents of this file are subject to the Mozilla Public License Version
- * 1.1 (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.mozilla.org/MPL/
- *
- * Software distributed under the License is distributed on an "AS IS" basis,
- * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- * for the specific language governing rights and limitations under the
- * License.
- *
- * The Original Code is guacamole-ext.
- *
- * The Initial Developer of the Original Code is
- * Michael Jumper.
- * Portions created by the Initial Developer are Copyright (C) 2010
- * the Initial Developer. All Rights Reserved.
- *
- * Contributor(s):
- *
- * Alternatively, the contents of this file may be used under the terms of
- * either the GNU General Public License Version 2 or later (the "GPL"), or
- * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
- * in which case the provisions of the GPL or the LGPL are applicable instead
- * of those above. If you wish to allow use of your version of this file only
- * under the terms of either the GPL or the LGPL, and not to allow others to
- * use your version of this file under the terms of the MPL, indicate your
- * decision by deleting the provisions above and replace them with the notice
- * and other provisions required by the GPL or the LGPL. If you do not delete
- * the provisions above, a recipient may use your version of this file under
- * the terms of any one of the MPL, the GPL or the LGPL.
- *
- * ***** END LICENSE BLOCK ***** */
 
 /**
  * Simple arbitrary set of credentials, including a username/password pair,
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/Directory.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/Directory.java
index 95866e8..958a9ad 100644
--- a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/Directory.java
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/Directory.java
@@ -1,58 +1,42 @@
-package org.glyptodon.guacamole.net.auth;
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ * 
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ * 
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ * 
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
 
-/* ***** BEGIN LICENSE BLOCK *****
- * Version: MPL 1.1/GPL 2.0/LGPL 2.1
- *
- * The contents of this file are subject to the Mozilla Public License Version
- * 1.1 (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.mozilla.org/MPL/
- *
- * Software distributed under the License is distributed on an "AS IS" basis,
- * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- * for the specific language governing rights and limitations under the
- * License.
- *
- * The Original Code is guacamole-ext.
- *
- * The Initial Developer of the Original Code is
- * Michael Jumper.
- * Portions created by the Initial Developer are Copyright (C) 2010
- * the Initial Developer. All Rights Reserved.
- *
- * Contributor(s):
- *
- * Alternatively, the contents of this file may be used under the terms of
- * either the GNU General Public License Version 2 or later (the "GPL"), or
- * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
- * in which case the provisions of the GPL or the LGPL are applicable instead
- * of those above. If you wish to allow use of your version of this file only
- * under the terms of either the GPL or the LGPL, and not to allow others to
- * use your version of this file under the terms of the MPL, indicate your
- * decision by deleting the provisions above and replace them with the notice
- * and other provisions required by the GPL or the LGPL. If you do not delete
- * the provisions above, a recipient may use your version of this file under
- * the terms of any one of the MPL, the GPL or the LGPL.
- *
- * ***** END LICENSE BLOCK ***** */
+package org.glyptodon.guacamole.net.auth;
 
+import java.util.Collection;
 import java.util.Set;
 import org.glyptodon.guacamole.GuacamoleException;
 
-
 /**
  * Provides access to a collection of all objects with associated identifiers,
- * and allows user manipulation and removal. Objects stored within a
- * Directory are not necessarily returned to the use as references to
- * the stored objects, thus updating an object requires calling an update
- * function.
+ * and allows user manipulation and removal. Objects returned by a Directory
+ * are not necessarily backed by the stored objects, thus updating an object
+ * always requires calling the update() function.
  *
  * @author Michael Jumper
- * @param <IdentifierType> The type of identifier used to identify objects
- *                         stored within this Directory.
- * @param <ObjectType> The type of objects stored within this Directory.
+ * @param <ObjectType>
+ *     The type of objects stored within this Directory.
  */
-public interface Directory<IdentifierType, ObjectType> {
+public interface Directory<ObjectType extends Identifiable> {
 
     /**
      * Returns the object having the given identifier. Note that changes to
@@ -70,7 +54,30 @@ public interface Directory<IdentifierType, ObjectType> {
      *                            object, or if permission for retrieving the
      *                            object is denied.
      */
-    ObjectType get(IdentifierType identifier) throws GuacamoleException;
+    ObjectType get(String identifier) throws GuacamoleException;
+
+    /**
+     * Returns the objects having the given identifiers. Note that changes to
+     * any object returned will not necessarily affect the object stored within
+     * the Directory. To update an object stored within a
+     * Directory such that future calls to get() will return the updated
+     * object, you must call update() on the object after modification.
+     *
+     * @param identifiers
+     *     The identifiers to use when locating the objects to return.
+     *
+     * @return
+     *     The objects having the given identifiers. If any identifiers do not
+     *     correspond to accessible objects, those identifiers will be ignored.
+     *     If no objects correspond to any of the given identifiers, the
+     *     returned collection will be empty.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while retrieving the objects, or if permission
+     *     to retrieve the requested objects is denied.
+     */
+    Collection<ObjectType> getAll(Collection<String> identifiers)
+            throws GuacamoleException;
 
     /**
      * Returns a Set containing all identifiers for all objects within this
@@ -80,15 +87,19 @@ public interface Directory<IdentifierType, ObjectType> {
      * @throws GuacamoleException If an error occurs while retrieving
      *                            the identifiers.
      */
-    Set<IdentifierType> getIdentifiers() throws GuacamoleException;
+    Set<String> getIdentifiers() throws GuacamoleException;
 
     /**
-     * Adds the given object to the overall set.
+     * Adds the given object to the overall set. If a new identifier is
+     * created for the added object, that identifier will be automatically
+     * assigned via setIdentifier().
      *
-     * @param object The object to add.
+     * @param object
+     *     The object to add.
      *
-     * @throws GuacamoleException If an error occurs while adding the object , or
-     *                            if adding the object is not allowed.
+     * @throws GuacamoleException
+     *     If an error occurs while adding the object, or if adding the object
+     *     is not allowed.
      */
     void add(ObjectType object)
             throws GuacamoleException;
@@ -112,18 +123,6 @@ public interface Directory<IdentifierType, ObjectType> {
      * @throws GuacamoleException If an error occurs while removing the object,
      *                            or if removing object is not allowed.
      */
-    void remove(IdentifierType identifier) throws GuacamoleException;
-
-    /**
-     * Moves the object with the given identifier to the given directory.
-     *
-     * @param identifier The identifier of the object to remove.
-     * @param directory The directory to move the object to.
-     *
-     * @throws GuacamoleException If an error occurs while moving the object,
-     *                            or if moving object is not allowed.
-     */
-    void move(IdentifierType identifier, Directory<IdentifierType, ObjectType> directory) 
-            throws GuacamoleException;
+    void remove(String identifier) throws GuacamoleException;
 
 }
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/Identifiable.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/Identifiable.java
new file mode 100644
index 0000000..8490bbf
--- /dev/null
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/Identifiable.java
@@ -0,0 +1,52 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ * 
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ * 
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ * 
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.auth;
+
+/**
+ * An object which has a deterministic, unique identifier, which may not be
+ * null.
+ *
+ * @author Michael Jumper
+ */
+public interface Identifiable {
+
+    /**
+     * Returns the unique identifier assigned to this object. All identifiable
+     * objects must have a deterministic, unique identifier which may not be
+     * null.
+     *
+     * @return
+     *     The unique identifier assigned to this object, which may not be
+     *     null.
+     */
+    public String getIdentifier();
+
+    /**
+     * Sets the identifier assigned to this object.
+     *
+     * @param identifier
+     *     The identifier to assign.
+     */
+    public void setIdentifier(String identifier);
+
+}
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/User.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/User.java
index 6e792c6..355dd5d 100644
--- a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/User.java
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/User.java
@@ -1,45 +1,31 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ * 
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ * 
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ * 
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
 
 package org.glyptodon.guacamole.net.auth;
 
-import java.util.Set;
+import java.util.Map;
 import org.glyptodon.guacamole.GuacamoleException;
-import org.glyptodon.guacamole.net.auth.permission.Permission;
-
-/* ***** BEGIN LICENSE BLOCK *****
- * Version: MPL 1.1/GPL 2.0/LGPL 2.1
- *
- * The contents of this file are subject to the Mozilla Public License Version
- * 1.1 (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.mozilla.org/MPL/
- *
- * Software distributed under the License is distributed on an "AS IS" basis,
- * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- * for the specific language governing rights and limitations under the
- * License.
- *
- * The Original Code is guacamole-ext.
- *
- * The Initial Developer of the Original Code is
- * Michael Jumper.
- * Portions created by the Initial Developer are Copyright (C) 2010
- * the Initial Developer. All Rights Reserved.
- *
- * Contributor(s):
- *
- * Alternatively, the contents of this file may be used under the terms of
- * either the GNU General Public License Version 2 or later (the "GPL"), or
- * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
- * in which case the provisions of the GPL or the LGPL are applicable instead
- * of those above. If you wish to allow use of your version of this file only
- * under the terms of either the GPL or the LGPL, and not to allow others to
- * use your version of this file under the terms of the MPL, indicate your
- * decision by deleting the provisions above and replace them with the notice
- * and other provisions required by the GPL or the LGPL. If you do not delete
- * the provisions above, a recipient may use your version of this file under
- * the terms of any one of the MPL, the GPL or the LGPL.
- *
- * ***** END LICENSE BLOCK ***** */
+import org.glyptodon.guacamole.net.auth.permission.ObjectPermissionSet;
+import org.glyptodon.guacamole.net.auth.permission.SystemPermissionSet;
 
 
 /**
@@ -47,21 +33,7 @@ import org.glyptodon.guacamole.net.auth.permission.Permission;
  *
  * @author Michael Jumper
  */
-public interface User {
-
-    /**
-     * Returns the name of this user, which must be unique across all users.
-     *
-     * @return The name of this user.
-     */
-    public String getUsername();
-
-    /**
-     * Sets the name of this user, which must be unique across all users.
-     *
-     * @param username  The name of this user.
-     */
-    public void setUsername(String username);
+public interface User extends Identifiable {
 
     /**
      * Returns this user's password. Note that the password returned may be
@@ -81,49 +53,92 @@ public interface User {
     public void setPassword(String password);
 
     /**
-     * Lists all permissions given to this user.
+     * Returns all attributes associated with this user. The returned map may
+     * not be modifiable.
+     *
+     * @return
+     *     A map of all attribute identifiers to their corresponding values,
+     *     for all attributes associated with this user, which may not be
+     *     modifiable.
+     */
+    Map<String, String> getAttributes();
+
+    /**
+     * Sets the given attributes. If an attribute within the map is not
+     * supported, it will simply be dropped. Any attributes not within the
+     * given map will be left untouched.
+     *
+     * @param attributes
+     *     A map of all attribute identifiers to their corresponding values.
+     */
+    void setAttributes(Map<String, String> attributes);
+
+    /**
+     * Returns all system-level permissions given to this user.
      *
-     * @return A Set of all permissions granted to this user.
+     * @return
+     *     A SystemPermissionSet of all system-level permissions granted to
+     *     this user.
      *
-     * @throws GuacamoleException  If an error occurs while retrieving
-     *                             permissions, or if reading all permissions
-     *                             is not allowed.
+     * @throws GuacamoleException 
+     *     If an error occurs while retrieving permissions, or if reading all
+     *     permissions is not allowed.
      */
-    Set<Permission> getPermissions() throws GuacamoleException;
+    SystemPermissionSet getSystemPermissions() throws GuacamoleException;
 
     /**
-     * Tests whether this user has the specified permission.
+     * Returns all connection permissions given to this user.
      *
-     * @param permission The permission to check.
-     * @return true if the permission is granted to this user, false otherwise.
+     * @return
+     *     An ObjectPermissionSet of all connection permissions granted to this
+     *     user.
      *
-     * @throws GuacamoleException If an error occurs while checking permissions,
-     *                            or if permissions cannot be checked due to
-     *                            lack of permissions to do so.
+     * @throws GuacamoleException 
+     *     If an error occurs while retrieving permissions, or if reading all
+     *     permissions is not allowed.
      */
-    boolean hasPermission(Permission permission) throws GuacamoleException;
+    ObjectPermissionSet getConnectionPermissions()
+            throws GuacamoleException;
 
     /**
-     * Adds the specified permission to this user.
+     * Returns all connection group permissions given to this user.
      *
-     * @param permission The permission to add.
+     * @return
+     *     An ObjectPermissionSet of all connection group permissions granted
+     *     to this user.
      *
-     * @throws GuacamoleException If an error occurs while adding the
-     *                            permission. or if permission to add
-     *                            permissions is denied.
+     * @throws GuacamoleException
+     *     If an error occurs while retrieving permissions, or if reading all
+     *     permissions is not allowed.
      */
-    void addPermission(Permission permission) throws GuacamoleException;
+    ObjectPermissionSet getConnectionGroupPermissions()
+            throws GuacamoleException;
 
     /**
-     * Removes the specified permission from this specified user.
+     * Returns all permissions given to this user regarding currently-active
+     * connections.
      *
-     * @param permission The permission to remove.
+     * @return
+     *     An ObjectPermissionSet of all active connection permissions granted
+     *     to this user.
      *
-     * @throws GuacamoleException If an error occurs while removing the
-     *                            permission. or if permission to remove
-     *                            permissions is denied.
+     * @throws GuacamoleException 
+     *     If an error occurs while retrieving permissions, or if reading all
+     *     permissions is not allowed.
      */
-    void removePermission(Permission permission) throws GuacamoleException;
+    ObjectPermissionSet getActiveConnectionPermissions()
+            throws GuacamoleException;
 
+    /**
+     * Returns all user permissions given to this user.
+     *
+     * @return
+     *     An ObjectPermissionSet of all user permissions granted to this user.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while retrieving permissions, or if reading all
+     *     permissions is not allowed.
+     */
+    ObjectPermissionSet getUserPermissions() throws GuacamoleException;
 
 }
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/UserContext.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/UserContext.java
index efb7431..f38fad8 100644
--- a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/UserContext.java
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/UserContext.java
@@ -1,43 +1,30 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ * 
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ * 
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ * 
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
 
 package org.glyptodon.guacamole.net.auth;
 
-/* ***** BEGIN LICENSE BLOCK *****
- * Version: MPL 1.1/GPL 2.0/LGPL 2.1
- *
- * The contents of this file are subject to the Mozilla Public License Version
- * 1.1 (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.mozilla.org/MPL/
- *
- * Software distributed under the License is distributed on an "AS IS" basis,
- * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- * for the specific language governing rights and limitations under the
- * License.
- *
- * The Original Code is guacamole-ext.
- *
- * The Initial Developer of the Original Code is
- * Michael Jumper.
- * Portions created by the Initial Developer are Copyright (C) 2010
- * the Initial Developer. All Rights Reserved.
- *
- * Contributor(s): James Muehlner
- *
- * Alternatively, the contents of this file may be used under the terms of
- * either the GNU General Public License Version 2 or later (the "GPL"), or
- * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
- * in which case the provisions of the GPL or the LGPL are applicable instead
- * of those above. If you wish to allow use of your version of this file only
- * under the terms of either the GPL or the LGPL, and not to allow others to
- * use your version of this file under the terms of the MPL, indicate your
- * decision by deleting the provisions above and replace them with the notice
- * and other provisions required by the GPL or the LGPL. If you do not delete
- * the provisions above, a recipient may use your version of this file under
- * the terms of any one of the MPL, the GPL or the LGPL.
- *
- * ***** END LICENSE BLOCK ***** */
-
+import java.util.Collection;
 import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.form.Form;
 
 /**
  * The context of an active user. The functions of this class enforce all
@@ -57,6 +44,16 @@ public interface UserContext {
     User self();
 
     /**
+     * Returns the AuthenticationProvider which created this UserContext, which
+     * may not be the same AuthenticationProvider that authenticated the user
+     * associated with this UserContext.
+     *
+     * @return
+     *     The AuthenticationProvider that created this UserContext.
+     */
+    AuthenticationProvider getAuthenticationProvider();
+
+    /**
      * Retrieves a Directory which can be used to view and manipulate other
      * users, but only as allowed by the permissions given to the user of this
      * UserContext.
@@ -67,8 +64,63 @@ public interface UserContext {
      * @throws GuacamoleException If an error occurs while creating the
      *                            Directory.
      */
-    Directory<String, User> getUserDirectory() throws GuacamoleException;
+    Directory<User> getUserDirectory() throws GuacamoleException;
+
+    /**
+     * Retrieves a Directory which can be used to view and manipulate
+     * connections and their configurations, but only as allowed by the
+     * permissions given to the user.
+     *
+     * @return A Directory whose operations are bound by the permissions of 
+     *         the user.
+     *
+     * @throws GuacamoleException If an error occurs while creating the
+     *                            Directory.
+     */
+    Directory<Connection> getConnectionDirectory()
+            throws GuacamoleException;
+
+    /**
+     * Retrieves a Directory which can be used to view and manipulate
+     * connection groups and their members, but only as allowed by the
+     * permissions given to the user.
+     *
+     * @return A Directory whose operations are bound by the permissions of
+     *         the user.
+     *
+     * @throws GuacamoleException If an error occurs while creating the
+     *                            Directory.
+     */
+    Directory<ConnectionGroup> getConnectionGroupDirectory()
+            throws GuacamoleException;
+
+    /**
+     * Retrieves a Directory which can be used to view and manipulate
+     * active connections, but only as allowed by the permissions given to the
+     * user.
+     *
+     * @return
+     *     A Directory whose operations are bound by the permissions of the
+     *     user.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while creating the Directory.
+     */
+    Directory<ActiveConnection> getActiveConnectionDirectory()
+            throws GuacamoleException;
 
+    /**
+     * Retrieves all connection records visible to current user. The resulting
+     * set of connection records can be further filtered and ordered using the
+     * methods defined on ConnectionRecordSet.
+     *
+     * @return
+     *     A set of all connection records visible to the current user.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while retrieving the connection records.
+     */
+    ConnectionRecordSet getConnectionHistory() throws GuacamoleException;
 
     /**
      * Retrieves a connection group which can be used to view and manipulate
@@ -83,4 +135,37 @@ public interface UserContext {
      */
     ConnectionGroup getRootConnectionGroup() throws GuacamoleException;
 
+    /**
+     * Retrieves a collection of all attributes applicable to users. This
+     * collection will contain only those attributes which the current user has
+     * general permission to view or modify. If there are no such attributes,
+     * this collection will be empty.
+     *
+     * @return
+     *     A collection of all attributes applicable to users.
+     */
+    Collection<Form> getUserAttributes();
+
+    /**
+     * Retrieves a collection of all attributes applicable to connections. This
+     * collection will contain only those attributes which the current user has
+     * general permission to view or modify. If there are no such attributes,
+     * this collection will be empty.
+     *
+     * @return
+     *     A collection of all attributes applicable to connections.
+     */
+    Collection<Form> getConnectionAttributes();
+
+    /**
+     * Retrieves a collection of all attributes applicable to connection
+     * groups. This collection will contain only those attributes which the
+     * current user has general permission to view or modify. If there are no
+     * such attributes, this collection will be empty.
+     *
+     * @return
+     *     A collection of all attributes applicable to connection groups.
+     */
+    Collection<Form> getConnectionGroupAttributes();
+
 }
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/credentials/CredentialsInfo.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/credentials/CredentialsInfo.java
new file mode 100644
index 0000000..a0c4716
--- /dev/null
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/credentials/CredentialsInfo.java
@@ -0,0 +1,93 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ * 
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ * 
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ * 
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.auth.credentials;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import org.glyptodon.guacamole.form.Field;
+import org.glyptodon.guacamole.form.PasswordField;
+import org.glyptodon.guacamole.form.UsernameField;
+
+/**
+ * Information which describes a set of valid credentials.
+ *
+ * @author Michael Jumper
+ */
+public class CredentialsInfo {
+
+    /**
+     * All fields required for valid credentials.
+     */
+    private final Collection<Field> fields;
+
+    /**
+     * Creates a new CredentialsInfo object which requires the given fields for
+     * any conforming credentials.
+     *
+     * @param fields
+     *     The fields to require.
+     */
+    public CredentialsInfo(Collection<Field> fields) {
+        this.fields = fields;
+    }
+    
+    /**
+     * Returns all fields required for valid credentials as described by this
+     * object.
+     *
+     * @return
+     *     All fields required for valid credentials.
+     */
+    public Collection<Field> getFields() {
+        return Collections.unmodifiableCollection(fields);
+    }
+
+    /**
+     * CredentialsInfo object which describes empty credentials. No fields are
+     * required.
+     */
+    public static final CredentialsInfo EMPTY = new CredentialsInfo(Collections.<Field>emptyList());
+
+    /**
+     * A field describing the username HTTP parameter expected by Guacamole
+     * during login, if usernames are being used.
+     */
+    public static final Field USERNAME = new UsernameField("username");
+
+    /**
+     * A field describing the password HTTP parameter expected by Guacamole
+     * during login, if passwords are being used.
+     */
+    public static final Field PASSWORD = new PasswordField("password");
+
+    /**
+     * CredentialsInfo object which describes standard username/password
+     * credentials.
+     */
+    public static final CredentialsInfo USERNAME_PASSWORD = new CredentialsInfo(Arrays.asList(
+        USERNAME,
+        PASSWORD
+    ));
+    
+}
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/credentials/GuacamoleCredentialsException.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/credentials/GuacamoleCredentialsException.java
new file mode 100644
index 0000000..6ece39a
--- /dev/null
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/credentials/GuacamoleCredentialsException.java
@@ -0,0 +1,100 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.auth.credentials;
+
+import org.glyptodon.guacamole.GuacamoleUnauthorizedException;
+
+/**
+ * A security-related exception thrown when access is denied to a user because
+ * of a problem related to the provided credentials. Additional information
+ * describing the form of valid credentials is provided.
+ *
+ * @author Michael Jumper
+ */
+public class GuacamoleCredentialsException extends GuacamoleUnauthorizedException {
+
+    /**
+     * Information describing the form of valid credentials.
+     */
+    private final CredentialsInfo credentialsInfo;
+    
+    /**
+     * Creates a new GuacamoleInvalidCredentialsException with the given
+     * message, cause, and associated credential information.
+     *
+     * @param message
+     *     A human readable description of the exception that occurred.
+     *
+     * @param cause
+     *     The cause of this exception.
+     *
+     * @param credentialsInfo
+     *     Information describing the form of valid credentials.
+     */
+    public GuacamoleCredentialsException(String message, Throwable cause,
+            CredentialsInfo credentialsInfo) {
+        super(message, cause);
+        this.credentialsInfo = credentialsInfo;
+    }
+
+    /**
+     * Creates a new GuacamoleInvalidCredentialsException with the given
+     * message and associated credential information.
+     *
+     * @param message
+     *     A human readable description of the exception that occurred.
+     *
+     * @param credentialsInfo
+     *     Information describing the form of valid credentials.
+     */
+    public GuacamoleCredentialsException(String message, CredentialsInfo credentialsInfo) {
+        super(message);
+        this.credentialsInfo = credentialsInfo;
+    }
+
+    /**
+     * Creates a new GuacamoleInvalidCredentialsException with the given cause
+     * and associated credential information.
+     *
+     * @param cause
+     *     The cause of this exception.
+     *
+     * @param credentialsInfo
+     *     Information describing the form of valid credentials.
+     */
+    public GuacamoleCredentialsException(Throwable cause, CredentialsInfo credentialsInfo) {
+        super(cause);
+        this.credentialsInfo = credentialsInfo;
+    }
+
+    /**
+     * Returns information describing the form of valid credentials.
+     *
+     * @return
+     *     Information describing the form of valid credentials.
+     */
+    public CredentialsInfo getCredentialsInfo() {
+        return credentialsInfo;
+    }
+
+}
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/credentials/GuacamoleInsufficientCredentialsException.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/credentials/GuacamoleInsufficientCredentialsException.java
new file mode 100644
index 0000000..ea8675a
--- /dev/null
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/credentials/GuacamoleInsufficientCredentialsException.java
@@ -0,0 +1,82 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.auth.credentials;
+
+/**
+ * A security-related exception thrown when access is denied to a user because
+ * the provided credentials are not sufficient for authentication to succeed.
+ * The validity or invalidity of the given credentials is not specified, and
+ * more information is needed before a decision can be made. Additional
+ * information describing the form of valid credentials is provided.
+ *
+ * @author Michael Jumper
+ */
+public class GuacamoleInsufficientCredentialsException extends GuacamoleCredentialsException {
+
+    /**
+     * Creates a new GuacamoleInsufficientCredentialsException with the given
+     * message, cause, and associated credential information.
+     *
+     * @param message
+     *     A human readable description of the exception that occurred.
+     *
+     * @param cause
+     *     The cause of this exception.
+     *
+     * @param credentialsInfo
+     *     Information describing the form of valid credentials.
+     */
+    public GuacamoleInsufficientCredentialsException(String message, Throwable cause,
+            CredentialsInfo credentialsInfo) {
+        super(message, cause, credentialsInfo);
+    }
+
+    /**
+     * Creates a new GuacamoleInsufficientCredentialsException with the given
+     * message and associated credential information.
+     *
+     * @param message
+     *     A human readable description of the exception that occurred.
+     *
+     * @param credentialsInfo
+     *     Information describing the form of valid credentials.
+     */
+    public GuacamoleInsufficientCredentialsException(String message, CredentialsInfo credentialsInfo) {
+        super(message, credentialsInfo);
+    }
+
+    /**
+     * Creates a new GuacamoleInsufficientCredentialsException with the given
+     * cause and associated credential information.
+     *
+     * @param cause
+     *     The cause of this exception.
+     *
+     * @param credentialsInfo
+     *     Information describing the form of valid credentials.
+     */
+    public GuacamoleInsufficientCredentialsException(Throwable cause, CredentialsInfo credentialsInfo) {
+        super(cause, credentialsInfo);
+    }
+
+}
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/credentials/GuacamoleInvalidCredentialsException.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/credentials/GuacamoleInvalidCredentialsException.java
new file mode 100644
index 0000000..903b82b
--- /dev/null
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/credentials/GuacamoleInvalidCredentialsException.java
@@ -0,0 +1,80 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.auth.credentials;
+
+/**
+ * A security-related exception thrown when access is denied to a user because
+ * the provided credentials are invalid. Additional information describing
+ * the form of valid credentials is provided.
+ *
+ * @author Michael Jumper
+ */
+public class GuacamoleInvalidCredentialsException extends GuacamoleCredentialsException {
+
+    /**
+     * Creates a new GuacamoleInvalidCredentialsException with the given
+     * message, cause, and associated credential information.
+     *
+     * @param message
+     *     A human readable description of the exception that occurred.
+     *
+     * @param cause
+     *     The cause of this exception.
+     *
+     * @param credentialsInfo
+     *     Information describing the form of valid credentials.
+     */
+    public GuacamoleInvalidCredentialsException(String message, Throwable cause,
+            CredentialsInfo credentialsInfo) {
+        super(message, cause, credentialsInfo);
+    }
+
+    /**
+     * Creates a new GuacamoleInvalidCredentialsException with the given
+     * message and associated credential information.
+     *
+     * @param message
+     *     A human readable description of the exception that occurred.
+     *
+     * @param credentialsInfo
+     *     Information describing the form of valid credentials.
+     */
+    public GuacamoleInvalidCredentialsException(String message, CredentialsInfo credentialsInfo) {
+        super(message, credentialsInfo);
+    }
+
+    /**
+     * Creates a new GuacamoleInvalidCredentialsException with the given cause
+     * and associated credential information.
+     *
+     * @param cause
+     *     The cause of this exception.
+     *
+     * @param credentialsInfo
+     *     Information describing the form of valid credentials.
+     */
+    public GuacamoleInvalidCredentialsException(Throwable cause, CredentialsInfo credentialsInfo) {
+        super(cause, credentialsInfo);
+    }
+
+}
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/package-info.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/package-info.java
index 7633ebb..1e17178 100644
--- a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/package-info.java
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/package-info.java
@@ -1,3 +1,24 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ * 
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ * 
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ * 
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
 
 /**
  * Provides classes which can be used to extend or replace the authentication
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/permission/ConnectionGroupPermission.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/permission/ConnectionGroupPermission.java
deleted file mode 100644
index 377cdbc..0000000
--- a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/permission/ConnectionGroupPermission.java
+++ /dev/null
@@ -1,121 +0,0 @@
-
-package org.glyptodon.guacamole.net.auth.permission;
-
-/* ***** BEGIN LICENSE BLOCK *****
- * Version: MPL 1.1/GPL 2.0/LGPL 2.1
- *
- * The contents of this file are subject to the Mozilla Public License Version
- * 1.1 (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.mozilla.org/MPL/
- *
- * Software distributed under the License is distributed on an "AS IS" basis,
- * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- * for the specific language governing rights and limitations under the
- * License.
- *
- * The Original Code is guacamole-ext.
- *
- * The Initial Developer of the Original Code is
- * Michael Jumper.
- * Portions created by the Initial Developer are Copyright (C) 2010
- * the Initial Developer. All Rights Reserved.
- *
- * Contributor(s): James Muehlner
- *
- * Alternatively, the contents of this file may be used under the terms of
- * either the GNU General Public License Version 2 or later (the "GPL"), or
- * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
- * in which case the provisions of the GPL or the LGPL are applicable instead
- * of those above. If you wish to allow use of your version of this file only
- * under the terms of either the GPL or the LGPL, and not to allow others to
- * use your version of this file under the terms of the MPL, indicate your
- * decision by deleting the provisions above and replace them with the notice
- * and other provisions required by the GPL or the LGPL. If you do not delete
- * the provisions above, a recipient may use your version of this file under
- * the terms of any one of the MPL, the GPL or the LGPL.
- *
- * ***** END LICENSE BLOCK ***** */
-
-
-/**
- * A permission which controls operations that directly affect a specific
- * ConnectionGroup. Note that this permission only refers to the
- * ConnectionGroup by its identifier. The actual ConnectionGroup
- * is not stored within.
- *
- * @author James Muehlner
- */
-public class ConnectionGroupPermission
-    implements ObjectPermission<String> {
-
-    /**
-     * The identifier of the GuacamoleConfiguration associated with the
-     * operation affected by this permission.
-     */
-    private String identifier;
-
-    /**
-     * The type of operation affected by this permission.
-     */
-    private ObjectPermission.Type type;
-
-    /**
-     * Creates a new ConnectionGroupPermission having the given type
-     * and identifier. The identifier must be the unique identifier assigned
-     * to the ConnectionGroup by the AuthenticationProvider in use.
-     *
-     * @param type The type of operation affected by this permission.
-     * @param identifier The identifier of the ConnectionGroup associated
-     *                   with the operation affected by this permission.
-     */
-    public ConnectionGroupPermission(ObjectPermission.Type type, String identifier) {
-
-        this.identifier = identifier;
-        this.type = type;
-
-    }
-
-    @Override
-    public String getObjectIdentifier() {
-        return identifier;
-    }
-
-    @Override
-    public ObjectPermission.Type getType() {
-        return type;
-    }
-
-    @Override
-    public int hashCode() {
-        int hash = 5;
-        if (identifier != null) hash = 47 * hash + identifier.hashCode();
-        if (type != null)       hash = 47 * hash + type.hashCode();
-        return hash;
-    }
-
-    @Override
-    public boolean equals(Object obj) {
-
-        // Not equal if null or wrong type
-        if (obj == null) return false;
-        if (getClass() != obj.getClass()) return false;
-
-        final ConnectionGroupPermission other =
-                (ConnectionGroupPermission) obj;
-
-        // Not equal if different type
-        if (this.type != other.type)
-            return false;
-
-        // If null identifier, equality depends on whether other identifier
-        // is null
-        if (identifier == null)
-            return other.identifier == null;
-
-        // Otherwise, equality depends entirely on identifier
-        return identifier.equals(other.identifier);
-
-    }
-
-}
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/permission/ConnectionPermission.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/permission/ConnectionPermission.java
deleted file mode 100644
index 7b0c267..0000000
--- a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/permission/ConnectionPermission.java
+++ /dev/null
@@ -1,121 +0,0 @@
-
-package org.glyptodon.guacamole.net.auth.permission;
-
-/* ***** BEGIN LICENSE BLOCK *****
- * Version: MPL 1.1/GPL 2.0/LGPL 2.1
- *
- * The contents of this file are subject to the Mozilla Public License Version
- * 1.1 (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.mozilla.org/MPL/
- *
- * Software distributed under the License is distributed on an "AS IS" basis,
- * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- * for the specific language governing rights and limitations under the
- * License.
- *
- * The Original Code is guacamole-ext.
- *
- * The Initial Developer of the Original Code is
- * Michael Jumper.
- * Portions created by the Initial Developer are Copyright (C) 2010
- * the Initial Developer. All Rights Reserved.
- *
- * Contributor(s):
- *
- * Alternatively, the contents of this file may be used under the terms of
- * either the GNU General Public License Version 2 or later (the "GPL"), or
- * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
- * in which case the provisions of the GPL or the LGPL are applicable instead
- * of those above. If you wish to allow use of your version of this file only
- * under the terms of either the GPL or the LGPL, and not to allow others to
- * use your version of this file under the terms of the MPL, indicate your
- * decision by deleting the provisions above and replace them with the notice
- * and other provisions required by the GPL or the LGPL. If you do not delete
- * the provisions above, a recipient may use your version of this file under
- * the terms of any one of the MPL, the GPL or the LGPL.
- *
- * ***** END LICENSE BLOCK ***** */
-
-
-/**
- * A permission which controls operations that directly affect a specific
- * GuacamoleConfiguration. Note that this permission only refers to the
- * GuacamoleConfiguration by its identifier. The actual GuacamoleConfiguration
- * is not stored within.
- *
- * @author Michael Jumper
- */
-public class ConnectionPermission
-    implements ObjectPermission<String> {
-
-    /**
-     * The identifier of the GuacamoleConfiguration associated with the
-     * operation affected by this permission.
-     */
-    private String identifier;
-
-    /**
-     * The type of operation affected by this permission.
-     */
-    private Type type;
-
-    /**
-     * Creates a new ConnectionPermission having the given type
-     * and identifier. The identifier must be the unique identifier assigned
-     * to the GuacamoleConfiguration by the AuthenticationProvider in use.
-     *
-     * @param type The type of operation affected by this permission.
-     * @param identifier The identifier of the GuacamoleConfiguration associated
-     *                   with the operation affected by this permission.
-     */
-    public ConnectionPermission(Type type, String identifier) {
-
-        this.identifier = identifier;
-        this.type = type;
-
-    }
-
-    @Override
-    public String getObjectIdentifier() {
-        return identifier;
-    }
-
-    @Override
-    public Type getType() {
-        return type;
-    }
-
-    @Override
-    public int hashCode() {
-        int hash = 5;
-        if (identifier != null) hash = 47 * hash + identifier.hashCode();
-        if (type != null)       hash = 47 * hash + type.hashCode();
-        return hash;
-    }
-
-    @Override
-    public boolean equals(Object obj) {
-
-        // Not equal if null or wrong type
-        if (obj == null) return false;
-        if (getClass() != obj.getClass()) return false;
-
-        final ConnectionPermission other =
-                (ConnectionPermission) obj;
-
-        // Not equal if different type
-        if (this.type != other.type)
-            return false;
-
-        // If null identifier, equality depends on whether other identifier
-        // is null
-        if (identifier == null)
-            return other.identifier == null;
-
-        // Otherwise, equality depends entirely on identifier
-        return identifier.equals(other.identifier);
-
-    }
-
-}
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/permission/ObjectPermission.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/permission/ObjectPermission.java
index 045f1c4..e0927f3 100644
--- a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/permission/ObjectPermission.java
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/permission/ObjectPermission.java
@@ -1,51 +1,35 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ * 
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ * 
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ * 
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
 
 package org.glyptodon.guacamole.net.auth.permission;
 
-/* ***** BEGIN LICENSE BLOCK *****
- * Version: MPL 1.1/GPL 2.0/LGPL 2.1
- *
- * The contents of this file are subject to the Mozilla Public License Version
- * 1.1 (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.mozilla.org/MPL/
- *
- * Software distributed under the License is distributed on an "AS IS" basis,
- * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- * for the specific language governing rights and limitations under the
- * License.
- *
- * The Original Code is guacamole-ext.
- *
- * The Initial Developer of the Original Code is
- * Michael Jumper.
- * Portions created by the Initial Developer are Copyright (C) 2010
- * the Initial Developer. All Rights Reserved.
- *
- * Contributor(s):
- *
- * Alternatively, the contents of this file may be used under the terms of
- * either the GNU General Public License Version 2 or later (the "GPL"), or
- * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
- * in which case the provisions of the GPL or the LGPL are applicable instead
- * of those above. If you wish to allow use of your version of this file only
- * under the terms of either the GPL or the LGPL, and not to allow others to
- * use your version of this file under the terms of the MPL, indicate your
- * decision by deleting the provisions above and replace them with the notice
- * and other provisions required by the GPL or the LGPL. If you do not delete
- * the provisions above, a recipient may use your version of this file under
- * the terms of any one of the MPL, the GPL or the LGPL.
- *
- * ***** END LICENSE BLOCK ***** */
-
 
 /**
  * A permission which affects a specific object, rather than the system as a
  * whole.
  *
  * @author Michael Jumper
- * @param <T> The type of identifier used by the object this permission affects.
  */
-public interface ObjectPermission<T> extends Permission<ObjectPermission.Type> {
+public class ObjectPermission implements Permission<ObjectPermission.Type> {
 
     /**
      * Specific types of object-level permissions. Each permission type is
@@ -76,12 +60,80 @@ public interface ObjectPermission<T> extends Permission<ObjectPermission.Type> {
     }
 
     /**
+     * The identifier of the GuacamoleConfiguration associated with the
+     * operation affected by this permission.
+     */
+    private final String identifier;
+
+    /**
+     * The type of operation affected by this permission.
+     */
+    private final Type type;
+
+    /**
+     * Creates a new ObjectPermission having the given type and identifier.
+     * The identifier must be the unique identifier assigned to the object
+     * associated with this permission by the AuthenticationProvider in use.
+     *
+     * @param type
+     *     The type of operation affected by this permission.
+     *
+     * @param identifier
+     *     The identifier of the object associated with the operation affected
+     *     by this permission.
+     */
+    public ObjectPermission(Type type, String identifier) {
+
+        this.identifier = identifier;
+        this.type = type;
+
+    }
+
+   /**
      * Returns the identifier of the specific object affected by this
      * permission.
      *
      * @return The identifier of the specific object affected by this
      *         permission.
      */
-    public T getObjectIdentifier();
+    public String getObjectIdentifier() {
+        return identifier;
+    }
+
+    @Override
+    public Type getType() {
+        return type;
+    }
+
+    @Override
+    public int hashCode() {
+        int hash = 5;
+        if (identifier != null) hash = 47 * hash + identifier.hashCode();
+        if (type != null)       hash = 47 * hash + type.hashCode();
+        return hash;
+    }
+
+    @Override
+    public boolean equals(Object obj) {
+
+        // Not equal if null or wrong type
+        if (obj == null) return false;
+        if (getClass() != obj.getClass()) return false;
+
+        final ObjectPermission other = (ObjectPermission) obj;
+
+        // Not equal if different type
+        if (this.type != other.type)
+            return false;
+
+        // If null identifier, equality depends on whether other identifier
+        // is null
+        if (identifier == null)
+            return other.identifier == null;
+
+        // Otherwise, equality depends entirely on identifier
+        return identifier.equals(other.identifier);
+
+    }
 
 }
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/permission/ObjectPermissionSet.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/permission/ObjectPermissionSet.java
new file mode 100644
index 0000000..7e6399a
--- /dev/null
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/permission/ObjectPermissionSet.java
@@ -0,0 +1,134 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.auth.permission;
+
+import java.util.Collection;
+import java.util.Set;
+import org.glyptodon.guacamole.GuacamoleException;
+
+
+/**
+ * A set of permissions which affect arbitrary objects, where each object has
+ * an associated unique identifier.
+ *
+ * @author Michael Jumper
+ */
+public interface ObjectPermissionSet extends PermissionSet<ObjectPermission> {
+
+    /**
+     * Tests whether the permission of the given type is granted for the
+     * object having the given identifier.
+     *
+     * @param permission
+     *     The permission to check.
+     *
+     * @param identifier
+     *     The identifier of the object affected by the permission being
+     *     checked.
+     *
+     * @return
+     *     true if the permission is granted, false otherwise.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while checking permissions, or if permissions
+     *     cannot be checked due to lack of permissions to do so.
+     */
+    boolean hasPermission(ObjectPermission.Type permission,
+            String identifier) throws GuacamoleException;
+
+    /**
+     * Adds the specified permission for the object having the given
+     * identifier.
+     *
+     * @param permission
+     *     The permission to add.
+     *
+     * @param identifier
+     *     The identifier of the object affected by the permission being
+     *     added.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while adding the permission, or if permission to
+     *     add permissions is denied.
+     */
+    void addPermission(ObjectPermission.Type permission,
+            String identifier) throws GuacamoleException;
+
+    /**
+     * Removes the specified permission for the object having the given
+     * identifier.
+     *
+     * @param permission
+     *     The permission to remove.
+     *
+     * @param identifier
+     *     The identifier of the object affected by the permission being
+     *     added.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while removing the permission, or if permission
+     *     to remove permissions is denied.
+     */
+    void removePermission(ObjectPermission.Type permission,
+            String identifier) throws GuacamoleException;
+
+    /**
+     * Tests whether this user has the specified permissions for the objects
+     * having the given identifiers. The identifier of an object is returned
+     * in a new collection if at least one of the specified permissions is
+     * granted for that object.
+     *
+     * @param permissions
+     *     The permissions to check. An identifier will be included in the
+     *     resulting collection if at least one of these permissions is granted
+     *     for the associated object
+     *
+     * @param identifiers
+     *     The identifiers of the objects affected by the permissions being
+     *     checked.
+     *
+     * @return
+     *     A collection containing the subset of identifiers for which at least
+     *     one of the specified permissions is granted.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while checking permissions, or if permissions
+     *     cannot be checked due to lack of permissions to do so.
+     */
+    Collection<String> getAccessibleObjects(
+            Collection<ObjectPermission.Type> permissions,
+            Collection<String> identifiers) throws GuacamoleException;
+
+    @Override
+    Set<ObjectPermission> getPermissions()
+            throws GuacamoleException;
+
+    @Override
+    void addPermissions(Set<ObjectPermission> permissions)
+            throws GuacamoleException;
+
+    @Override
+    void removePermissions(Set<ObjectPermission> permissions)
+            throws GuacamoleException;
+
+}
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/permission/Permission.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/permission/Permission.java
index dcd18dd..aac252e 100644
--- a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/permission/Permission.java
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/permission/Permission.java
@@ -1,42 +1,27 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ * 
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ * 
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ * 
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
 
 package org.glyptodon.guacamole.net.auth.permission;
 
-/* ***** BEGIN LICENSE BLOCK *****
- * Version: MPL 1.1/GPL 2.0/LGPL 2.1
- *
- * The contents of this file are subject to the Mozilla Public License Version
- * 1.1 (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.mozilla.org/MPL/
- *
- * Software distributed under the License is distributed on an "AS IS" basis,
- * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- * for the specific language governing rights and limitations under the
- * License.
- *
- * The Original Code is guacamole-ext.
- *
- * The Initial Developer of the Original Code is
- * Michael Jumper.
- * Portions created by the Initial Developer are Copyright (C) 2010
- * the Initial Developer. All Rights Reserved.
- *
- * Contributor(s):
- *
- * Alternatively, the contents of this file may be used under the terms of
- * either the GNU General Public License Version 2 or later (the "GPL"), or
- * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
- * in which case the provisions of the GPL or the LGPL are applicable instead
- * of those above. If you wish to allow use of your version of this file only
- * under the terms of either the GPL or the LGPL, and not to allow others to
- * use your version of this file under the terms of the MPL, indicate your
- * decision by deleting the provisions above and replace them with the notice
- * and other provisions required by the GPL or the LGPL. If you do not delete
- * the provisions above, a recipient may use your version of this file under
- * the terms of any one of the MPL, the GPL or the LGPL.
- *
- * ***** END LICENSE BLOCK ***** */
-
 
 /**
  * A permission which affects a specific type of operation, where all available
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/permission/PermissionSet.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/permission/PermissionSet.java
new file mode 100644
index 0000000..1bfa717
--- /dev/null
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/permission/PermissionSet.java
@@ -0,0 +1,82 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.auth.permission;
+
+import java.util.Set;
+import org.glyptodon.guacamole.GuacamoleException;
+
+
+/**
+ * An arbitrary set of permissions.
+ *
+ * @author Michael Jumper
+ * @param <PermissionType>
+ *     The type of permission stored within this PermissionSet.
+ */
+public interface PermissionSet<PermissionType extends Permission> {
+
+    /**
+     * Returns a Set which contains all permissions granted within this
+     * permission set.
+     *
+     * @return
+     *     A Set containing all permissions granted within this permission set.
+     *
+     * @throws GuacamoleException 
+     *     If an error occurs while retrieving permissions, or if permissions
+     *     cannot be retrieved due to lack of permissions to do so.
+     */
+    Set<PermissionType> getPermissions() throws GuacamoleException;
+
+    /**
+     * Adds the specified permissions, if not already granted. If a specified
+     * permission is already granted, no operation is performed regarding that
+     * permission.
+     *
+     * @param permissions
+     *     The permissions to add.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while adding the permissions, or if permission to
+     *     add permissions is denied.
+     */
+    void addPermissions(Set<PermissionType> permissions)
+            throws GuacamoleException;
+
+    /**
+     * Removes each of the specified permissions, if granted. If a specified
+     * permission is not granted, no operation is performed regarding that
+     * permission.
+     *
+     * @param permissions
+     *     The permissions to remove.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while removing the permissions, or if permission
+     *     to remove permissions is denied.
+     */
+    void removePermissions(Set<PermissionType> permissions)
+            throws GuacamoleException;
+
+
+}
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/permission/SystemPermission.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/permission/SystemPermission.java
index bc64785..4b50484 100644
--- a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/permission/SystemPermission.java
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/permission/SystemPermission.java
@@ -1,41 +1,27 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ * 
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ * 
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ * 
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
 
 package org.glyptodon.guacamole.net.auth.permission;
 
-/* ***** BEGIN LICENSE BLOCK *****
- * Version: MPL 1.1/GPL 2.0/LGPL 2.1
- *
- * The contents of this file are subject to the Mozilla Public License Version
- * 1.1 (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.mozilla.org/MPL/
- *
- * Software distributed under the License is distributed on an "AS IS" basis,
- * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- * for the specific language governing rights and limitations under the
- * License.
- *
- * The Original Code is guacamole-ext.
- *
- * The Initial Developer of the Original Code is
- * Michael Jumper.
- * Portions created by the Initial Developer are Copyright (C) 2010
- * the Initial Developer. All Rights Reserved.
- *
- * Contributor(s):
- *
- * Alternatively, the contents of this file may be used under the terms of
- * either the GNU General Public License Version 2 or later (the "GPL"), or
- * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
- * in which case the provisions of the GPL or the LGPL are applicable instead
- * of those above. If you wish to allow use of your version of this file only
- * under the terms of either the GPL or the LGPL, and not to allow others to
- * use your version of this file under the terms of the MPL, indicate your
- * decision by deleting the provisions above and replace them with the notice
- * and other provisions required by the GPL or the LGPL. If you do not delete
- * the provisions above, a recipient may use your version of this file under
- * the terms of any one of the MPL, the GPL or the LGPL.
- *
- * ***** END LICENSE BLOCK ***** */
 
 /**
  * A permission which affects the system as a whole, rather than an individual
@@ -116,5 +102,4 @@ public class SystemPermission implements Permission<SystemPermission.Type> {
         return true;
     }
 
-
 }
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/permission/SystemPermissionSet.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/permission/SystemPermissionSet.java
new file mode 100644
index 0000000..195c46e
--- /dev/null
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/permission/SystemPermissionSet.java
@@ -0,0 +1,89 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.auth.permission;
+
+import java.util.Set;
+import org.glyptodon.guacamole.GuacamoleException;
+
+
+/**
+ * A set of permissions which affects the system as a whole.
+ *
+ * @author Michael Jumper
+ */
+public interface SystemPermissionSet extends PermissionSet<SystemPermission> {
+
+    /**
+     * Tests whether the permission of the given type is granted.
+     *
+     * @param permission
+     *     The permission to check.
+     *
+     * @return
+     *     true if the permission is granted, false otherwise.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while checking permissions, or if permissions
+     *     cannot be checked due to lack of permissions to do so.
+     */
+    boolean hasPermission(SystemPermission.Type permission)
+            throws GuacamoleException;
+
+    /**
+     * Adds the specified permission.
+     *
+     * @param permission
+     *     The permission to add.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while adding the permission, or if permission to
+     *     add permissions is denied.
+     */
+    void addPermission(SystemPermission.Type permission)
+            throws GuacamoleException;
+
+    /**
+     * Removes the specified permission.
+     *
+     * @param permission
+     *     The permission to remove.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while removing the permission, or if permission
+     *     to remove permissions is denied.
+     */
+    void removePermission(SystemPermission.Type permission)
+            throws GuacamoleException;
+
+    @Override
+    Set<SystemPermission> getPermissions() throws GuacamoleException;
+
+    @Override
+    void addPermissions(Set<SystemPermission> permissions)
+            throws GuacamoleException;
+
+    @Override
+    void removePermissions(Set<SystemPermission> permissions)
+            throws GuacamoleException;
+
+}
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/permission/UserPermission.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/permission/UserPermission.java
deleted file mode 100644
index 5266d40..0000000
--- a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/permission/UserPermission.java
+++ /dev/null
@@ -1,116 +0,0 @@
-
-package org.glyptodon.guacamole.net.auth.permission;
-
-/* ***** BEGIN LICENSE BLOCK *****
- * Version: MPL 1.1/GPL 2.0/LGPL 2.1
- *
- * The contents of this file are subject to the Mozilla Public License Version
- * 1.1 (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.mozilla.org/MPL/
- *
- * Software distributed under the License is distributed on an "AS IS" basis,
- * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- * for the specific language governing rights and limitations under the
- * License.
- *
- * The Original Code is guacamole-ext.
- *
- * The Initial Developer of the Original Code is
- * Michael Jumper.
- * Portions created by the Initial Developer are Copyright (C) 2010
- * the Initial Developer. All Rights Reserved.
- *
- * Contributor(s):
- *
- * Alternatively, the contents of this file may be used under the terms of
- * either the GNU General Public License Version 2 or later (the "GPL"), or
- * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
- * in which case the provisions of the GPL or the LGPL are applicable instead
- * of those above. If you wish to allow use of your version of this file only
- * under the terms of either the GPL or the LGPL, and not to allow others to
- * use your version of this file under the terms of the MPL, indicate your
- * decision by deleting the provisions above and replace them with the notice
- * and other provisions required by the GPL or the LGPL. If you do not delete
- * the provisions above, a recipient may use your version of this file under
- * the terms of any one of the MPL, the GPL or the LGPL.
- *
- * ***** END LICENSE BLOCK ***** */
-
-
-/**
- * A permission which controls operations that directly affect a specific
- * User.
- *
- * @author Michael Jumper
- */
-public class UserPermission implements ObjectPermission<String> {
-
-    /**
-     * The username of the User associated with the operation affected by this
-     * permission.
-     */
-    private String identifier;
-
-    /**
-     * The type of operation affected by this permission.
-     */
-    private Type type;
-
-    /**
-     * Creates a new UserPermission having the given type and identifier. The
-     * identifier must be the user's username.
-     *
-     * @param type The type of operation affected by this permission.
-     * @param identifier The username of the User associated with the operation
-     *                   affected by this permission.
-     */
-    public UserPermission(Type type, String identifier) {
-
-        this.identifier = identifier;
-        this.type = type;
-
-    }
-
-    @Override
-    public String getObjectIdentifier() {
-        return identifier;
-    }
-
-    @Override
-    public Type getType() {
-        return type;
-    }
-
-    @Override
-    public int hashCode() {
-        int hash = 5;
-        if (identifier != null) hash = 47 * hash + identifier.hashCode();
-        if (type != null)       hash = 47 * hash + type.hashCode();
-        return hash;
-    }
-
-    @Override
-    public boolean equals(Object obj) {
-
-        // Not equal if null or wrong type
-        if (obj == null) return false;
-        if (getClass() != obj.getClass()) return false;
-
-        final UserPermission other = (UserPermission) obj;
-
-        // Not equal if different type
-        if (this.type != other.type)
-            return false;
-
-        // If null identifier, equality depends on whether other identifier
-        // is null
-        if (identifier == null)
-            return other.identifier == null;
-
-        // Otherwise, equality depends entirely on identifier
-        return identifier.equals(other.identifier);
-
-    }
-
-}
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/permission/package-info.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/permission/package-info.java
index aed3059..5607925 100644
--- a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/permission/package-info.java
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/permission/package-info.java
@@ -1,3 +1,24 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ * 
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ * 
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ * 
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
 
 /**
  * Provides classes which describe the various permissions a Guacamole user
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/simple/SimpleAuthenticationProvider.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/simple/SimpleAuthenticationProvider.java
index 548713a..3ee2342 100644
--- a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/simple/SimpleAuthenticationProvider.java
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/simple/SimpleAuthenticationProvider.java
@@ -1,49 +1,38 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ * 
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ * 
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ * 
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
 
 package org.glyptodon.guacamole.net.auth.simple;
 
-/* ***** BEGIN LICENSE BLOCK *****
- * Version: MPL 1.1/GPL 2.0/LGPL 2.1
- *
- * The contents of this file are subject to the Mozilla Public License Version
- * 1.1 (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.mozilla.org/MPL/
- *
- * Software distributed under the License is distributed on an "AS IS" basis,
- * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- * for the specific language governing rights and limitations under the
- * License.
- *
- * The Original Code is guacamole-auth.
- *
- * The Initial Developer of the Original Code is
- * Michael Jumper.
- * Portions created by the Initial Developer are Copyright (C) 2010
- * the Initial Developer. All Rights Reserved.
- *
- * Contributor(s):
- *
- * Alternatively, the contents of this file may be used under the terms of
- * either the GNU General Public License Version 2 or later (the "GPL"), or
- * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
- * in which case the provisions of the GPL or the LGPL are applicable instead
- * of those above. If you wish to allow use of your version of this file only
- * under the terms of either the GPL or the LGPL, and not to allow others to
- * use your version of this file under the terms of the MPL, indicate your
- * decision by deleting the provisions above and replace them with the notice
- * and other provisions required by the GPL or the LGPL. If you do not delete
- * the provisions above, a recipient may use your version of this file under
- * the terms of any one of the MPL, the GPL or the LGPL.
- *
- * ***** END LICENSE BLOCK ***** */
-
 import java.util.Map;
+import java.util.UUID;
 import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.net.auth.AbstractAuthenticatedUser;
 import org.glyptodon.guacamole.net.auth.AuthenticationProvider;
+import org.glyptodon.guacamole.net.auth.AuthenticatedUser;
 import org.glyptodon.guacamole.net.auth.Credentials;
 import org.glyptodon.guacamole.net.auth.UserContext;
 import org.glyptodon.guacamole.protocol.GuacamoleConfiguration;
-
+import org.glyptodon.guacamole.token.StandardTokens;
+import org.glyptodon.guacamole.token.TokenFilter;
 
 /**
  * Provides means of retrieving a set of named GuacamoleConfigurations for a
@@ -76,8 +65,99 @@ public abstract class SimpleAuthenticationProvider
             getAuthorizedConfigurations(Credentials credentials)
             throws GuacamoleException;
 
-    @Override
-    public UserContext getUserContext(Credentials credentials)
+    /**
+     * AuthenticatedUser which contains its own predefined set of authorized
+     * configurations.
+     *
+     * @author Michael Jumper
+     */
+    private class SimpleAuthenticatedUser extends AbstractAuthenticatedUser {
+
+        /**
+         * The credentials provided when this AuthenticatedUser was
+         * authenticated.
+         */
+        private final Credentials credentials;
+
+        /**
+         * The GuacamoleConfigurations that this AuthenticatedUser is
+         * authorized to use.
+         */
+        private final Map<String, GuacamoleConfiguration> configs;
+
+        /**
+         * Creates a new SimpleAuthenticatedUser associated with the given
+         * credentials and having access to the given Map of
+         * GuacamoleConfigurations.
+         *
+         * @param credentials
+         *     The credentials provided by the user when they authenticated.
+         *
+         * @param configs
+         *     A Map of all GuacamoleConfigurations for which this user has
+         *     access. The keys of this Map are Strings which uniquely identify
+         *     each configuration.
+         */
+        public SimpleAuthenticatedUser(Credentials credentials, Map<String, GuacamoleConfiguration> configs) {
+
+            // Store credentials and configurations
+            this.credentials = credentials;
+            this.configs = configs;
+
+            // Pull username from credentials if it exists
+            String username = credentials.getUsername();
+            if (username != null && !username.isEmpty())
+                setIdentifier(username);
+
+            // Otherwise generate a random username
+            else
+                setIdentifier(UUID.randomUUID().toString());
+
+        }
+
+        /**
+         * Returns a Map containing all GuacamoleConfigurations that this user
+         * is authorized to use. The keys of this Map are Strings which
+         * uniquely identify each configuration.
+         *
+         * @return
+         *     A Map of all configurations for which this user is authorized.
+         */
+        public Map<String, GuacamoleConfiguration> getAuthorizedConfigurations() {
+            return configs;
+        }
+
+        @Override
+        public AuthenticationProvider getAuthenticationProvider() {
+            return SimpleAuthenticationProvider.this;
+        }
+
+        @Override
+        public Credentials getCredentials() {
+            return credentials;
+        }
+
+    }
+
+    /**
+     * Given an arbitrary credentials object, returns a Map containing all
+     * configurations authorized by those credentials, filtering those
+     * configurations using a TokenFilter and the standard credential tokens
+     * (like ${GUAC_USERNAME} and ${GUAC_PASSWORD}). The keys of this Map
+     * are Strings which uniquely identify each configuration.
+     *
+     * @param credentials
+     *     The credentials to use to retrieve authorized configurations.
+     *
+     * @return
+     *     A Map of all configurations authorized by the given credentials, or
+     *     null if the credentials given are not authorized.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while retrieving configurations.
+     */
+    private Map<String, GuacamoleConfiguration>
+            getFilteredAuthorizedConfigurations(Credentials credentials)
             throws GuacamoleException {
 
         // Get configurations
@@ -88,14 +168,93 @@ public abstract class SimpleAuthenticationProvider
         if (configs == null)
             return null;
 
+        // Build credential TokenFilter
+        TokenFilter tokenFilter = new TokenFilter();
+        StandardTokens.addStandardTokens(tokenFilter, credentials);
+
+        // Filter each configuration
+        for (GuacamoleConfiguration config : configs.values())
+            tokenFilter.filterValues(config.getParameters());
+
+        return configs;
+
+    }
+
+    /**
+     * Given a user who has already been authenticated, returns a Map
+     * containing all configurations for which that user is authorized,
+     * filtering those configurations using a TokenFilter and the standard
+     * credential tokens (like ${GUAC_USERNAME} and ${GUAC_PASSWORD}). The keys
+     * of this Map are Strings which uniquely identify each configuration.
+     *
+     * @param authenticatedUser
+     *     The user whose authorized configurations are to be retrieved.
+     *
+     * @return
+     *     A Map of all configurations authorized for use by the given user, or
+     *     null if the user is not authorized to use any configurations.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while retrieving configurations.
+     */
+    private Map<String, GuacamoleConfiguration>
+            getFilteredAuthorizedConfigurations(AuthenticatedUser authenticatedUser)
+            throws GuacamoleException {
+
+        // Pull cached configurations, if any
+        if (authenticatedUser instanceof SimpleAuthenticatedUser && authenticatedUser.getAuthenticationProvider() == this)
+            return ((SimpleAuthenticatedUser) authenticatedUser).getAuthorizedConfigurations();
+
+        // Otherwise, pull using credentials
+        return getFilteredAuthorizedConfigurations(authenticatedUser.getCredentials());
+
+    }
+
+    @Override
+    public AuthenticatedUser authenticateUser(final Credentials credentials)
+            throws GuacamoleException {
+
+        // Get configurations
+        Map<String, GuacamoleConfiguration> configs =
+                getFilteredAuthorizedConfigurations(credentials);
+
+        // Return as unauthorized if not authorized to retrieve configs
+        if (configs == null)
+            return null;
+
+        return new SimpleAuthenticatedUser(credentials, configs);
+
+    }
+
+    @Override
+    public UserContext getUserContext(AuthenticatedUser authenticatedUser)
+            throws GuacamoleException {
+
+        // Get configurations
+        Map<String, GuacamoleConfiguration> configs =
+                getFilteredAuthorizedConfigurations(authenticatedUser);
+
+        // Return as unauthorized if not authorized to retrieve configs
+        if (configs == null)
+            return null;
+
         // Return user context restricted to authorized configs
-        return new SimpleUserContext(configs);
+        return new SimpleUserContext(this, authenticatedUser.getIdentifier(), configs);
+
+    }
+
+    @Override
+    public AuthenticatedUser updateAuthenticatedUser(AuthenticatedUser authenticatedUser,
+            Credentials credentials) throws GuacamoleException {
+
+        // Simply return the given user, updating nothing
+        return authenticatedUser;
 
     }
 
     @Override
     public UserContext updateUserContext(UserContext context,
-        Credentials credentials) throws GuacamoleException {
+        AuthenticatedUser authorizedUser) throws GuacamoleException {
 
         // Simply return the given context, updating nothing
         return context;
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/simple/SimpleConnection.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/simple/SimpleConnection.java
index 231bd57..f59d205 100644
--- a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/simple/SimpleConnection.java
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/simple/SimpleConnection.java
@@ -1,56 +1,44 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ * 
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ * 
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ * 
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
 
 package org.glyptodon.guacamole.net.auth.simple;
 
-/* ***** BEGIN LICENSE BLOCK *****
- * Version: MPL 1.1/GPL 2.0/LGPL 2.1
- *
- * The contents of this file are subject to the Mozilla Public License Version
- * 1.1 (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.mozilla.org/MPL/
- *
- * Software distributed under the License is distributed on an "AS IS" basis,
- * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- * for the specific language governing rights and limitations under the
- * License.
- *
- * The Original Code is guacamole-auth.
- *
- * The Initial Developer of the Original Code is
- * Michael Jumper.
- * Portions created by the Initial Developer are Copyright (C) 2010
- * the Initial Developer. All Rights Reserved.
- *
- * Contributor(s):
- *
- * Alternatively, the contents of this file may be used under the terms of
- * either the GNU General Public License Version 2 or later (the "GPL"), or
- * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
- * in which case the provisions of the GPL or the LGPL are applicable instead
- * of those above. If you wish to allow use of your version of this file only
- * under the terms of either the GPL or the LGPL, and not to allow others to
- * use your version of this file under the terms of the MPL, indicate your
- * decision by deleting the provisions above and replace them with the notice
- * and other provisions required by the GPL or the LGPL. If you do not delete
- * the provisions above, a recipient may use your version of this file under
- * the terms of any one of the MPL, the GPL or the LGPL.
- *
- * ***** END LICENSE BLOCK ***** */
-
 import java.util.Collections;
 import java.util.List;
+import java.util.Map;
 import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.environment.Environment;
+import org.glyptodon.guacamole.environment.LocalEnvironment;
 import org.glyptodon.guacamole.net.GuacamoleSocket;
+import org.glyptodon.guacamole.net.GuacamoleTunnel;
 import org.glyptodon.guacamole.net.InetGuacamoleSocket;
 import org.glyptodon.guacamole.net.SSLGuacamoleSocket;
+import org.glyptodon.guacamole.net.SimpleGuacamoleTunnel;
 import org.glyptodon.guacamole.net.auth.AbstractConnection;
 import org.glyptodon.guacamole.net.auth.ConnectionRecord;
-import org.glyptodon.guacamole.properties.GuacamoleProperties;
 import org.glyptodon.guacamole.protocol.ConfiguredGuacamoleSocket;
 import org.glyptodon.guacamole.protocol.GuacamoleClientInformation;
 import org.glyptodon.guacamole.protocol.GuacamoleConfiguration;
 
-
 /**
  * An extremely basic Connection implementation.
  *
@@ -59,6 +47,18 @@ import org.glyptodon.guacamole.protocol.GuacamoleConfiguration;
 public class SimpleConnection extends AbstractConnection {
 
     /**
+     * The hostname to use when connecting to guacd if no hostname is provided
+     * within guacamole.properties.
+     */
+    private static final String DEFAULT_GUACD_HOSTNAME = "localhost";
+
+    /**
+     * The port to use when connecting to guacd if no port is provided within
+     * guacamole.properties.
+     */
+    private static final int DEFAULT_GUACD_PORT = 4822;
+
+    /**
      * Backing configuration, containing all sensitive information.
      */
     private GuacamoleConfiguration config;
@@ -94,31 +94,53 @@ public class SimpleConnection extends AbstractConnection {
     }
 
     @Override
-    public GuacamoleSocket connect(GuacamoleClientInformation info)
+    public int getActiveConnections() {
+        return 0;
+    }
+
+    @Override
+    public Map<String, String> getAttributes() {
+        return Collections.<String, String>emptyMap();
+    }
+
+    @Override
+    public void setAttributes(Map<String, String> attributes) {
+        // Do nothing - there are no attributes
+    }
+
+    @Override
+    public GuacamoleTunnel connect(GuacamoleClientInformation info)
             throws GuacamoleException {
 
+        Environment env = new LocalEnvironment();
+        
         // Get guacd connection parameters
-        String hostname = GuacamoleProperties.getProperty(GuacamoleProperties.GUACD_HOSTNAME);
-        int port = GuacamoleProperties.getProperty(GuacamoleProperties.GUACD_PORT);
+        String hostname = env.getProperty(Environment.GUACD_HOSTNAME, DEFAULT_GUACD_HOSTNAME);
+        int port = env.getProperty(Environment.GUACD_PORT, DEFAULT_GUACD_PORT);
 
+        GuacamoleSocket socket;
+        
         // If guacd requires SSL, use it
-        if (GuacamoleProperties.getProperty(GuacamoleProperties.GUACD_SSL, false))
-            return new ConfiguredGuacamoleSocket(
+        if (env.getProperty(Environment.GUACD_SSL, false))
+            socket = new ConfiguredGuacamoleSocket(
                 new SSLGuacamoleSocket(hostname, port),
                 config, info
             );
 
-        // Return connected socket
-        return new ConfiguredGuacamoleSocket(
-            new InetGuacamoleSocket(hostname, port),
-            config, info
-        );
+        // Otherwise, just connect directly via TCP
+        else
+            socket = new ConfiguredGuacamoleSocket(
+                new InetGuacamoleSocket(hostname, port),
+                config, info
+            );
 
+        return new SimpleGuacamoleTunnel(socket);
+        
     }
 
     @Override
     public List<ConnectionRecord> getHistory() throws GuacamoleException {
-        return Collections.EMPTY_LIST;
+        return Collections.<ConnectionRecord>emptyList();
     }
 
 }
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/simple/SimpleConnectionDirectory.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/simple/SimpleConnectionDirectory.java
index af3af8b..50e9d4f 100644
--- a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/simple/SimpleConnectionDirectory.java
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/simple/SimpleConnectionDirectory.java
@@ -1,52 +1,31 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ * 
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ * 
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ * 
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
 
 package org.glyptodon.guacamole.net.auth.simple;
 
-/* ***** BEGIN LICENSE BLOCK *****
- * Version: MPL 1.1/GPL 2.0/LGPL 2.1
- *
- * The contents of this file are subject to the Mozilla Public License Version
- * 1.1 (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.mozilla.org/MPL/
- *
- * Software distributed under the License is distributed on an "AS IS" basis,
- * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- * for the specific language governing rights and limitations under the
- * License.
- *
- * The Original Code is guacamole-auth.
- *
- * The Initial Developer of the Original Code is
- * Michael Jumper.
- * Portions created by the Initial Developer are Copyright (C) 2010
- * the Initial Developer. All Rights Reserved.
- *
- * Contributor(s):
- *
- * Alternatively, the contents of this file may be used under the terms of
- * either the GNU General Public License Version 2 or later (the "GPL"), or
- * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
- * in which case the provisions of the GPL or the LGPL are applicable instead
- * of those above. If you wish to allow use of your version of this file only
- * under the terms of either the GPL or the LGPL, and not to allow others to
- * use your version of this file under the terms of the MPL, indicate your
- * decision by deleting the provisions above and replace them with the notice
- * and other provisions required by the GPL or the LGPL. If you do not delete
- * the provisions above, a recipient may use your version of this file under
- * the terms of any one of the MPL, the GPL or the LGPL.
- *
- * ***** END LICENSE BLOCK ***** */
-
+import java.util.Collection;
 import java.util.HashMap;
 import java.util.Map;
-import java.util.Map.Entry;
-import java.util.Set;
-import org.glyptodon.guacamole.GuacamoleException;
-import org.glyptodon.guacamole.GuacamoleSecurityException;
 import org.glyptodon.guacamole.net.auth.Connection;
-import org.glyptodon.guacamole.net.auth.Directory;
-import org.glyptodon.guacamole.protocol.GuacamoleConfiguration;
-
 
 /**
  * An extremely simple read-only implementation of a Directory of
@@ -55,77 +34,44 @@ import org.glyptodon.guacamole.protocol.GuacamoleConfiguration;
  *
  * @author Michael Jumper
  */
-public class SimpleConnectionDirectory
-    implements Directory<String, Connection> {
+public class SimpleConnectionDirectory extends SimpleDirectory<Connection> {
 
     /**
      * The Map of Connections to provide access to.
      */
-    private Map<String, Connection> connections =
+    private final Map<String, Connection> connections =
             new HashMap<String, Connection>();
 
     /**
-     * Creates a new SimpleConnectionDirectory which provides
-     * access to the configurations contained within the given Map.
+     * Creates a new SimpleConnectionDirectory which provides access to the
+     * connections contained within the given Map.
      *
-     * @param configs The Map of GuacamoleConfigurations to provide access to.
+     * @param connections
+     *     A Collection of all connections that should be present in this
+     *     connection directory.
      */
-    public SimpleConnectionDirectory(
-            Map<String, GuacamoleConfiguration> configs) {
-
-        // Create connections for each config
-        for (Entry<String, GuacamoleConfiguration> entry : configs.entrySet())
-            connections.put(entry.getKey(),
-                    new SimpleConnection(entry.getKey(), entry.getKey(), 
-                entry.getValue()));
+    public SimpleConnectionDirectory(Collection<Connection> connections) {
 
-    }
-
-    @Override
-    public Connection get(String identifier)
-            throws GuacamoleException {
-        return connections.get(identifier);
-    }
+        // Add all given connections
+        for (Connection connection : connections)
+            this.connections.put(connection.getIdentifier(), connection);
 
-    @Override
-    public Set<String> getIdentifiers() throws GuacamoleException {
-        return connections.keySet();
-    }
-
-    @Override
-    public void add(Connection connection)
-            throws GuacamoleException {
-        throw new GuacamoleSecurityException("Permission denied.");
-    }
+        // Use the connection map to back the underlying directory 
+        super.setObjects(this.connections);
 
-    @Override
-    public void update(Connection connection)
-            throws GuacamoleException {
-        throw new GuacamoleSecurityException("Permission denied.");
     }
 
-    @Override
-    public void remove(String identifier) throws GuacamoleException {
-        throw new GuacamoleSecurityException("Permission denied.");
-    }
-
-    @Override
-    public void move(String identifier, Directory<String, Connection> directory) 
-            throws GuacamoleException {
-        throw new GuacamoleSecurityException("Permission denied.");
-    }
-    
     /**
      * An internal method for modifying the Connections in this Directory.
      * Returns the previous connection for the given identifier, if found.
-     * 
+     *
      * @param connection The connection to add or update the Directory with.
      * @return The previous connection for the connection identifier, if found.
      */
     public Connection putConnection(Connection connection) {
         return connections.put(connection.getIdentifier(), connection);
     }
-    
+
     /**
      * An internal method for removing a Connection from this Directory.
      * @param identifier The identifier of the Connection to remove.
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/simple/SimpleConnectionGroup.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/simple/SimpleConnectionGroup.java
index 6afd28f..179a86b 100644
--- a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/simple/SimpleConnectionGroup.java
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/simple/SimpleConnectionGroup.java
@@ -1,55 +1,42 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ * 
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ * 
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ * 
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
 
 package org.glyptodon.guacamole.net.auth.simple;
 
-/* ***** BEGIN LICENSE BLOCK *****
- * Version: MPL 1.1/GPL 2.0/LGPL 2.1
- *
- * The contents of this file are subject to the Mozilla Public License Version
- * 1.1 (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.mozilla.org/MPL/
- *
- * Software distributed under the License is distributed on an "AS IS" basis,
- * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- * for the specific language governing rights and limitations under the
- * License.
- *
- * The Original Code is guacamole-ext.
- *
- * The Initial Developer of the Original Code is
- * Michael Jumper.
- * Portions created by the Initial Developer are Copyright (C) 2010
- * the Initial Developer. All Rights Reserved.
- *
- * Contributor(s): James Muehlner
- *
- * Alternatively, the contents of this file may be used under the terms of
- * either the GNU General Public License Version 2 or later (the "GPL"), or
- * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
- * in which case the provisions of the GPL or the LGPL are applicable instead
- * of those above. If you wish to allow use of your version of this file only
- * under the terms of either the GPL or the LGPL, and not to allow others to
- * use your version of this file under the terms of the MPL, indicate your
- * decision by deleting the provisions above and replace them with the notice
- * and other provisions required by the GPL or the LGPL. If you do not delete
- * the provisions above, a recipient may use your version of this file under
- * the terms of any one of the MPL, the GPL or the LGPL.
- *
- * ***** END LICENSE BLOCK ***** */
-
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
 import org.glyptodon.guacamole.GuacamoleException;
 import org.glyptodon.guacamole.GuacamoleSecurityException;
-import org.glyptodon.guacamole.net.GuacamoleSocket;
+import org.glyptodon.guacamole.net.GuacamoleTunnel;
 import org.glyptodon.guacamole.net.auth.AbstractConnectionGroup;
-import org.glyptodon.guacamole.net.auth.Connection;
 import org.glyptodon.guacamole.net.auth.ConnectionGroup;
-import org.glyptodon.guacamole.net.auth.Directory;
 import org.glyptodon.guacamole.protocol.GuacamoleClientInformation;
 
-
 /**
  * An extremely simple read-only implementation of a ConnectionGroup which
- * returns the connection and connection group directories it was constructed
+ * returns the connection and connection group identifiers it was constructed
  * with. Load balancing across this connection group is not allowed.
  * 
  * @author James Muehlner
@@ -57,31 +44,34 @@ import org.glyptodon.guacamole.protocol.GuacamoleClientInformation;
 public class SimpleConnectionGroup extends AbstractConnectionGroup {
 
     /**
-     * Underlying connection directory, containing all connections within this
-     * group.
+     * The identifiers of all connections in this group.
      */
-    private final Directory<String, Connection> connectionDirectory;
+    private final Set<String> connectionIdentifiers;
 
     /**
-     * Underlying connection group directory, containing all connections within
-     * this group.
+     * The identifiers of all connection groups in this group.
      */
-    private final Directory<String, ConnectionGroup> connectionGroupDirectory;
+    private final Set<String> connectionGroupIdentifiers;
     
     /**
      * Creates a new SimpleConnectionGroup having the given name and identifier
-     * which will expose the given directories as its contents.
+     * which will expose the given contents.
      * 
-     * @param name The name to associate with this connection.
-     * @param identifier The identifier to associate with this connection.
-     * @param connectionDirectory The connection directory to expose when
-     *                            requested.
-     * @param connectionGroupDirectory The connection group directory to expose
-     *                                 when requested.
+     * @param name
+     *     The name to associate with this connection group.
+     *
+     * @param identifier
+     *     The identifier to associate with this connection group.
+     *
+     * @param connectionIdentifiers
+     *     The connection identifiers to expose when requested.
+     *
+     * @param connectionGroupIdentifiers
+     *     The connection group identifiers to expose when requested.
      */
     public SimpleConnectionGroup(String name, String identifier,
-            Directory<String, Connection> connectionDirectory, 
-            Directory<String, ConnectionGroup> connectionGroupDirectory) {
+            Collection<String> connectionIdentifiers, 
+            Collection<String> connectionGroupIdentifiers) {
 
         // Set name
         setName(name);
@@ -92,26 +82,39 @@ public class SimpleConnectionGroup extends AbstractConnectionGroup {
         // Set group type
         setType(ConnectionGroup.Type.ORGANIZATIONAL);
 
-        // Assign directories
-        this.connectionDirectory = connectionDirectory;
-        this.connectionGroupDirectory = connectionGroupDirectory;
+        // Populate contents
+        this.connectionIdentifiers = new HashSet<String>(connectionIdentifiers);
+        this.connectionGroupIdentifiers = new HashSet<String>(connectionGroupIdentifiers);
 
     }
-    
+
     @Override
-    public Directory<String, Connection> getConnectionDirectory() 
-            throws GuacamoleException {
-        return connectionDirectory;
+    public int getActiveConnections() {
+        return 0;
     }
 
     @Override
-    public Directory<String, ConnectionGroup> getConnectionGroupDirectory() 
-            throws GuacamoleException {
-        return connectionGroupDirectory;
+    public Set<String> getConnectionIdentifiers() {
+        return connectionIdentifiers;
+    }
+
+    @Override
+    public Set<String> getConnectionGroupIdentifiers() {
+        return connectionGroupIdentifiers;
+    }
+
+    @Override
+    public Map<String, String> getAttributes() {
+        return Collections.<String, String>emptyMap();
+    }
+
+    @Override
+    public void setAttributes(Map<String, String> attributes) {
+        // Do nothing - there are no attributes
     }
 
     @Override
-    public GuacamoleSocket connect(GuacamoleClientInformation info) 
+    public GuacamoleTunnel connect(GuacamoleClientInformation info) 
             throws GuacamoleException {
         throw new GuacamoleSecurityException("Permission denied.");
     }
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/simple/SimpleConnectionGroupDirectory.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/simple/SimpleConnectionGroupDirectory.java
index ce46428..8e0a6d6 100644
--- a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/simple/SimpleConnectionGroupDirectory.java
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/simple/SimpleConnectionGroupDirectory.java
@@ -1,51 +1,31 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ * 
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ * 
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ * 
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
 
 package org.glyptodon.guacamole.net.auth.simple;
 
-/* ***** BEGIN LICENSE BLOCK *****
- * Version: MPL 1.1/GPL 2.0/LGPL 2.1
- *
- * The contents of this file are subject to the Mozilla Public License Version
- * 1.1 (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.mozilla.org/MPL/
- *
- * Software distributed under the License is distributed on an "AS IS" basis,
- * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- * for the specific language governing rights and limitations under the
- * License.
- *
- * The Original Code is guacamole-auth.
- *
- * The Initial Developer of the Original Code is
- * Michael Jumper.
- * Portions created by the Initial Developer are Copyright (C) 2010
- * the Initial Developer. All Rights Reserved.
- *
- * Contributor(s): James Muehlner
- *
- * Alternatively, the contents of this file may be used under the terms of
- * either the GNU General Public License Version 2 or later (the "GPL"), or
- * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
- * in which case the provisions of the GPL or the LGPL are applicable instead
- * of those above. If you wish to allow use of your version of this file only
- * under the terms of either the GPL or the LGPL, and not to allow others to
- * use your version of this file under the terms of the MPL, indicate your
- * decision by deleting the provisions above and replace them with the notice
- * and other provisions required by the GPL or the LGPL. If you do not delete
- * the provisions above, a recipient may use your version of this file under
- * the terms of any one of the MPL, the GPL or the LGPL.
- *
- * ***** END LICENSE BLOCK ***** */
-
 import java.util.Collection;
 import java.util.HashMap;
 import java.util.Map;
-import java.util.Set;
-import org.glyptodon.guacamole.GuacamoleException;
-import org.glyptodon.guacamole.GuacamoleSecurityException;
 import org.glyptodon.guacamole.net.auth.ConnectionGroup;
-import org.glyptodon.guacamole.net.auth.Directory;
-
 
 /**
  * An extremely simple read-only implementation of a Directory of
@@ -55,18 +35,18 @@ import org.glyptodon.guacamole.net.auth.Directory;
  * @author James Muehlner
  */
 public class SimpleConnectionGroupDirectory
-    implements Directory<String, ConnectionGroup> {
+    extends SimpleDirectory<ConnectionGroup> {
 
     /**
      * The Map of ConnectionGroups to provide access to.
      */
-    private Map<String, ConnectionGroup> connectionGroups =
+    private final Map<String, ConnectionGroup> connectionGroups =
             new HashMap<String, ConnectionGroup>();
 
     /**
      * Creates a new SimpleConnectionGroupDirectory which contains the given
      * groups.
-     * 
+     *
      * @param groups A Collection of all groups that should be present in this
      *               connection group directory.
      */
@@ -76,46 +56,15 @@ public class SimpleConnectionGroupDirectory
         for (ConnectionGroup group : groups)
             connectionGroups.put(group.getIdentifier(), group);
 
-    }
-
-    @Override
-    public ConnectionGroup get(String identifier)
-            throws GuacamoleException {
-        return connectionGroups.get(identifier);
-    }
-
-    @Override
-    public Set<String> getIdentifiers() throws GuacamoleException {
-        return connectionGroups.keySet();
-    }
-
-    @Override
-    public void add(ConnectionGroup connectionGroup)
-            throws GuacamoleException {
-        throw new GuacamoleSecurityException("Permission denied.");
-    }
+        // Use the connection group map to back the underlying AbstractDirectory
+        super.setObjects(connectionGroups);
 
-    @Override
-    public void update(ConnectionGroup connectionGroup)
-            throws GuacamoleException {
-        throw new GuacamoleSecurityException("Permission denied.");
-    }
-
-    @Override
-    public void remove(String identifier) throws GuacamoleException {
-        throw new GuacamoleSecurityException("Permission denied.");
-    }
-
-    @Override
-    public void move(String identifier, Directory<String, ConnectionGroup> directory) 
-            throws GuacamoleException {
-        throw new GuacamoleSecurityException("Permission denied.");
     }
 
     /**
      * An internal method for modifying the ConnectionGroups in this Directory.
      * Returns the previous connection group for the given identifier, if found.
-     * 
+     *
      * @param connectionGroup The connection group to add or update the
      *                        Directory with.
      * @return The previous connection group for the connection group
@@ -124,10 +73,10 @@ public class SimpleConnectionGroupDirectory
     public ConnectionGroup putConnectionGroup(ConnectionGroup connectionGroup) {
         return connectionGroups.put(connectionGroup.getIdentifier(), connectionGroup);
     }
-    
+
     /**
      * An internal method for removing a ConnectionGroup from this Directory.
-     * 
+     *
      * @param identifier The identifier of the ConnectionGroup to remove.
      * @return The previous connection group for the given identifier, if found.
      */
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/simple/SimpleConnectionRecordSet.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/simple/SimpleConnectionRecordSet.java
new file mode 100644
index 0000000..28be395
--- /dev/null
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/simple/SimpleConnectionRecordSet.java
@@ -0,0 +1,62 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.auth.simple;
+
+import java.util.Collection;
+import java.util.Collections;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.net.auth.ConnectionRecord;
+import org.glyptodon.guacamole.net.auth.ConnectionRecordSet;
+
+/**
+ * An immutable and empty ConnectionRecordSet.
+ *
+ * @author Michael Jumper
+ */
+public class SimpleConnectionRecordSet implements ConnectionRecordSet {
+
+    @Override
+    public Collection<ConnectionRecord> asCollection()
+            throws GuacamoleException {
+        return Collections.<ConnectionRecord>emptyList();
+    }
+
+    @Override
+    public ConnectionRecordSet contains(String value)
+            throws GuacamoleException {
+        return this;
+    }
+
+    @Override
+    public ConnectionRecordSet limit(int limit)
+            throws GuacamoleException {
+        return this;
+    }
+
+    @Override
+    public ConnectionRecordSet sort(SortableProperty property, boolean desc)
+            throws GuacamoleException {
+        return this;
+    }
+
+}
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/simple/SimpleDirectory.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/simple/SimpleDirectory.java
new file mode 100644
index 0000000..f688350
--- /dev/null
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/simple/SimpleDirectory.java
@@ -0,0 +1,143 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.auth.simple;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Map;
+import java.util.Set;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.GuacamoleSecurityException;
+import org.glyptodon.guacamole.net.auth.Directory;
+import org.glyptodon.guacamole.net.auth.Identifiable;
+
+/**
+ * An extremely simple read-only implementation of a Directory which provides
+ * access to a pre-defined Map of arbitrary objects. Any changes to the Map
+ * will affect the available contents of this SimpleDirectory.
+ *
+ * @author Michael Jumper
+ * @param <ObjectType>
+ *     The type of objects stored within this SimpleDirectory.
+ */
+public class SimpleDirectory<ObjectType extends Identifiable>
+        implements Directory<ObjectType> {
+
+    /**
+     * The Map of objects to provide access to.
+     */
+    private Map<String, ObjectType> objects = Collections.<String, ObjectType>emptyMap();
+
+    /**
+     * Creates a new empty SimpleDirectory which does not provide access to
+     * any objects.
+     */
+    public SimpleDirectory() {
+    }
+
+    /**
+     * Creates a new SimpleDirectory which provides access to the objects
+     * contained within the given Map.
+     *
+     * @param objects
+     *     The Map of objects to provide access to.
+     */
+    public SimpleDirectory(Map<String, ObjectType> objects) {
+        this.objects = objects;
+    }
+
+    /**
+     * Sets the Map which backs this SimpleDirectory. Future function calls
+     * which retrieve objects from this SimpleDirectory will use the provided
+     * Map.
+     *
+     * @param objects
+     *     The Map of objects to provide access to.
+     */
+    protected void setObjects(Map<String, ObjectType> objects) {
+        this.objects = objects;
+    }
+
+    /**
+     * Returns the Map which currently backs this SimpleDirectory. Changes to
+     * this Map will affect future function calls that retrieve objects from
+     * this SimpleDirectory.
+     *
+     * @return
+     *     The Map of objects which currently backs this SimpleDirectory.
+     */
+    protected Map<String, ObjectType> getObjects() {
+        return objects;
+    }
+
+    @Override
+    public ObjectType get(String identifier)
+            throws GuacamoleException {
+        return objects.get(identifier);
+    }
+
+    @Override
+    public Collection<ObjectType> getAll(Collection<String> identifiers)
+            throws GuacamoleException {
+
+        // Create collection which has an appropriate initial size
+        Collection<ObjectType> foundObjects = new ArrayList<ObjectType>(identifiers.size());
+
+        // Populate collection with matching objects
+        for (String identifier : identifiers) {
+
+            // Add the object which has the current identifier, if any
+            ObjectType object = objects.get(identifier);
+            if (object != null)
+                foundObjects.add(object);
+
+        }
+
+        return foundObjects;
+
+    }
+
+    @Override
+    public Set<String> getIdentifiers() throws GuacamoleException {
+        return objects.keySet();
+    }
+
+    @Override
+    public void add(ObjectType connection)
+            throws GuacamoleException {
+        throw new GuacamoleSecurityException("Permission denied.");
+    }
+
+    @Override
+    public void update(ObjectType connection)
+            throws GuacamoleException {
+        throw new GuacamoleSecurityException("Permission denied.");
+    }
+
+    @Override
+    public void remove(String identifier) throws GuacamoleException {
+        throw new GuacamoleSecurityException("Permission denied.");
+    }
+
+}
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/simple/SimpleObjectPermissionSet.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/simple/SimpleObjectPermissionSet.java
new file mode 100644
index 0000000..b884263
--- /dev/null
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/simple/SimpleObjectPermissionSet.java
@@ -0,0 +1,142 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.auth.simple;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Set;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.GuacamoleSecurityException;
+import org.glyptodon.guacamole.net.auth.permission.ObjectPermission;
+import org.glyptodon.guacamole.net.auth.permission.ObjectPermissionSet;
+
+/**
+ * A read-only implementation of ObjectPermissionSet which uses a backing Set
+ * of Permissions to determine which permissions are present.
+ *
+ * @author Michael Jumper
+ */
+public class SimpleObjectPermissionSet implements ObjectPermissionSet {
+
+    /**
+     * The set of all permissions currently granted.
+     */
+    private Set<ObjectPermission> permissions = Collections.<ObjectPermission>emptySet();
+
+    /**
+     * Creates a new empty SimpleObjectPermissionSet.
+     */
+    public SimpleObjectPermissionSet() {
+    }
+
+    /**
+     * Creates a new SimpleObjectPermissionSet which contains the permissions
+     * within the given Set.
+     *
+     * @param permissions 
+     *     The Set of permissions this SimpleObjectPermissionSet should
+     *     contain.
+     */
+    public SimpleObjectPermissionSet(Set<ObjectPermission> permissions) {
+        this.permissions = permissions;
+    }
+
+    /**
+     * Sets the Set which backs this SimpleObjectPermissionSet. Future function
+     * calls on this SimpleObjectPermissionSet will use the provided Set.
+     *
+     * @param permissions 
+     *     The Set of permissions this SimpleObjectPermissionSet should
+     *     contain.
+     */
+    protected void setPermissions(Set<ObjectPermission> permissions) {
+        this.permissions = permissions;
+    }
+
+    @Override
+    public Set<ObjectPermission> getPermissions() {
+        return permissions;
+    }
+
+    @Override
+    public boolean hasPermission(ObjectPermission.Type permission,
+            String identifier) throws GuacamoleException {
+
+        ObjectPermission objectPermission =
+                new ObjectPermission(permission, identifier);
+        
+        return permissions.contains(objectPermission);
+
+    }
+
+    @Override
+    public void addPermission(ObjectPermission.Type permission,
+            String identifier) throws GuacamoleException {
+        throw new GuacamoleSecurityException("Permission denied.");
+    }
+
+    @Override
+    public void removePermission(ObjectPermission.Type permission,
+            String identifier) throws GuacamoleException {
+        throw new GuacamoleSecurityException("Permission denied.");
+    }
+
+    @Override
+    public Collection<String> getAccessibleObjects(
+            Collection<ObjectPermission.Type> permissionTypes,
+            Collection<String> identifiers) throws GuacamoleException {
+
+        Collection<String> accessibleObjects = new ArrayList<String>(permissions.size());
+
+        // For each identifier/permission combination
+        for (String identifier : identifiers) {
+            for (ObjectPermission.Type permissionType : permissionTypes) {
+
+                // Add identifier if at least one requested permission is granted
+                ObjectPermission permission = new ObjectPermission(permissionType, identifier);
+                if (permissions.contains(permission)) {
+                    accessibleObjects.add(identifier);
+                    break;
+                }
+
+            }
+        }
+
+        return accessibleObjects;
+        
+    }
+
+    @Override
+    public void addPermissions(Set<ObjectPermission> permissions)
+            throws GuacamoleException {
+        throw new GuacamoleSecurityException("Permission denied.");
+    }
+
+    @Override
+    public void removePermissions(Set<ObjectPermission> permissions)
+            throws GuacamoleException {
+        throw new GuacamoleSecurityException("Permission denied.");
+    }
+
+}
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/simple/SimpleSystemPermissionSet.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/simple/SimpleSystemPermissionSet.java
new file mode 100644
index 0000000..a83a5d1
--- /dev/null
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/simple/SimpleSystemPermissionSet.java
@@ -0,0 +1,113 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.auth.simple;
+
+import java.util.Collections;
+import java.util.Set;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.GuacamoleSecurityException;
+import org.glyptodon.guacamole.net.auth.permission.SystemPermission;
+import org.glyptodon.guacamole.net.auth.permission.SystemPermissionSet;
+
+/**
+ * A read-only implementation of SystemPermissionSet which uses a backing Set
+ * of Permissions to determine which permissions are present.
+ *
+ * @author Michael Jumper
+ */
+public class SimpleSystemPermissionSet implements SystemPermissionSet {
+
+    /**
+     * The set of all permissions currently granted.
+     */
+    private Set<SystemPermission> permissions = Collections.<SystemPermission>emptySet();
+
+    /**
+     * Creates a new empty SimpleSystemPermissionSet.
+     */
+    public SimpleSystemPermissionSet() {
+    }
+
+    /**
+     * Creates a new SimpleSystemPermissionSet which contains the permissions
+     * within the given Set.
+     *
+     * @param permissions 
+     *     The Set of permissions this SimpleSystemPermissionSet should
+     *     contain.
+     */
+    public SimpleSystemPermissionSet(Set<SystemPermission> permissions) {
+        this.permissions = permissions;
+    }
+
+    /**
+     * Sets the Set which backs this SimpleSystemPermissionSet. Future function
+     * calls on this SimpleSystemPermissionSet will use the provided Set.
+     *
+     * @param permissions 
+     *     The Set of permissions this SimpleSystemPermissionSet should
+     *     contain.
+     */
+    protected void setPermissions(Set<SystemPermission> permissions) {
+        this.permissions = permissions;
+    }
+
+    @Override
+    public Set<SystemPermission> getPermissions() {
+        return permissions;
+    }
+
+    @Override
+    public boolean hasPermission(SystemPermission.Type permission)
+            throws GuacamoleException {
+
+        SystemPermission systemPermission = new SystemPermission(permission);
+        return permissions.contains(systemPermission);
+
+    }
+
+    @Override
+    public void addPermission(SystemPermission.Type permission)
+            throws GuacamoleException {
+        throw new GuacamoleSecurityException("Permission denied.");
+    }
+
+    @Override
+    public void removePermission(SystemPermission.Type permission)
+            throws GuacamoleException {
+        throw new GuacamoleSecurityException("Permission denied.");
+    }
+
+    @Override
+    public void addPermissions(Set<SystemPermission> permissions)
+            throws GuacamoleException {
+        throw new GuacamoleSecurityException("Permission denied.");
+    }
+
+    @Override
+    public void removePermissions(Set<SystemPermission> permissions)
+            throws GuacamoleException {
+        throw new GuacamoleSecurityException("Permission denied.");
+    }
+
+}
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/simple/SimpleUser.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/simple/SimpleUser.java
index 4568e0f..046458a 100644
--- a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/simple/SimpleUser.java
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/simple/SimpleUser.java
@@ -1,56 +1,37 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ * 
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ * 
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ * 
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
 
 package org.glyptodon.guacamole.net.auth.simple;
 
-/* ***** BEGIN LICENSE BLOCK *****
- * Version: MPL 1.1/GPL 2.0/LGPL 2.1
- *
- * The contents of this file are subject to the Mozilla Public License Version
- * 1.1 (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.mozilla.org/MPL/
- *
- * Software distributed under the License is distributed on an "AS IS" basis,
- * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- * for the specific language governing rights and limitations under the
- * License.
- *
- * The Original Code is guacamole-auth.
- *
- * The Initial Developer of the Original Code is
- * Michael Jumper.
- * Portions created by the Initial Developer are Copyright (C) 2010
- * the Initial Developer. All Rights Reserved.
- *
- * Contributor(s):
- *
- * Alternatively, the contents of this file may be used under the terms of
- * either the GNU General Public License Version 2 or later (the "GPL"), or
- * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
- * in which case the provisions of the GPL or the LGPL are applicable instead
- * of those above. If you wish to allow use of your version of this file only
- * under the terms of either the GPL or the LGPL, and not to allow others to
- * use your version of this file under the terms of the MPL, indicate your
- * decision by deleting the provisions above and replace them with the notice
- * and other provisions required by the GPL or the LGPL. If you do not delete
- * the provisions above, a recipient may use your version of this file under
- * the terms of any one of the MPL, the GPL or the LGPL.
- *
- * ***** END LICENSE BLOCK ***** */
-
 import java.util.Collection;
+import java.util.Collections;
 import java.util.HashSet;
 import java.util.Map;
 import java.util.Set;
 import org.glyptodon.guacamole.GuacamoleException;
-import org.glyptodon.guacamole.GuacamoleSecurityException;
 import org.glyptodon.guacamole.net.auth.AbstractUser;
-import org.glyptodon.guacamole.net.auth.ConnectionGroup;
-import org.glyptodon.guacamole.net.auth.permission.ConnectionGroupPermission;
-import org.glyptodon.guacamole.net.auth.permission.ConnectionPermission;
 import org.glyptodon.guacamole.net.auth.permission.ObjectPermission;
-import org.glyptodon.guacamole.net.auth.permission.Permission;
-import org.glyptodon.guacamole.protocol.GuacamoleConfiguration;
-
+import org.glyptodon.guacamole.net.auth.permission.ObjectPermissionSet;
+import org.glyptodon.guacamole.net.auth.permission.SystemPermissionSet;
 
 /**
  * An extremely basic User implementation.
@@ -60,9 +41,22 @@ import org.glyptodon.guacamole.protocol.GuacamoleConfiguration;
 public class SimpleUser extends AbstractUser {
 
     /**
-     * The set of all permissions available to this user.
+     * All connection permissions granted to this user.
+     */
+    private final Set<ObjectPermission> userPermissions =
+            new HashSet<ObjectPermission>();
+
+    /**
+     * All connection permissions granted to this user.
      */
-    private Set<Permission> permissions = new HashSet<Permission>();
+    private final Set<ObjectPermission> connectionPermissions =
+            new HashSet<ObjectPermission>();
+    
+    /**
+     * All connection group permissions granted to this user.
+     */
+    private final Set<ObjectPermission> connectionGroupPermissions =
+            new HashSet<ObjectPermission>();
 
     /**
      * Creates a completely uninitialized SimpleUser.
@@ -71,67 +65,137 @@ public class SimpleUser extends AbstractUser {
     }
 
     /**
-     * Creates a new SimpleUser having the given username.
+     * Creates a new SimpleUser having the given username and no permissions.
      *
-     * @param username The username to assign to this SimpleUser.
-     * @param configs All configurations this user has read access to.
-     * @param groups All groups this user has read access to.
+     * @param username
+     *     The username to assign to this SimpleUser.
      */
-    public SimpleUser(String username,
-            Map<String, GuacamoleConfiguration> configs,
-            Collection<ConnectionGroup> groups) {
+    public SimpleUser(String username) {
 
         // Set username
-        setUsername(username);
+        setIdentifier(username);
 
-        // Add connection permissions
-        for (String identifier : configs.keySet()) {
+    }
 
-            // Create permission
-            Permission permission = new ConnectionPermission(
+    /**
+     * Adds a new READ permission to the given set of permissions for each of
+     * the given identifiers.
+     *
+     * @param permissions
+     *     The set of permissions to add READ permissions to.
+     *
+     * @param identifiers
+     *     The identifiers which should each have a corresponding READ
+     *     permission added to the given set.
+     */
+    private void addReadPermissions(Set<ObjectPermission> permissions,
+            Collection<String> identifiers) {
+
+        // Add a READ permission to the set for each identifier given
+        for (String identifier : identifiers) {
+            permissions.add(new ObjectPermission (
                 ObjectPermission.Type.READ,
                 identifier
-            );
+            ));
+        }
 
-            // Add to set
-            permissions.add(permission);
+    }
+    
+    /**
+     * Creates a new SimpleUser having the given username and READ access to
+     * the connections and groups having the given identifiers.
+     *
+     * @param username
+     *     The username to assign to this SimpleUser.
+     *
+     * @param connectionIdentifiers
+     *     The identifiers of all connections this user has READ access to.
+     *
+     * @param connectionGroupIdentifiers
+     *     The identifiers of all connection groups this user has READ access
+     *     to.
+     */
+    public SimpleUser(String username,
+            Collection<String> connectionIdentifiers,
+            Collection<String> connectionGroupIdentifiers) {
 
-        }
+        this(username);
 
-        // Add group permissions
-        for (ConnectionGroup group : groups) {
+        // Add permissions
+        addReadPermissions(connectionPermissions,      connectionIdentifiers);
+        addReadPermissions(connectionGroupPermissions, connectionGroupIdentifiers);
 
-            // Create permission
-            Permission permission = new ConnectionGroupPermission(
-                ObjectPermission.Type.READ,
-                group.getIdentifier()
-            );
+    }
 
-            // Add to set
-            permissions.add(permission);
+    /**
+     * Creates a new SimpleUser having the given username and READ access to
+     * the users, connections, and groups having the given identifiers.
+     *
+     * @param username
+     *     The username to assign to this SimpleUser.
+     *
+     * @param userIdentifiers
+     *     The identifiers of all users this user has READ access to.
+     *
+     * @param connectionIdentifiers
+     *     The identifiers of all connections this user has READ access to.
+     *
+     * @param connectionGroupIdentifiers
+     *     The identifiers of all connection groups this user has READ access
+     *     to.
+     */
+    public SimpleUser(String username,
+            Collection<String> userIdentifiers,
+            Collection<String> connectionIdentifiers,
+            Collection<String> connectionGroupIdentifiers) {
 
-        }
+        this(username);
+
+        // Add permissions
+        addReadPermissions(userPermissions,            userIdentifiers);
+        addReadPermissions(connectionPermissions,      connectionIdentifiers);
+        addReadPermissions(connectionGroupPermissions, connectionGroupIdentifiers);
+
+    }
+
+    @Override
+    public Map<String, String> getAttributes() {
+        return Collections.<String, String>emptyMap();
+    }
+
+    @Override
+    public void setAttributes(Map<String, String> attributes) {
+        // Do nothing - there are no attributes
+    }
 
+    @Override
+    public SystemPermissionSet getSystemPermissions()
+            throws GuacamoleException {
+        return new SimpleSystemPermissionSet();
     }
 
     @Override
-    public Set<Permission> getPermissions() throws GuacamoleException {
-        return permissions;
+    public ObjectPermissionSet getConnectionPermissions()
+            throws GuacamoleException {
+        return new SimpleObjectPermissionSet(connectionPermissions);
     }
 
     @Override
-    public boolean hasPermission(Permission permission) throws GuacamoleException {
-        return permissions.contains(permission);
+    public ObjectPermissionSet getConnectionGroupPermissions()
+            throws GuacamoleException {
+        return new SimpleObjectPermissionSet(connectionGroupPermissions);
     }
 
     @Override
-    public void addPermission(Permission permission) throws GuacamoleException {
-        throw new GuacamoleSecurityException("Permission denied.");
+    public ObjectPermissionSet getUserPermissions()
+            throws GuacamoleException {
+        return new SimpleObjectPermissionSet(userPermissions);
     }
 
     @Override
-    public void removePermission(Permission permission) throws GuacamoleException {
-        throw new GuacamoleSecurityException("Permission denied.");
+    public ObjectPermissionSet getActiveConnectionPermissions()
+            throws GuacamoleException {
+        return new SimpleObjectPermissionSet();
     }
 
 }
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/simple/SimpleUserContext.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/simple/SimpleUserContext.java
index dd4d0dd..a47fd0b 100644
--- a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/simple/SimpleUserContext.java
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/simple/SimpleUserContext.java
@@ -1,46 +1,39 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ * 
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ * 
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ * 
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
 
 package org.glyptodon.guacamole.net.auth.simple;
 
-/* ***** BEGIN LICENSE BLOCK *****
- * Version: MPL 1.1/GPL 2.0/LGPL 2.1
- *
- * The contents of this file are subject to the Mozilla Public License Version
- * 1.1 (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.mozilla.org/MPL/
- *
- * Software distributed under the License is distributed on an "AS IS" basis,
- * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- * for the specific language governing rights and limitations under the
- * License.
- *
- * The Original Code is guacamole-ext.
- *
- * The Initial Developer of the Original Code is
- * Michael Jumper.
- * Portions created by the Initial Developer are Copyright (C) 2010
- * the Initial Developer. All Rights Reserved.
- *
- * Contributor(s):
- *
- * Alternatively, the contents of this file may be used under the terms of
- * either the GNU General Public License Version 2 or later (the "GPL"), or
- * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
- * in which case the provisions of the GPL or the LGPL are applicable instead
- * of those above. If you wish to allow use of your version of this file only
- * under the terms of either the GPL or the LGPL, and not to allow others to
- * use your version of this file under the terms of the MPL, indicate your
- * decision by deleting the provisions above and replace them with the notice
- * and other provisions required by the GPL or the LGPL. If you do not delete
- * the provisions above, a recipient may use your version of this file under
- * the terms of any one of the MPL, the GPL or the LGPL.
- *
- * ***** END LICENSE BLOCK ***** */
-
+import java.util.ArrayList;
+import java.util.Collection;
 import java.util.Collections;
 import java.util.Map;
+import java.util.UUID;
 import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.form.Form;
+import org.glyptodon.guacamole.net.auth.ActiveConnection;
+import org.glyptodon.guacamole.net.auth.AuthenticationProvider;
+import org.glyptodon.guacamole.net.auth.Connection;
 import org.glyptodon.guacamole.net.auth.ConnectionGroup;
+import org.glyptodon.guacamole.net.auth.ConnectionRecordSet;
 import org.glyptodon.guacamole.net.auth.Directory;
 import org.glyptodon.guacamole.net.auth.User;
 import org.glyptodon.guacamole.net.auth.UserContext;
@@ -56,6 +49,16 @@ import org.glyptodon.guacamole.protocol.GuacamoleConfiguration;
 public class SimpleUserContext implements UserContext {
 
     /**
+     * The unique identifier of the root connection group.
+     */
+    private static final String ROOT_IDENTIFIER = "ROOT";
+
+    /**
+     * The AuthenticationProvider that created this UserContext.
+     */
+    private final AuthenticationProvider authProvider;
+
+    /**
      * Reference to the user whose permissions dictate the configurations
      * accessible within this UserContext.
      */
@@ -65,35 +68,98 @@ public class SimpleUserContext implements UserContext {
      * The Directory with access only to the User associated with this
      * UserContext.
      */
-    private final Directory<String, User> userDirectory;
+    private final Directory<User> userDirectory;
 
     /**
-     * The ConnectionGroup with access only to those Connections that the User
-     * associated with this UserContext has access to.
+     * The Directory with access only to the root group associated with this
+     * UserContext.
      */
-    private final ConnectionGroup connectionGroup;
+    private final Directory<ConnectionGroup> connectionGroupDirectory;
+
+    /**
+     * The Directory with access to all connections within the root group
+     * associated with this UserContext.
+     */
+    private final Directory<Connection> connectionDirectory;
+
+    /**
+     * The root connection group.
+     */
+    private final ConnectionGroup rootGroup;
 
     /**
      * Creates a new SimpleUserContext which provides access to only those
-     * configurations within the given Map.
-     * 
-     * @param configs A Map of all configurations for which the user associated
-     *                with this UserContext has read access.
+     * configurations within the given Map. The username is assigned
+     * arbitrarily.
+     *
+     * @param authProvider
+     *     The AuthenticationProvider creating this UserContext.
+     *
+     * @param configs
+     *     A Map of all configurations for which the user associated with this
+     *     UserContext has read access.
      */
-    public SimpleUserContext(Map<String, GuacamoleConfiguration> configs) {
+    public SimpleUserContext(AuthenticationProvider authProvider,
+            Map<String, GuacamoleConfiguration> configs) {
+        this(authProvider, UUID.randomUUID().toString(), configs);
+    }
 
-        // Add root group that contains only configurations
-        this.connectionGroup = new SimpleConnectionGroup("ROOT", "ROOT",
-                new SimpleConnectionDirectory(configs),
-                new SimpleConnectionGroupDirectory(Collections.EMPTY_LIST));
+    /**
+     * Creates a new SimpleUserContext for the user with the given username
+     * which provides access to only those configurations within the given Map.
+     *
+     * @param authProvider
+     *     The AuthenticationProvider creating this UserContext.
+     *
+     * @param username
+     *     The username of the user associated with this UserContext.
+     *
+     * @param configs
+     *     A Map of all configurations for which the user associated with
+     *     this UserContext has read access.
+     */
+    public SimpleUserContext(AuthenticationProvider authProvider,
+            String username, Map<String, GuacamoleConfiguration> configs) {
+
+        Collection<String> connectionIdentifiers = new ArrayList<String>(configs.size());
+        Collection<String> connectionGroupIdentifiers = Collections.singleton(ROOT_IDENTIFIER);
+        
+        // Produce collection of connections from given configs
+        Collection<Connection> connections = new ArrayList<Connection>(configs.size());
+        for (Map.Entry<String, GuacamoleConfiguration> configEntry : configs.entrySet()) {
 
-        // Build new user from credentials, giving the user an arbitrary name
-        this.self = new SimpleUser("user",
-                configs, Collections.singleton(connectionGroup));
+            // Get connection identifier and configuration
+            String identifier = configEntry.getKey();
+            GuacamoleConfiguration config = configEntry.getValue();
 
-        // Create user directory for new user
-        this.userDirectory = new SimpleUserDirectory(self);
+            // Add as simple connection
+            Connection connection = new SimpleConnection(identifier, identifier, config);
+            connection.setParentIdentifier(ROOT_IDENTIFIER);
+            connections.add(connection);
+
+            // Add identifier to overall set of identifiers
+            connectionIdentifiers.add(identifier);
+            
+        }
         
+        // Add root group that contains only the given configurations
+        this.rootGroup = new SimpleConnectionGroup(
+            ROOT_IDENTIFIER, ROOT_IDENTIFIER,
+            connectionIdentifiers, Collections.<String>emptyList()
+        );
+
+        // Build new user from credentials
+        this.self = new SimpleUser(username, connectionIdentifiers,
+                connectionGroupIdentifiers);
+
+        // Create directories for new user
+        this.userDirectory = new SimpleUserDirectory(self);
+        this.connectionDirectory = new SimpleConnectionDirectory(connections);
+        this.connectionGroupDirectory = new SimpleConnectionGroupDirectory(Collections.singleton(this.rootGroup));
+
+        // Associate provided AuthenticationProvider
+        this.authProvider = authProvider;
+
     }
 
     @Override
@@ -102,14 +168,58 @@ public class SimpleUserContext implements UserContext {
     }
 
     @Override
-    public Directory<String, User> getUserDirectory()
+    public AuthenticationProvider getAuthenticationProvider() {
+        return authProvider;
+    }
+
+    @Override
+    public Directory<User> getUserDirectory()
             throws GuacamoleException {
         return userDirectory;
     }
 
     @Override
+    public Directory<Connection> getConnectionDirectory()
+            throws GuacamoleException {
+        return connectionDirectory;
+    }
+
+    @Override
+    public Directory<ConnectionGroup> getConnectionGroupDirectory()
+            throws GuacamoleException {
+        return connectionGroupDirectory;
+    }
+
+    @Override
     public ConnectionGroup getRootConnectionGroup() throws GuacamoleException {
-        return connectionGroup;
+        return rootGroup;
+    }
+
+    @Override
+    public Directory<ActiveConnection> getActiveConnectionDirectory()
+            throws GuacamoleException {
+        return new SimpleDirectory<ActiveConnection>();
+    }
+
+    @Override
+    public ConnectionRecordSet getConnectionHistory()
+            throws GuacamoleException {
+        return new SimpleConnectionRecordSet();
+    }
+
+    @Override
+    public Collection<Form> getUserAttributes() {
+        return Collections.<Form>emptyList();
+    }
+
+    @Override
+    public Collection<Form> getConnectionAttributes() {
+        return Collections.<Form>emptyList();
+    }
+
+    @Override
+    public Collection<Form> getConnectionGroupAttributes() {
+        return Collections.<Form>emptyList();
     }
 
 }
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/simple/SimpleUserDirectory.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/simple/SimpleUserDirectory.java
index 7937247..e44f189 100644
--- a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/simple/SimpleUserDirectory.java
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/simple/SimpleUserDirectory.java
@@ -1,62 +1,37 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ * 
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ * 
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ * 
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
 
 package org.glyptodon.guacamole.net.auth.simple;
 
-/* ***** BEGIN LICENSE BLOCK *****
- * Version: MPL 1.1/GPL 2.0/LGPL 2.1
- *
- * The contents of this file are subject to the Mozilla Public License Version
- * 1.1 (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.mozilla.org/MPL/
- *
- * Software distributed under the License is distributed on an "AS IS" basis,
- * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- * for the specific language governing rights and limitations under the
- * License.
- *
- * The Original Code is guacamole-auth.
- *
- * The Initial Developer of the Original Code is
- * Michael Jumper.
- * Portions created by the Initial Developer are Copyright (C) 2010
- * the Initial Developer. All Rights Reserved.
- *
- * Contributor(s):
- *
- * Alternatively, the contents of this file may be used under the terms of
- * either the GNU General Public License Version 2 or later (the "GPL"), or
- * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
- * in which case the provisions of the GPL or the LGPL are applicable instead
- * of those above. If you wish to allow use of your version of this file only
- * under the terms of either the GPL or the LGPL, and not to allow others to
- * use your version of this file under the terms of the MPL, indicate your
- * decision by deleting the provisions above and replace them with the notice
- * and other provisions required by the GPL or the LGPL. If you do not delete
- * the provisions above, a recipient may use your version of this file under
- * the terms of any one of the MPL, the GPL or the LGPL.
- *
- * ***** END LICENSE BLOCK ***** */
-
 import java.util.Collections;
-import java.util.Set;
-import org.glyptodon.guacamole.GuacamoleException;
-import org.glyptodon.guacamole.GuacamoleSecurityException;
-import org.glyptodon.guacamole.net.auth.Directory;
 import org.glyptodon.guacamole.net.auth.User;
 
-
 /**
  * An extremely simple read-only implementation of a Directory of Users which
  * provides access to a single pre-defined User.
  *
  * @author Michael Jumper
  */
-public class SimpleUserDirectory implements Directory<String, User> {
-
-    /**
-     * The only user to be contained within this directory.
-     */
-    private User user;
+public class SimpleUserDirectory extends SimpleDirectory<User> {
 
     /**
      * Creates a new SimpleUserDirectory which provides access to the single
@@ -65,45 +40,7 @@ public class SimpleUserDirectory implements Directory<String, User> {
      * @param user The user to provide access to.
      */
     public SimpleUserDirectory(User user) {
-        this.user = user;
-    }
-
-    @Override
-    public User get(String username) throws GuacamoleException {
-
-        // If username matches, return the user
-        if (user.getUsername().equals(username))
-            return user;
-
-        // Otherwise, not found
-        return null;
-
-    }
-
-    @Override
-    public Set<String> getIdentifiers() throws GuacamoleException {
-        return Collections.singleton(user.getUsername());
-    }
-
-    @Override
-    public void add(User user) throws GuacamoleException {
-        throw new GuacamoleSecurityException("Permission denied.");
-    }
-
-    @Override
-    public void update(User user) throws GuacamoleException {
-        throw new GuacamoleSecurityException("Permission denied.");
-    }
-
-    @Override
-    public void remove(String username) throws GuacamoleException {
-        throw new GuacamoleSecurityException("Permission denied.");
-    }
-
-    @Override
-    public void move(String identifier, Directory<String, User> directory) 
-            throws GuacamoleException {
-        throw new GuacamoleSecurityException("Permission denied.");
+        super(Collections.singletonMap(user.getIdentifier(), user));
     }
 
 }
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/simple/package-info.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/simple/package-info.java
index 3da06ac..c2e6496 100644
--- a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/simple/package-info.java
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/auth/simple/package-info.java
@@ -1,3 +1,24 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ * 
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ * 
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ * 
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
 
 /**
  * Provides a basic AuthenticationProvider base class that can be used to create
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/event/AuthenticationFailureEvent.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/event/AuthenticationFailureEvent.java
index ddd5202..6067d5e 100644
--- a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/event/AuthenticationFailureEvent.java
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/event/AuthenticationFailureEvent.java
@@ -1,3 +1,25 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ * 
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ * 
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ * 
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
 package org.glyptodon.guacamole.net.event;
 
 import org.glyptodon.guacamole.net.auth.Credentials;
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/event/AuthenticationSuccessEvent.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/event/AuthenticationSuccessEvent.java
index 5a989f7..08aa3b1 100644
--- a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/event/AuthenticationSuccessEvent.java
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/event/AuthenticationSuccessEvent.java
@@ -1,3 +1,25 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ * 
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ * 
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ * 
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
 package org.glyptodon.guacamole.net.event;
 
 import org.glyptodon.guacamole.net.auth.Credentials;
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/event/CredentialEvent.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/event/CredentialEvent.java
index b9c0b51..5ce87e7 100644
--- a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/event/CredentialEvent.java
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/event/CredentialEvent.java
@@ -1,3 +1,25 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ * 
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ * 
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ * 
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
 package org.glyptodon.guacamole.net.event;
 
 import org.glyptodon.guacamole.net.auth.Credentials;
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/event/TunnelCloseEvent.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/event/TunnelCloseEvent.java
index 36f0bab..55b0ad1 100644
--- a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/event/TunnelCloseEvent.java
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/event/TunnelCloseEvent.java
@@ -1,3 +1,25 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ * 
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ * 
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ * 
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
 package org.glyptodon.guacamole.net.event;
 
 import org.glyptodon.guacamole.net.GuacamoleTunnel;
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/event/TunnelConnectEvent.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/event/TunnelConnectEvent.java
index 43f1057..71f3aaa 100644
--- a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/event/TunnelConnectEvent.java
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/event/TunnelConnectEvent.java
@@ -1,3 +1,25 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ * 
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ * 
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ * 
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
 package org.glyptodon.guacamole.net.event;
 
 import org.glyptodon.guacamole.net.GuacamoleTunnel;
@@ -9,7 +31,6 @@ import org.glyptodon.guacamole.net.auth.UserContext;
  * being connected can be accessed through getTunnel(), and the UserContext
  * associated with the request which is connecting the tunnel can be retrieved
  * with getUserContext().
-
  *
  * @author Michael Jumper
  */
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/event/TunnelEvent.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/event/TunnelEvent.java
index cad9bac..2a583a7 100644
--- a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/event/TunnelEvent.java
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/event/TunnelEvent.java
@@ -1,3 +1,25 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ * 
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ * 
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ * 
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
 package org.glyptodon.guacamole.net.event;
 
 import org.glyptodon.guacamole.net.GuacamoleTunnel;
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/event/UserEvent.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/event/UserEvent.java
index f5983b1..f851efc 100644
--- a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/event/UserEvent.java
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/event/UserEvent.java
@@ -1,3 +1,25 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ * 
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ * 
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ * 
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
 package org.glyptodon.guacamole.net.event;
 
 import org.glyptodon.guacamole.net.auth.UserContext;
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/event/listener/AuthenticationFailureListener.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/event/listener/AuthenticationFailureListener.java
index 2d98682..2ce854e 100644
--- a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/event/listener/AuthenticationFailureListener.java
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/event/listener/AuthenticationFailureListener.java
@@ -1,3 +1,25 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ * 
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ * 
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ * 
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
 package org.glyptodon.guacamole.net.event.listener;
 
 import org.glyptodon.guacamole.GuacamoleException;
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/event/listener/AuthenticationSuccessListener.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/event/listener/AuthenticationSuccessListener.java
index 64330a3..70f76ec 100644
--- a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/event/listener/AuthenticationSuccessListener.java
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/event/listener/AuthenticationSuccessListener.java
@@ -1,3 +1,25 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ * 
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ * 
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ * 
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
 package org.glyptodon.guacamole.net.event.listener;
 
 import org.glyptodon.guacamole.GuacamoleException;
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/event/listener/TunnelCloseListener.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/event/listener/TunnelCloseListener.java
index ea2ff9b..9aa183d 100644
--- a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/event/listener/TunnelCloseListener.java
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/event/listener/TunnelCloseListener.java
@@ -1,3 +1,25 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ * 
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ * 
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ * 
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
 package org.glyptodon.guacamole.net.event.listener;
 
 import org.glyptodon.guacamole.GuacamoleException;
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/event/listener/TunnelConnectListener.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/event/listener/TunnelConnectListener.java
index 831cdb0..c31e601 100644
--- a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/event/listener/TunnelConnectListener.java
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/event/listener/TunnelConnectListener.java
@@ -1,3 +1,25 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ * 
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ * 
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ * 
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
 package org.glyptodon.guacamole.net.event.listener;
 
 import org.glyptodon.guacamole.GuacamoleException;
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/event/listener/package-info.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/event/listener/package-info.java
index 6122726..e5fc9db 100644
--- a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/event/listener/package-info.java
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/event/listener/package-info.java
@@ -1,3 +1,24 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ * 
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ * 
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ * 
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
 
 /**
  * Provides classes for hooking into various events that take place as
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/event/package-info.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/event/package-info.java
index b011016..f604cc4 100644
--- a/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/event/package-info.java
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/net/event/package-info.java
@@ -1,3 +1,24 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ * 
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ * 
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ * 
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
 
 /**
  * Provides classes for storing information about events that are
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/properties/BooleanGuacamoleProperty.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/properties/BooleanGuacamoleProperty.java
index 829e44b..0015bc4 100644
--- a/guacamole-ext/src/main/java/org/glyptodon/guacamole/properties/BooleanGuacamoleProperty.java
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/properties/BooleanGuacamoleProperty.java
@@ -1,42 +1,27 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ * 
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ * 
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ * 
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
 
 package org.glyptodon.guacamole.properties;
 
-/* ***** BEGIN LICENSE BLOCK *****
- * Version: MPL 1.1/GPL 2.0/LGPL 2.1
- *
- * The contents of this file are subject to the Mozilla Public License Version
- * 1.1 (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.mozilla.org/MPL/
- *
- * Software distributed under the License is distributed on an "AS IS" basis,
- * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- * for the specific language governing rights and limitations under the
- * License.
- *
- * The Original Code is guacamole-ext.
- *
- * The Initial Developer of the Original Code is
- * Michael Jumper.
- * Portions created by the Initial Developer are Copyright (C) 2010
- * the Initial Developer. All Rights Reserved.
- *
- * Contributor(s):
- *
- * Alternatively, the contents of this file may be used under the terms of
- * either the GNU General Public License Version 2 or later (the "GPL"), or
- * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
- * in which case the provisions of the GPL or the LGPL are applicable instead
- * of those above. If you wish to allow use of your version of this file only
- * under the terms of either the GPL or the LGPL, and not to allow others to
- * use your version of this file under the terms of the MPL, indicate your
- * decision by deleting the provisions above and replace them with the notice
- * and other provisions required by the GPL or the LGPL. If you do not delete
- * the provisions above, a recipient may use your version of this file under
- * the terms of any one of the MPL, the GPL or the LGPL.
- *
- * ***** END LICENSE BLOCK ***** */
-
 import org.glyptodon.guacamole.GuacamoleException;
 import org.glyptodon.guacamole.GuacamoleServerException;
 
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/properties/FileGuacamoleProperty.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/properties/FileGuacamoleProperty.java
index aa7563e..416dc33 100644
--- a/guacamole-ext/src/main/java/org/glyptodon/guacamole/properties/FileGuacamoleProperty.java
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/properties/FileGuacamoleProperty.java
@@ -1,42 +1,27 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ * 
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ * 
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ * 
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
 
 package org.glyptodon.guacamole.properties;
 
-/* ***** BEGIN LICENSE BLOCK *****
- * Version: MPL 1.1/GPL 2.0/LGPL 2.1
- *
- * The contents of this file are subject to the Mozilla Public License Version
- * 1.1 (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.mozilla.org/MPL/
- *
- * Software distributed under the License is distributed on an "AS IS" basis,
- * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- * for the specific language governing rights and limitations under the
- * License.
- *
- * The Original Code is guacamole-ext.
- *
- * The Initial Developer of the Original Code is
- * Michael Jumper.
- * Portions created by the Initial Developer are Copyright (C) 2010
- * the Initial Developer. All Rights Reserved.
- *
- * Contributor(s):
- *
- * Alternatively, the contents of this file may be used under the terms of
- * either the GNU General Public License Version 2 or later (the "GPL"), or
- * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
- * in which case the provisions of the GPL or the LGPL are applicable instead
- * of those above. If you wish to allow use of your version of this file only
- * under the terms of either the GPL or the LGPL, and not to allow others to
- * use your version of this file under the terms of the MPL, indicate your
- * decision by deleting the provisions above and replace them with the notice
- * and other provisions required by the GPL or the LGPL. If you do not delete
- * the provisions above, a recipient may use your version of this file under
- * the terms of any one of the MPL, the GPL or the LGPL.
- *
- * ***** END LICENSE BLOCK ***** */
-
 import java.io.File;
 import org.glyptodon.guacamole.GuacamoleException;
 
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/properties/GuacamoleHome.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/properties/GuacamoleHome.java
index 438316e..b629183 100644
--- a/guacamole-ext/src/main/java/org/glyptodon/guacamole/properties/GuacamoleHome.java
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/properties/GuacamoleHome.java
@@ -1,52 +1,50 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ * 
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ * 
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ * 
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
 
 package org.glyptodon.guacamole.properties;
 
-/* ***** BEGIN LICENSE BLOCK *****
- * Version: MPL 1.1/GPL 2.0/LGPL 2.1
- *
- * The contents of this file are subject to the Mozilla Public License Version
- * 1.1 (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.mozilla.org/MPL/
- *
- * Software distributed under the License is distributed on an "AS IS" basis,
- * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- * for the specific language governing rights and limitations under the
- * License.
- *
- * The Original Code is guacamole-ext.
- *
- * The Initial Developer of the Original Code is
- * Michael Jumper.
- * Portions created by the Initial Developer are Copyright (C) 2010
- * the Initial Developer. All Rights Reserved.
- *
- * Contributor(s):
- *
- * Alternatively, the contents of this file may be used under the terms of
- * either the GNU General Public License Version 2 or later (the "GPL"), or
- * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
- * in which case the provisions of the GPL or the LGPL are applicable instead
- * of those above. If you wish to allow use of your version of this file only
- * under the terms of either the GPL or the LGPL, and not to allow others to
- * use your version of this file under the terms of the MPL, indicate your
- * decision by deleting the provisions above and replace them with the notice
- * and other provisions required by the GPL or the LGPL. If you do not delete
- * the provisions above, a recipient may use your version of this file under
- * the terms of any one of the MPL, the GPL or the LGPL.
- *
- * ***** END LICENSE BLOCK ***** */
-
 import java.io.File;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 /**
  * Abstract representation of the Guacamole configuration directory.
  *
+ * @deprecated
  * @author Michael Jumper
  */
 public class GuacamoleHome {
 
     /**
+     * Logger for this class.
+     */
+    private static final Logger logger = LoggerFactory.getLogger(GuacamoleHome.class);
+
+    static {
+        // Warn about deprecation
+        logger.warn("GuacamoleHome is deprecated. Please use Environment instead.");
+    }
+    
+    /**
      * GuacamoleHome is a utility class and cannot be instantiated.
      */
     private GuacamoleHome() {}
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/properties/GuacamoleProperties.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/properties/GuacamoleProperties.java
index f502642..891667e 100644
--- a/guacamole-ext/src/main/java/org/glyptodon/guacamole/properties/GuacamoleProperties.java
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/properties/GuacamoleProperties.java
@@ -1,42 +1,27 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ * 
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ * 
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ * 
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
 
 package org.glyptodon.guacamole.properties;
 
-/* ***** BEGIN LICENSE BLOCK *****
- * Version: MPL 1.1/GPL 2.0/LGPL 2.1
- *
- * The contents of this file are subject to the Mozilla Public License Version
- * 1.1 (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.mozilla.org/MPL/
- *
- * Software distributed under the License is distributed on an "AS IS" basis,
- * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- * for the specific language governing rights and limitations under the
- * License.
- *
- * The Original Code is guacamole-ext.
- *
- * The Initial Developer of the Original Code is
- * Michael Jumper.
- * Portions created by the Initial Developer are Copyright (C) 2010
- * the Initial Developer. All Rights Reserved.
- *
- * Contributor(s):
- *
- * Alternatively, the contents of this file may be used under the terms of
- * either the GNU General Public License Version 2 or later (the "GPL"), or
- * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
- * in which case the provisions of the GPL or the LGPL are applicable instead
- * of those above. If you wish to allow use of your version of this file only
- * under the terms of either the GPL or the LGPL, and not to allow others to
- * use your version of this file under the terms of the MPL, indicate your
- * decision by deleting the provisions above and replace them with the notice
- * and other provisions required by the GPL or the LGPL. If you do not delete
- * the provisions above, a recipient may use your version of this file under
- * the terms of any one of the MPL, the GPL or the LGPL.
- *
- * ***** END LICENSE BLOCK ***** */
-
 import java.io.File;
 import java.io.FileInputStream;
 import java.io.IOException;
@@ -44,6 +29,8 @@ import java.io.InputStream;
 import java.util.Properties;
 import org.glyptodon.guacamole.GuacamoleException;
 import org.glyptodon.guacamole.GuacamoleServerException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 /**
  * Simple utility class for reading properties from the guacamole.properties
@@ -54,11 +41,22 @@ import org.glyptodon.guacamole.GuacamoleServerException;
  * If none of those locations are possible, guacamole.properties will also
  * be read from the root of the classpath.
  *
+ * @deprecated
  * @author Michael Jumper
  */
 public class GuacamoleProperties {
 
     /**
+     * Logger for this class.
+     */
+    private static final Logger logger = LoggerFactory.getLogger(GuacamoleProperties.class);
+
+    static {
+        // Warn about deprecation
+        logger.warn("GuacamoleProperties is deprecated. Please use Environment instead.");
+    }
+ 
+    /**
      * GuacamoleProperties is a utility class and cannot be instantiated.
      */
     private GuacamoleProperties() {}
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/properties/GuacamoleProperty.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/properties/GuacamoleProperty.java
index ddbe6d6..c2980c1 100644
--- a/guacamole-ext/src/main/java/org/glyptodon/guacamole/properties/GuacamoleProperty.java
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/properties/GuacamoleProperty.java
@@ -1,42 +1,27 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ * 
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ * 
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ * 
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
 
 package org.glyptodon.guacamole.properties;
 
-/* ***** BEGIN LICENSE BLOCK *****
- * Version: MPL 1.1/GPL 2.0/LGPL 2.1
- *
- * The contents of this file are subject to the Mozilla Public License Version
- * 1.1 (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.mozilla.org/MPL/
- *
- * Software distributed under the License is distributed on an "AS IS" basis,
- * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- * for the specific language governing rights and limitations under the
- * License.
- *
- * The Original Code is guacamole-ext.
- *
- * The Initial Developer of the Original Code is
- * Michael Jumper.
- * Portions created by the Initial Developer are Copyright (C) 2010
- * the Initial Developer. All Rights Reserved.
- *
- * Contributor(s):
- *
- * Alternatively, the contents of this file may be used under the terms of
- * either the GNU General Public License Version 2 or later (the "GPL"), or
- * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
- * in which case the provisions of the GPL or the LGPL are applicable instead
- * of those above. If you wish to allow use of your version of this file only
- * under the terms of either the GPL or the LGPL, and not to allow others to
- * use your version of this file under the terms of the MPL, indicate your
- * decision by deleting the provisions above and replace them with the notice
- * and other provisions required by the GPL or the LGPL. If you do not delete
- * the provisions above, a recipient may use your version of this file under
- * the terms of any one of the MPL, the GPL or the LGPL.
- *
- * ***** END LICENSE BLOCK ***** */
-
 import org.glyptodon.guacamole.GuacamoleException;
 
 /**
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/properties/IntegerGuacamoleProperty.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/properties/IntegerGuacamoleProperty.java
index 6289bb9..b11a1db 100644
--- a/guacamole-ext/src/main/java/org/glyptodon/guacamole/properties/IntegerGuacamoleProperty.java
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/properties/IntegerGuacamoleProperty.java
@@ -1,42 +1,27 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ * 
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ * 
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ * 
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
 
 package org.glyptodon.guacamole.properties;
 
-/* ***** BEGIN LICENSE BLOCK *****
- * Version: MPL 1.1/GPL 2.0/LGPL 2.1
- *
- * The contents of this file are subject to the Mozilla Public License Version
- * 1.1 (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.mozilla.org/MPL/
- *
- * Software distributed under the License is distributed on an "AS IS" basis,
- * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- * for the specific language governing rights and limitations under the
- * License.
- *
- * The Original Code is guacamole-ext.
- *
- * The Initial Developer of the Original Code is
- * Michael Jumper.
- * Portions created by the Initial Developer are Copyright (C) 2010
- * the Initial Developer. All Rights Reserved.
- *
- * Contributor(s):
- *
- * Alternatively, the contents of this file may be used under the terms of
- * either the GNU General Public License Version 2 or later (the "GPL"), or
- * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
- * in which case the provisions of the GPL or the LGPL are applicable instead
- * of those above. If you wish to allow use of your version of this file only
- * under the terms of either the GPL or the LGPL, and not to allow others to
- * use your version of this file under the terms of the MPL, indicate your
- * decision by deleting the provisions above and replace them with the notice
- * and other provisions required by the GPL or the LGPL. If you do not delete
- * the provisions above, a recipient may use your version of this file under
- * the terms of any one of the MPL, the GPL or the LGPL.
- *
- * ***** END LICENSE BLOCK ***** */
-
 import org.glyptodon.guacamole.GuacamoleException;
 import org.glyptodon.guacamole.GuacamoleServerException;
 
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/properties/LongGuacamoleProperty.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/properties/LongGuacamoleProperty.java
new file mode 100644
index 0000000..9fa238f
--- /dev/null
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/properties/LongGuacamoleProperty.java
@@ -0,0 +1,52 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ * 
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ * 
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ * 
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.properties;
+
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.GuacamoleServerException;
+
+/**
+ * A GuacamoleProperty whose value is an long.
+ *
+ * @author James Muehlner
+ */
+public abstract class LongGuacamoleProperty implements GuacamoleProperty<Long> {
+
+    @Override
+    public Long parseValue(String value) throws GuacamoleException {
+
+        // If no property provided, return null.
+        if (value == null)
+            return null;
+
+        try {
+            Long longValue = new Long(value);
+            return longValue;
+        }
+        catch (NumberFormatException e) {
+            throw new GuacamoleServerException("Property \"" + getName() + "\" must be an long.", e);
+        }
+
+    }
+
+}
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/properties/StringGuacamoleProperty.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/properties/StringGuacamoleProperty.java
index a8ebef8..afad37f 100644
--- a/guacamole-ext/src/main/java/org/glyptodon/guacamole/properties/StringGuacamoleProperty.java
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/properties/StringGuacamoleProperty.java
@@ -1,42 +1,27 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ * 
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ * 
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ * 
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
 
 package org.glyptodon.guacamole.properties;
 
-/* ***** BEGIN LICENSE BLOCK *****
- * Version: MPL 1.1/GPL 2.0/LGPL 2.1
- *
- * The contents of this file are subject to the Mozilla Public License Version
- * 1.1 (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.mozilla.org/MPL/
- *
- * Software distributed under the License is distributed on an "AS IS" basis,
- * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- * for the specific language governing rights and limitations under the
- * License.
- *
- * The Original Code is guacamole-ext.
- *
- * The Initial Developer of the Original Code is
- * Michael Jumper.
- * Portions created by the Initial Developer are Copyright (C) 2010
- * the Initial Developer. All Rights Reserved.
- *
- * Contributor(s):
- *
- * Alternatively, the contents of this file may be used under the terms of
- * either the GNU General Public License Version 2 or later (the "GPL"), or
- * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
- * in which case the provisions of the GPL or the LGPL are applicable instead
- * of those above. If you wish to allow use of your version of this file only
- * under the terms of either the GPL or the LGPL, and not to allow others to
- * use your version of this file under the terms of the MPL, indicate your
- * decision by deleting the provisions above and replace them with the notice
- * and other provisions required by the GPL or the LGPL. If you do not delete
- * the provisions above, a recipient may use your version of this file under
- * the terms of any one of the MPL, the GPL or the LGPL.
- *
- * ***** END LICENSE BLOCK ***** */
-
 import org.glyptodon.guacamole.GuacamoleException;
 
 /**
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/properties/package-info.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/properties/package-info.java
index 5dbd06f..2be1bd1 100644
--- a/guacamole-ext/src/main/java/org/glyptodon/guacamole/properties/package-info.java
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/properties/package-info.java
@@ -1,3 +1,24 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ * 
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ * 
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ * 
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
 
 /**
  * Provides classes for reading properties from the web-application-wide
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/protocols/ProtocolInfo.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/protocols/ProtocolInfo.java
new file mode 100644
index 0000000..36d88b4
--- /dev/null
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/protocols/ProtocolInfo.java
@@ -0,0 +1,122 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.protocols;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import org.glyptodon.guacamole.form.Form;
+
+/**
+ * Describes a protocol and all forms associated with it, as required by
+ * a protocol plugin for guacd. This class allows known forms for a
+ * protocol to be exposed to the user as friendly fields.
+ *
+ * @author Michael Jumper
+ */
+public class ProtocolInfo {
+
+    /**
+     * The unique name associated with this protocol.
+     */
+    private String name;
+
+    /**
+     * A collection of all associated protocol forms.
+     */
+    private Collection<Form> forms;
+
+    /**
+     * Creates a new ProtocolInfo with no associated name or forms.
+     */
+    public ProtocolInfo() {
+        this.forms = new ArrayList<Form>();
+    }
+
+    /**
+     * Creates a new ProtocolInfo having the given name, but without any forms.
+     *
+     * @param name
+     *     The unique name associated with the protocol.
+     */
+    public ProtocolInfo(String name) {
+        this.name  = name;
+        this.forms = new ArrayList<Form>();
+    }
+
+    /**
+     * Creates a new ProtocolInfo having the given name and forms.
+     *
+     * @param name
+     *     The unique name associated with the protocol.
+     *
+     * @param forms
+     *     The forms to associate with the protocol.
+     */
+    public ProtocolInfo(String name, Collection<Form> forms) {
+        this.name  = name;
+        this.forms = forms;
+    }
+
+    /**
+     * Returns the unique name of this protocol. The protocol name is the
+     * value required by the corresponding protocol plugin for guacd.
+     *
+     * @return The unique name of this protocol.
+     */
+    public String getName() {
+        return name;
+    }
+
+    /**
+     * Sets the unique name of this protocol. The protocol name is the value
+     * required by the corresponding protocol plugin for guacd.
+     *
+     * @param name The unique name of this protocol.
+     */
+    public void setName(String name) {
+        this.name = name;
+    }
+
+    /**
+     * Returns a mutable collection of the protocol forms associated with
+     * this protocol. Changes to this collection affect the forms exposed
+     * to the user.
+     *
+     * @return A mutable collection of protocol forms.
+     */
+    public Collection<Form> getForms() {
+        return forms;
+    }
+
+    /**
+     * Sets the collection of protocol forms associated with this
+     * protocol.
+     *
+     * @param forms
+     *     The collection of forms to associate with this protocol.
+     */
+    public void setForms(Collection<Form> forms) {
+        this.forms = forms;
+    }
+    
+}
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/token/StandardTokens.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/token/StandardTokens.java
new file mode 100644
index 0000000..31b2c4e
--- /dev/null
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/token/StandardTokens.java
@@ -0,0 +1,79 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ * 
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ * 
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ * 
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.token;
+
+import org.glyptodon.guacamole.net.auth.Credentials;
+
+/**
+ * Utility class which provides access to standardized token names, as well as
+ * facilities for generating those tokens from common objects.
+ *
+ * @author Michael Jumper
+ */
+public class StandardTokens {
+
+    /**
+     * The name of the username token added via addStandardTokens().
+     */
+    private static final String USERNAME_TOKEN = "GUAC_USERNAME";
+
+    /**
+     * The name of the password token added via addStandardTokens().
+     */
+    private static final String PASSWORD_TOKEN = "GUAC_PASSWORD";
+
+    /**
+     * This utility class should not be instantiated.
+     */
+    private StandardTokens() {}
+
+    /**
+     * Adds the standard username (GUAC_USERNAME) and password (GUAC_PASSWORD)
+     * tokens to the given TokenFilter using the values from the given
+     * Credentials object. If either the username or password are not set
+     * within the given credentials, the corresponding token(s) will remain
+     * unset.
+     *
+     * @param filter
+     *     The TokenFilter to add standard username/password tokens to.
+     *
+     * @param credentials
+     *     The Credentials containing the username/password to add.
+     *
+     */
+    public static void addStandardTokens(TokenFilter filter, Credentials credentials) {
+
+        // Add username token
+        String username = credentials.getUsername();
+        if (username != null)
+            filter.setToken(USERNAME_TOKEN, username);
+        
+        // Add password token
+        String password = credentials.getPassword();
+        if (password != null)
+            filter.setToken(PASSWORD_TOKEN, password);
+        
+    }
+    
+
+}
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/token/TokenFilter.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/token/TokenFilter.java
new file mode 100644
index 0000000..56a5dba
--- /dev/null
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/token/TokenFilter.java
@@ -0,0 +1,234 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ * 
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ * 
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ * 
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.token;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Filtering object which replaces tokens of the form "${TOKEN_NAME}" with
+ * their corresponding values. Unknown tokens are not replaced. If TOKEN_NAME
+ * is a valid token, the literal value "${TOKEN_NAME}" can be included by using
+ * "$${TOKEN_NAME}".
+ *
+ * @author Michael Jumper
+ */
+public class TokenFilter {
+
+    /**
+     * Regular expression which matches individual tokens, with additional
+     * capturing groups for convenient retrieval of leading text, the possible
+     * escape character preceding the token, the name of the token, and the
+     * entire token itself.
+     */
+    private final Pattern tokenPattern = Pattern.compile("(.*?)(^|.)(\\$\\{([A-Za-z0-9_]*)\\})");
+
+    /**
+     * The index of the capturing group within tokenPattern which matches
+     * non-token text preceding a possible token.
+     */
+    private static final int LEADING_TEXT_GROUP = 1;
+
+    /**
+     * The index of the capturing group within tokenPattern which matches the
+     * character immediately preceding a possible token, possibly denoting that
+     * the token should instead be interpreted as a literal.
+     */
+    private static final int ESCAPE_CHAR_GROUP = 2;
+
+    /**
+     * The index of the capturing group within tokenPattern which matches the
+     * entire token, including the leading "${" and terminating "}" strings.
+     */
+    private static final int TOKEN_GROUP = 3;
+
+    /**
+     * The index of the capturing group within tokenPattern which matches only
+     * the token name contained within the "${" and "}" strings.
+     */
+    private static final int TOKEN_NAME_GROUP = 4;
+    
+    /**
+     * The values of all known tokens.
+     */
+    private final Map<String, String> tokenValues = new HashMap<String, String>();
+
+    /**
+     * Sets the token having the given name to the given value. Any existing
+     * value for that token is replaced.
+     *
+     * @param name
+     *     The name of the token to set.
+     *
+     * @param value
+     *     The value to set the token to.
+     */
+    public void setToken(String name, String value) {
+        tokenValues.put(name, value);
+    }
+
+    /**
+     * Returns the value of the token with the given name, or null if no such
+     * token has been set.
+     *
+     * @param name
+     *     The name of the token to return.
+     * 
+     * @return
+     *     The value of the token with the given name, or null if no such
+     *     token exists.
+     */
+    public String getToken(String name) {
+        return tokenValues.get(name);
+    }
+
+    /**
+     * Removes the value of the token with the given name. If no such token
+     * exists, this function has no effect.
+     *
+     * @param name
+     *     The name of the token whose value should be removed.
+     */
+    public void unsetToken(String name) {
+        tokenValues.remove(name);
+    }
+
+    /**
+     * Returns a map of all tokens, with each key being a token name, and each
+     * value being the corresponding token value. Changes to this map will
+     * directly affect the tokens associated with this filter.
+     *
+     * @return
+     *     A map of all token names and their corresponding values.
+     */
+    public Map<String, String> getTokens() {
+        return tokenValues;
+    }
+
+    /**
+     * Replaces all current token values with the contents of the given map,
+     * where each map key represents a token name, and each map value
+     * represents a token value.
+     *
+     * @param tokens
+     *     A map containing the token names and corresponding values to
+     *     assign.
+     */
+    public void setTokens(Map<String, String> tokens) {
+        tokenValues.clear();
+        tokenValues.putAll(tokens);
+    }
+    
+    /**
+     * Filters the given string, replacing any tokens with their corresponding
+     * values.
+     *
+     * @param input
+     *     The string to filter.
+     *
+     * @return
+     *     A copy of the input string, with any tokens replaced with their
+     *     corresponding values.
+     */
+    public String filter(String input) {
+
+        StringBuilder output = new StringBuilder();
+        Matcher tokenMatcher = tokenPattern.matcher(input);
+
+        // Track last regex match
+        int endOfLastMatch = 0;
+
+        // For each possible token
+        while (tokenMatcher.find()) {
+
+            // Pull possible leading text and first char before possible token
+            String literal = tokenMatcher.group(LEADING_TEXT_GROUP);
+            String escape = tokenMatcher.group(ESCAPE_CHAR_GROUP);
+
+            // Append leading non-token text
+            output.append(literal);
+
+            // If char before token is '$', the token itself is escaped
+            if ("$".equals(escape)) {
+                String notToken = tokenMatcher.group(TOKEN_GROUP);
+                output.append(notToken);
+            }
+
+            // If char is not '$', interpret as a token
+            else {
+
+                // The char before the token, if any, is a literal
+                output.append(escape);
+
+                // Pull token value
+                String tokenName = tokenMatcher.group(TOKEN_NAME_GROUP);
+                String tokenValue = getToken(tokenName);
+
+                // If token is unknown, interpret as literal
+                if (tokenValue == null) {
+                    String notToken = tokenMatcher.group(TOKEN_GROUP);
+                    output.append(notToken);
+                }
+
+                // Otherwise, substitute value
+                else
+                    output.append(tokenValue);
+
+            }
+
+            // Update last regex match
+            endOfLastMatch = tokenMatcher.end();
+            
+        }
+
+        // Append any remaining non-token text
+        output.append(input.substring(endOfLastMatch));
+        
+        return output.toString();
+       
+    }
+
+    /**
+     * Given an arbitrary map containing String values, replace each non-null
+     * value with the corresponding filtered value.
+     *
+     * @param map
+     *     The map whose values should be filtered.
+     */
+    public void filterValues(Map<?, String> map) {
+
+        // For each map entry
+        for (Map.Entry<?, String> entry : map.entrySet()) {
+
+            // If value is non-null, filter value through this TokenFilter
+            String value = entry.getValue();
+            if (value != null)
+                entry.setValue(filter(value));
+            
+        }
+        
+    }
+
+}
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/xml/DocumentHandler.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/xml/DocumentHandler.java
new file mode 100644
index 0000000..bc77ad4
--- /dev/null
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/xml/DocumentHandler.java
@@ -0,0 +1,205 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.xml;
+
+import java.util.Deque;
+import java.util.LinkedList;
+import org.xml.sax.Attributes;
+import org.xml.sax.SAXException;
+import org.xml.sax.helpers.DefaultHandler;
+
+/**
+ * A simple ContentHandler implementation which digests SAX document events and
+ * produces simpler tag-level events, maintaining its own stack for the
+ * convenience of the tag handlers.
+ *
+ * @author Mike Jumper
+ */
+public class DocumentHandler extends DefaultHandler {
+
+    /**
+     * The name of the root element of the document.
+     */
+    private String rootElementName;
+
+    /**
+     * The handler which will be used to handle element events for the root
+     * element of the document.
+     */
+    private TagHandler root;
+
+    /**
+     * The stack of all states applicable to the current parser state. Each
+     * element of the stack references the TagHandler for the element being
+     * parsed at that level of the document, where the current element is
+     * last in the stack, and the root element is first.
+     */
+    private Deque<DocumentHandlerState> stack =
+            new LinkedList<DocumentHandlerState>();
+
+    /**
+     * Creates a new DocumentHandler which will use the given TagHandler
+     * to handle the root element.
+     *
+     * @param rootElementName The name of the root element of the document
+     *                        being handled.
+     * @param root The TagHandler to use for the root element.
+     */
+    public DocumentHandler(String rootElementName, TagHandler root) {
+        this.root = root;
+        this.rootElementName = rootElementName;
+    }
+
+    /**
+     * Returns the current element state. The current element state is the
+     * state of the element the parser is currently within.
+     *
+     * @return The current element state.
+     */
+    private DocumentHandlerState getCurrentState() {
+
+        // If no state, return null
+        if (stack.isEmpty())
+            return null;
+
+        return stack.getLast();
+    }
+
+    @Override
+    public void startElement(String uri, String localName, String qName,
+        Attributes attributes) throws SAXException {
+
+        // Get current state
+        DocumentHandlerState current = getCurrentState();
+
+        // Handler for tag just read
+        TagHandler handler;
+
+        // If no stack, use root handler
+        if (current == null) {
+
+            // Validate element name
+            if (!localName.equals(rootElementName))
+                throw new SAXException("Root element must be '" + rootElementName + "'");
+
+            handler = root;
+        }
+
+        // Otherwise, get handler from parent
+        else {
+            TagHandler parent_handler = current.getTagHandler();
+            handler = parent_handler.childElement(localName);
+        }
+
+        // If no handler returned, the element was not expected
+        if (handler == null)
+            throw new SAXException("Unexpected element: '" + localName + "'");
+
+        // Initialize handler
+        handler.init(attributes);
+
+        // Append new element state to stack
+        stack.addLast(new DocumentHandlerState(handler));
+
+    }
+
+    @Override
+    public void endElement(String uri, String localName, String qName)
+            throws SAXException {
+
+        // Pop last element from stack
+        DocumentHandlerState completed = stack.removeLast();
+
+        // Finish element by sending text content
+        completed.getTagHandler().complete(
+                completed.getTextContent().toString());
+
+    }
+
+    @Override
+    public void characters(char[] ch, int start, int length)
+            throws SAXException {
+
+        // Get current state
+        DocumentHandlerState current = getCurrentState();
+        if (current == null)
+            throw new SAXException("Character data not allowed outside XML document.");
+        
+        // Append received chunk to text content
+        current.getTextContent().append(ch, start, length);
+
+    }
+
+    /**
+     * The current state of the DocumentHandler.
+     */
+    private static class DocumentHandlerState {
+
+        /**
+         * The current text content of the current element being parsed.
+         */
+        private StringBuilder textContent = new StringBuilder();
+
+        /**
+         * The TagHandler which must handle document events related to the
+         * element currently being parsed.
+         */
+        private TagHandler tagHandler;
+
+        /**
+         * Creates a new DocumentHandlerState which will maintain the state
+         * of parsing of the current element, as well as contain the TagHandler
+         * which will receive events related to that element.
+         *
+         * @param tagHandler The TagHandler which should receive any events
+         *                   related to the element being parsed.
+         */
+        public DocumentHandlerState(TagHandler tagHandler) {
+            this.tagHandler = tagHandler;
+        }
+
+        /**
+         * Returns the mutable StringBuilder which contains the current text
+         * content of the element being parsed.
+         *
+         * @return The mutable StringBuilder which contains the current text
+         *         content of the element being parsed.
+         */
+        public StringBuilder getTextContent() {
+            return textContent;
+        }
+
+        /**
+         * Returns the TagHandler which must handle any events relating to the
+         * element being parsed.
+         *
+         * @return The TagHandler which must handle any events relating to the
+         *         element being parsed.
+         */
+        public TagHandler getTagHandler() {
+            return tagHandler;
+        }
+
+    }
+
+}
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/xml/TagHandler.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/xml/TagHandler.java
new file mode 100644
index 0000000..b890499
--- /dev/null
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/xml/TagHandler.java
@@ -0,0 +1,70 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.xml;
+
+import org.xml.sax.Attributes;
+import org.xml.sax.SAXException;
+
+/**
+ * A simple element-level event handler for events triggered by the
+ * SAX-driven DocumentHandler parser.
+ *
+ * @author Mike Jumper
+ */
+public interface TagHandler {
+
+    /**
+     * Called when a child element of the current element is parsed.
+     *
+     * @param localName The local name of the child element seen.
+     * @return The TagHandler which should handle all element-level events
+     *         related to the child element.
+     * @throws SAXException If the child element being parsed was not expected,
+     *                      or some other error prevents a proper TagHandler
+     *                      from being constructed for the child element.
+     */
+    public TagHandler childElement(String localName)
+            throws SAXException;
+
+    /**
+     * Called when the element corresponding to this TagHandler is first seen,
+     * just after an instance is created.
+     *
+     * @param attributes The attributes of the element seen.
+     * @throws SAXException If an error prevents a the TagHandler from being
+     *                      from being initialized.
+     */
+    public void init(Attributes attributes) throws SAXException;
+
+    /**
+     * Called when this element, and all child elements, have been fully parsed,
+     * and the entire text content of this element (if any) is available.
+     *
+     * @param textContent The full text content of this element, if any.
+     * @throws SAXException If the text content received is not valid for any
+     *                      reason, or the child elements parsed are not
+     *                      correct.
+     */
+    public void complete(String textContent) throws SAXException;
+
+}
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/xml/package-info.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/xml/package-info.java
new file mode 100644
index 0000000..4b6d156
--- /dev/null
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/xml/package-info.java
@@ -0,0 +1,28 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Classes driving the SAX-based XML parser used by the Guacamole web
+ * application.
+ */
+package org.glyptodon.guacamole.xml;
+
diff --git a/guacamole-ext/src/main/resources/org/glyptodon/guacamole/protocols/rdp.json b/guacamole-ext/src/main/resources/org/glyptodon/guacamole/protocols/rdp.json
new file mode 100644
index 0000000..f0e4340
--- /dev/null
+++ b/guacamole-ext/src/main/resources/org/glyptodon/guacamole/protocols/rdp.json
@@ -0,0 +1,254 @@
+{
+    "name"  : "rdp",
+    "forms" : [
+
+        {
+            "name"  : "network",
+            "fields" : [
+                {
+                    "name"  : "hostname",
+                    "type"  : "TEXT"
+                },
+                {
+                    "name"  : "port",
+                    "type"  : "NUMERIC"
+                }
+            ]
+        },
+
+        {
+            "name"  : "authentication",
+            "fields" : [
+                {
+                    "name"  : "username",
+                    "type"  : "USERNAME"
+                },
+                {
+                    "name"  : "password",
+                    "type"  : "PASSWORD"
+                },
+                {
+                    "name"  : "domain",
+                    "type"  : "TEXT"
+                },
+                {
+                    "name"    : "security",
+                    "type"    : "ENUM",
+                    "options" : [ "", "rdp", "tls", "nla", "any" ]
+                },
+                {
+                    "name"    : "disable-auth",
+                    "type"    : "BOOLEAN",
+                    "options" : [ "true" ]
+                },
+                {
+                    "name"    : "ignore-cert",
+                    "type"    : "BOOLEAN",
+                    "options" : [ "true" ]
+                }
+            ]
+        },
+
+        {
+            "name"  : "basic-parameters",
+            "fields" : [
+                {
+                    "name"  : "initial-program",
+                    "type"  : "TEXT"
+                },
+                {
+                    "name"  : "client-name",
+                    "type"  : "TEXT"
+                },
+                {
+                    "name"    : "server-layout",
+                    "type"    : "ENUM",
+                    "options" : [
+                        "",
+                        "en-us-qwerty",
+                        "fr-fr-azerty",
+                        "de-de-qwertz",
+                        "it-it-qwerty",
+                        "sv-se-qwerty",
+                        "failsafe"
+                    ]
+                },
+                {
+                    "name"    : "console",
+                    "type"    : "BOOLEAN",
+                    "options" : [ "true" ]
+                }
+            ]
+        },
+
+        {
+            "name"  : "display",
+            "fields" : [
+                {
+                    "name"  : "width",
+                    "type"  : "NUMERIC"
+                },
+                {
+                    "name"  : "height",
+                    "type"  : "NUMERIC"
+                },
+                {
+                    "name"  : "dpi",
+                    "type"  : "NUMERIC"
+                },
+                {
+                    "name"    : "color-depth",
+                    "type"    : "ENUM",
+                    "options" : [ "", "8", "16", "24", "32" ]
+                }
+            ]
+        },
+
+        {
+            "name"  : "device-redirection",
+            "fields" : [
+                {
+                    "name"    : "console-audio",
+                    "type"    : "BOOLEAN",
+                    "options" : [ "true" ]
+                },
+                {
+                    "name"    : "disable-audio",
+                    "type"    : "BOOLEAN",
+                    "options" : [ "true" ]
+                },
+                {
+                    "name"    : "enable-printing",
+                    "type"    : "BOOLEAN",
+                    "options" : [ "true" ]
+                },
+                {
+                    "name"    : "enable-drive",
+                    "type"    : "BOOLEAN",
+                    "options" : [ "true" ]
+                },
+                {
+                    "name"  : "drive-path",
+                    "type"  : "TEXT"
+                },
+                {
+                    "name"    : "create-drive-path",
+                    "type"    : "BOOLEAN",
+                    "options" : [ "true" ]
+                },
+                {
+                    "name"  : "static-channels",
+                    "type"  : "TEXT"
+                }
+            ]
+        },
+
+        {
+            "name" : "performance",
+            "fields" : [
+                {
+                    "name"    : "enable-wallpaper",
+                    "type"    : "BOOLEAN",
+                    "options" : [ "true" ]
+                },
+                {
+                    "name"    : "enable-theming",
+                    "type"    : "BOOLEAN",
+                    "options" : [ "true" ]
+                },
+                {
+                    "name"    : "enable-font-smoothing",
+                    "type"    : "BOOLEAN",
+                    "options" : [ "true" ]
+                },
+                {
+                    "name"    : "enable-full-window-drag",
+                    "type"    : "BOOLEAN",
+                    "options" : [ "true" ]
+                },
+                {
+                    "name"    : "enable-desktop-composition",
+                    "type"    : "BOOLEAN",
+                    "options" : [ "true" ]
+                },
+                {
+                    "name"    : "enable-menu-animations",
+                    "type"    : "BOOLEAN",
+                    "options" : [ "true" ]
+                }
+            ]
+        },
+
+        {
+            "name"  : "remoteapp",
+            "fields" : [
+                {
+                    "name"  : "remote-app",
+                    "type"  : "TEXT"
+                },
+                {
+                    "name"  : "remote-app-dir",
+                    "type"  : "TEXT"
+                },
+                {
+                    "name"  : "remote-app-args",
+                    "type"  : "TEXT"
+                }
+            ]
+        },
+
+        {
+            "name"  : "preconnection-pdu",
+            "fields" : [
+                {
+                    "name"  : "preconnection-id",
+                    "type"  : "NUMERIC"
+                },
+                {
+                    "name"  : "preconnection-blob",
+                    "type"  : "TEXT"
+                }
+            ]
+        },
+
+        {
+            "name"  : "sftp",
+            "fields" : [
+                {
+                    "name"    : "enable-sftp",
+                    "type"    : "BOOLEAN",
+                    "options" : [ "true" ]
+                },
+                {
+                    "name"  : "sftp-hostname",
+                    "type"  : "TEXT"
+                },
+                {
+                    "name"  : "sftp-port",
+                    "type"  : "NUMERIC"
+                },
+                {
+                    "name"  : "sftp-username",
+                    "type"  : "USERNAME"
+                },
+                {
+                    "name"  : "sftp-password",
+                    "type"  : "PASSWORD"
+                },
+                {
+                    "name"  : "sftp-private-key",
+                    "type"  : "MULTILINE"
+                },
+                {
+                    "name"  : "sftp-passphrase",
+                    "type"  : "PASSWORD"
+                },
+                {
+                    "name"  : "sftp-directory",
+                    "type"  : "TEXT"
+                }
+            ]
+        }
+
+    ]
+}
diff --git a/guacamole-ext/src/main/resources/org/glyptodon/guacamole/protocols/ssh.json b/guacamole-ext/src/main/resources/org/glyptodon/guacamole/protocols/ssh.json
new file mode 100644
index 0000000..aea3f56
--- /dev/null
+++ b/guacamole-ext/src/main/resources/org/glyptodon/guacamole/protocols/ssh.json
@@ -0,0 +1,83 @@
+{
+    "name"  : "ssh",
+    "forms" : [
+
+        {
+            "name"  : "network",
+            "fields" : [
+                {
+                    "name"  : "hostname",
+                    "type"  : "TEXT"
+                },
+                {
+                    "name"  : "port",
+                    "type"  : "NUMERIC"
+                }
+            ]
+        },
+
+        {
+            "name"  : "authentication",
+            "fields" : [
+                {
+                    "name"  : "username",
+                    "type"  : "USERNAME"
+                },
+                {
+                    "name"  : "password",
+                    "type"  : "PASSWORD"
+                },
+                {
+                    "name"  : "private-key",
+                    "type"  : "MULTILINE"
+                },
+                {
+                    "name"  : "passphrase",
+                    "type"  : "PASSWORD"
+                }
+            ]
+        },
+
+        {
+            "name"  : "display",
+            "fields" : [
+                {
+                    "name"  : "color-scheme",
+                    "type"  : "ENUM",
+                    "options" : [ "", "black-white", "gray-black", "green-black", "white-black" ]
+                },
+                {
+                    "name"  : "font-name",
+                    "type"  : "TEXT"
+                },
+                {
+                    "name"  : "font-size",
+                    "type"  : "ENUM",
+                    "options" : [ "", "8", "9", "10", "11", "12", "14", "18", "24", "30", "36", "48", "60", "72", "96" ]
+                }
+            ]
+        },
+
+        {
+            "name" : "session",
+            "fields" : [
+                {
+                    "name"  : "command",
+                    "type"  : "TEXT"
+                }
+            ]
+        },
+
+        {
+            "name"  : "sftp",
+            "fields" : [
+                {
+                    "name"    : "enable-sftp",
+                    "type"    : "BOOLEAN",
+                    "options" : [ "true" ]
+                }
+            ]
+        }
+
+    ]
+}
\ No newline at end of file
diff --git a/guacamole-ext/src/main/resources/org/glyptodon/guacamole/protocols/telnet.json b/guacamole-ext/src/main/resources/org/glyptodon/guacamole/protocols/telnet.json
new file mode 100644
index 0000000..0e4426b
--- /dev/null
+++ b/guacamole-ext/src/main/resources/org/glyptodon/guacamole/protocols/telnet.json
@@ -0,0 +1,58 @@
+{
+    "name"  : "telnet",
+    "forms" : [
+
+        {
+            "name"  : "network",
+            "fields" : [
+                {
+                    "name"  : "hostname",
+                    "type"  : "TEXT"
+                },
+                {
+                    "name"  : "port",
+                    "type"  : "NUMERIC"
+                }
+            ]
+        },
+
+        {
+            "name"  : "authentication",
+            "fields" : [
+                {
+                    "name"  : "username",
+                    "type"  : "USERNAME"
+                },
+                {
+                    "name"  : "password",
+                    "type"  : "PASSWORD"
+                },
+                {
+                    "name"  : "password-regex",
+                    "type"  : "TEXT"
+                }
+            ]
+        },
+
+        {
+            "name"  : "display",
+            "fields" : [
+                {
+                    "name"  : "color-scheme",
+                    "type"  : "ENUM",
+                    "options" : [ "", "black-white", "gray-black", "green-black", "white-black" ]
+                },
+                {
+                    "name"  : "font-name",
+                    "type"  : "TEXT"
+                },
+                {
+                    "name"  : "font-size",
+                    "type"  : "ENUM",
+                    "options" : [ "", "8", "9", "10", "11", "12", "14", "18", "24", "30", "36", "48", "60", "72", "96" ]
+                }
+            ]
+        }
+
+    ]
+}
diff --git a/guacamole-ext/src/main/resources/org/glyptodon/guacamole/protocols/vnc.json b/guacamole-ext/src/main/resources/org/glyptodon/guacamole/protocols/vnc.json
new file mode 100644
index 0000000..c134d9f
--- /dev/null
+++ b/guacamole-ext/src/main/resources/org/glyptodon/guacamole/protocols/vnc.json
@@ -0,0 +1,135 @@
+{
+    "name"  : "vnc",
+    "forms" : [
+
+        {
+            "name"  : "network",
+            "fields" : [
+                {
+                    "name"  : "hostname",
+                    "type"  : "TEXT"
+                },
+                {
+                    "name"  : "port",
+                    "type"  : "NUMERIC"
+                }
+            ]
+        },
+
+        {
+            "name"  : "authentication",
+            "fields" : [
+                {
+                    "name"  : "password",
+                    "type"  : "PASSWORD"
+                }
+            ]
+        },
+
+        {
+            "name"  : "display",
+            "fields" : [
+                {
+                    "name"    : "read-only",
+                    "type"    : "BOOLEAN",
+                    "options" : [ "true" ]
+                },
+                {
+                    "name"    : "swap-red-blue",
+                    "type"    : "BOOLEAN",
+                    "options" : [ "true" ]
+                },
+                {
+                    "name"  : "cursor",
+                    "type"  : "ENUM",
+                    "options" : [ "", "local", "remote" ]
+                },
+                {
+                    "name"  : "color-depth",
+                    "type"  : "ENUM",
+                    "options" : [ "", "8", "16", "24", "32" ]
+                }
+            ]
+        },
+
+        {
+            "name"  : "clipboard",
+            "fields" : [
+                {
+                    "name"    : "clipboard-encoding",
+                    "type"    : "ENUM",
+                    "options" : [ "", "ISO8859-1", "UTF-8", "UTF-16", "CP1252" ]
+                }
+            ]
+        },
+
+        {
+            "name"  : "repeater",
+            "fields" : [
+                {
+                    "name"  : "dest-host",
+                    "type"  : "TEXT"
+                },
+                {
+                    "name"  : "dest-port",
+                    "type"  : "NUMERIC"
+                }
+            ]
+        },
+
+        {
+            "name"  : "sftp",
+            "fields" : [
+                {
+                    "name"    : "enable-sftp",
+                    "type"    : "BOOLEAN",
+                    "options" : [ "true" ]
+                },
+                {
+                    "name"  : "sftp-hostname",
+                    "type"  : "TEXT"
+                },
+                {
+                    "name"  : "sftp-port",
+                    "type"  : "NUMERIC"
+                },
+                {
+                    "name"  : "sftp-username",
+                    "type"  : "USERNAME"
+                },
+                {
+                    "name"  : "sftp-password",
+                    "type"  : "PASSWORD"
+                },
+                {
+                    "name"  : "sftp-private-key",
+                    "type"  : "MULTILINE"
+                },
+                {
+                    "name"  : "sftp-passphrase",
+                    "type"  : "PASSWORD"
+                },
+                {
+                    "name"  : "sftp-directory",
+                    "type"  : "TEXT"
+                }
+            ]
+        },
+
+        {
+            "name"  : "audio",
+            "fields" : [
+                {
+                    "name"    : "enable-audio",
+                    "type"    : "BOOLEAN",
+                    "options" : [ "true" ]
+                },
+                {
+                    "name"  : "audio-servername",
+                    "type"  : "TEXT"
+                }
+            ]
+        }
+
+    ]
+}
diff --git a/guacamole-ext/src/test/java/org/glyptodon/guacamole/token/TokenFilterTest.java b/guacamole-ext/src/test/java/org/glyptodon/guacamole/token/TokenFilterTest.java
new file mode 100644
index 0000000..9d0777c
--- /dev/null
+++ b/guacamole-ext/src/test/java/org/glyptodon/guacamole/token/TokenFilterTest.java
@@ -0,0 +1,103 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ * 
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ * 
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ * 
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.token;
+
+import java.util.HashMap;
+import java.util.Map;
+import org.junit.Test;
+import static org.junit.Assert.*;
+
+/**
+ * Test which verifies the filtering functionality of TokenFilter.
+ *
+ * @author Michael Jumper
+ */
+public class TokenFilterTest {
+
+    /**
+     * Verifies that token replacement via filter() functions as specified.
+     */
+    @Test
+    public void testFilter() {
+
+        // Create token filter
+        TokenFilter tokenFilter = new TokenFilter();
+        tokenFilter.setToken("TOKEN_A", "value-of-a");
+        tokenFilter.setToken("TOKEN_B", "value-of-b");
+
+        // Test basic substitution and escaping
+        assertEquals(
+            "$${NOPE}hellovalue-of-aworldvalue-of-b${NOT_A_TOKEN}",
+            tokenFilter.filter("$$${NOPE}hello${TOKEN_A}world${TOKEN_B}$${NOT_A_TOKEN}")
+        );
+        
+        // Unknown tokens must be interpreted as literals
+        assertEquals(
+            "${NOPE}hellovalue-of-aworld${TOKEN_C}",
+            tokenFilter.filter("${NOPE}hello${TOKEN_A}world${TOKEN_C}")
+        );
+        
+    }
+    
+    /**
+     * Verifies that token replacement via filterValues() functions as
+     * specified.
+     */
+    @Test
+    public void testFilterValues() {
+
+        // Create token filter
+        TokenFilter tokenFilter = new TokenFilter();
+        tokenFilter.setToken("TOKEN_A", "value-of-a");
+        tokenFilter.setToken("TOKEN_B", "value-of-b");
+
+        // Create test map
+        Map<Integer, String> map = new HashMap<Integer, String>();
+        map.put(1, "$$${NOPE}hello${TOKEN_A}world${TOKEN_B}$${NOT_A_TOKEN}");
+        map.put(2, "${NOPE}hello${TOKEN_A}world${TOKEN_C}");
+        map.put(3, null);
+
+        // Filter map values
+        tokenFilter.filterValues(map);
+
+        // Filter should not affect size of map
+        assertEquals(3, map.size());
+
+        // Filtered value 1
+        assertEquals(
+            "$${NOPE}hellovalue-of-aworldvalue-of-b${NOT_A_TOKEN}",
+            map.get(1)
+        );
+        
+        // Filtered value 2
+        assertEquals(
+            "${NOPE}hellovalue-of-aworld${TOKEN_C}",
+            map.get(2)
+        );
+
+        // Null values are not filtered
+        assertNull(map.get(3));
+        
+    }
+    
+}
diff --git a/guacamole/COPYING b/guacamole/COPYING
deleted file mode 100644
index dba13ed..0000000
--- a/guacamole/COPYING
+++ /dev/null
@@ -1,661 +0,0 @@
-                    GNU AFFERO GENERAL PUBLIC LICENSE
-                       Version 3, 19 November 2007
-
- Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
- Everyone is permitted to copy and distribute verbatim copies
- of this license document, but changing it is not allowed.
-
-                            Preamble
-
-  The GNU Affero General Public License is a free, copyleft license for
-software and other kinds of works, specifically designed to ensure
-cooperation with the community in the case of network server software.
-
-  The licenses for most software and other practical works are designed
-to take away your freedom to share and change the works.  By contrast,
-our General Public Licenses are intended to guarantee your freedom to
-share and change all versions of a program--to make sure it remains free
-software for all its users.
-
-  When we speak of free software, we are referring to freedom, not
-price.  Our General Public Licenses are designed to make sure that you
-have the freedom to distribute copies of free software (and charge for
-them if you wish), that you receive source code or can get it if you
-want it, that you can change the software or use pieces of it in new
-free programs, and that you know you can do these things.
-
-  Developers that use our General Public Licenses protect your rights
-with two steps: (1) assert copyright on the software, and (2) offer
-you this License which gives you legal permission to copy, distribute
-and/or modify the software.
-
-  A secondary benefit of defending all users' freedom is that
-improvements made in alternate versions of the program, if they
-receive widespread use, become available for other developers to
-incorporate.  Many developers of free software are heartened and
-encouraged by the resulting cooperation.  However, in the case of
-software used on network servers, this result may fail to come about.
-The GNU General Public License permits making a modified version and
-letting the public access it on a server without ever releasing its
-source code to the public.
-
-  The GNU Affero General Public License is designed specifically to
-ensure that, in such cases, the modified source code becomes available
-to the community.  It requires the operator of a network server to
-provide the source code of the modified version running there to the
-users of that server.  Therefore, public use of a modified version, on
-a publicly accessible server, gives the public access to the source
-code of the modified version.
-
-  An older license, called the Affero General Public License and
-published by Affero, was designed to accomplish similar goals.  This is
-a different license, not a version of the Affero GPL, but Affero has
-released a new version of the Affero GPL which permits relicensing under
-this license.
-
-  The precise terms and conditions for copying, distribution and
-modification follow.
-
-                       TERMS AND CONDITIONS
-
-  0. Definitions.
-
-  "This License" refers to version 3 of the GNU Affero General Public License.
-
-  "Copyright" also means copyright-like laws that apply to other kinds of
-works, such as semiconductor masks.
-
-  "The Program" refers to any copyrightable work licensed under this
-License.  Each licensee is addressed as "you".  "Licensees" and
-"recipients" may be individuals or organizations.
-
-  To "modify" a work means to copy from or adapt all or part of the work
-in a fashion requiring copyright permission, other than the making of an
-exact copy.  The resulting work is called a "modified version" of the
-earlier work or a work "based on" the earlier work.
-
-  A "covered work" means either the unmodified Program or a work based
-on the Program.
-
-  To "propagate" a work means to do anything with it that, without
-permission, would make you directly or secondarily liable for
-infringement under applicable copyright law, except executing it on a
-computer or modifying a private copy.  Propagation includes copying,
-distribution (with or without modification), making available to the
-public, and in some countries other activities as well.
-
-  To "convey" a work means any kind of propagation that enables other
-parties to make or receive copies.  Mere interaction with a user through
-a computer network, with no transfer of a copy, is not conveying.
-
-  An interactive user interface displays "Appropriate Legal Notices"
-to the extent that it includes a convenient and prominently visible
-feature that (1) displays an appropriate copyright notice, and (2)
-tells the user that there is no warranty for the work (except to the
-extent that warranties are provided), that licensees may convey the
-work under this License, and how to view a copy of this License.  If
-the interface presents a list of user commands or options, such as a
-menu, a prominent item in the list meets this criterion.
-
-  1. Source Code.
-
-  The "source code" for a work means the preferred form of the work
-for making modifications to it.  "Object code" means any non-source
-form of a work.
-
-  A "Standard Interface" means an interface that either is an official
-standard defined by a recognized standards body, or, in the case of
-interfaces specified for a particular programming language, one that
-is widely used among developers working in that language.
-
-  The "System Libraries" of an executable work include anything, other
-than the work as a whole, that (a) is included in the normal form of
-packaging a Major Component, but which is not part of that Major
-Component, and (b) serves only to enable use of the work with that
-Major Component, or to implement a Standard Interface for which an
-implementation is available to the public in source code form.  A
-"Major Component", in this context, means a major essential component
-(kernel, window system, and so on) of the specific operating system
-(if any) on which the executable work runs, or a compiler used to
-produce the work, or an object code interpreter used to run it.
-
-  The "Corresponding Source" for a work in object code form means all
-the source code needed to generate, install, and (for an executable
-work) run the object code and to modify the work, including scripts to
-control those activities.  However, it does not include the work's
-System Libraries, or general-purpose tools or generally available free
-programs which are used unmodified in performing those activities but
-which are not part of the work.  For example, Corresponding Source
-includes interface definition files associated with source files for
-the work, and the source code for shared libraries and dynamically
-linked subprograms that the work is specifically designed to require,
-such as by intimate data communication or control flow between those
-subprograms and other parts of the work.
-
-  The Corresponding Source need not include anything that users
-can regenerate automatically from other parts of the Corresponding
-Source.
-
-  The Corresponding Source for a work in source code form is that
-same work.
-
-  2. Basic Permissions.
-
-  All rights granted under this License are granted for the term of
-copyright on the Program, and are irrevocable provided the stated
-conditions are met.  This License explicitly affirms your unlimited
-permission to run the unmodified Program.  The output from running a
-covered work is covered by this License only if the output, given its
-content, constitutes a covered work.  This License acknowledges your
-rights of fair use or other equivalent, as provided by copyright law.
-
-  You may make, run and propagate covered works that you do not
-convey, without conditions so long as your license otherwise remains
-in force.  You may convey covered works to others for the sole purpose
-of having them make modifications exclusively for you, or provide you
-with facilities for running those works, provided that you comply with
-the terms of this License in conveying all material for which you do
-not control copyright.  Those thus making or running the covered works
-for you must do so exclusively on your behalf, under your direction
-and control, on terms that prohibit them from making any copies of
-your copyrighted material outside their relationship with you.
-
-  Conveying under any other circumstances is permitted solely under
-the conditions stated below.  Sublicensing is not allowed; section 10
-makes it unnecessary.
-
-  3. Protecting Users' Legal Rights From Anti-Circumvention Law.
-
-  No covered work shall be deemed part of an effective technological
-measure under any applicable law fulfilling obligations under article
-11 of the WIPO copyright treaty adopted on 20 December 1996, or
-similar laws prohibiting or restricting circumvention of such
-measures.
-
-  When you convey a covered work, you waive any legal power to forbid
-circumvention of technological measures to the extent such circumvention
-is effected by exercising rights under this License with respect to
-the covered work, and you disclaim any intention to limit operation or
-modification of the work as a means of enforcing, against the work's
-users, your or third parties' legal rights to forbid circumvention of
-technological measures.
-
-  4. Conveying Verbatim Copies.
-
-  You may convey verbatim copies of the Program's source code as you
-receive it, in any medium, provided that you conspicuously and
-appropriately publish on each copy an appropriate copyright notice;
-keep intact all notices stating that this License and any
-non-permissive terms added in accord with section 7 apply to the code;
-keep intact all notices of the absence of any warranty; and give all
-recipients a copy of this License along with the Program.
-
-  You may charge any price or no price for each copy that you convey,
-and you may offer support or warranty protection for a fee.
-
-  5. Conveying Modified Source Versions.
-
-  You may convey a work based on the Program, or the modifications to
-produce it from the Program, in the form of source code under the
-terms of section 4, provided that you also meet all of these conditions:
-
-    a) The work must carry prominent notices stating that you modified
-    it, and giving a relevant date.
-
-    b) The work must carry prominent notices stating that it is
-    released under this License and any conditions added under section
-    7.  This requirement modifies the requirement in section 4 to
-    "keep intact all notices".
-
-    c) You must license the entire work, as a whole, under this
-    License to anyone who comes into possession of a copy.  This
-    License will therefore apply, along with any applicable section 7
-    additional terms, to the whole of the work, and all its parts,
-    regardless of how they are packaged.  This License gives no
-    permission to license the work in any other way, but it does not
-    invalidate such permission if you have separately received it.
-
-    d) If the work has interactive user interfaces, each must display
-    Appropriate Legal Notices; however, if the Program has interactive
-    interfaces that do not display Appropriate Legal Notices, your
-    work need not make them do so.
-
-  A compilation of a covered work with other separate and independent
-works, which are not by their nature extensions of the covered work,
-and which are not combined with it such as to form a larger program,
-in or on a volume of a storage or distribution medium, is called an
-"aggregate" if the compilation and its resulting copyright are not
-used to limit the access or legal rights of the compilation's users
-beyond what the individual works permit.  Inclusion of a covered work
-in an aggregate does not cause this License to apply to the other
-parts of the aggregate.
-
-  6. Conveying Non-Source Forms.
-
-  You may convey a covered work in object code form under the terms
-of sections 4 and 5, provided that you also convey the
-machine-readable Corresponding Source under the terms of this License,
-in one of these ways:
-
-    a) Convey the object code in, or embodied in, a physical product
-    (including a physical distribution medium), accompanied by the
-    Corresponding Source fixed on a durable physical medium
-    customarily used for software interchange.
-
-    b) Convey the object code in, or embodied in, a physical product
-    (including a physical distribution medium), accompanied by a
-    written offer, valid for at least three years and valid for as
-    long as you offer spare parts or customer support for that product
-    model, to give anyone who possesses the object code either (1) a
-    copy of the Corresponding Source for all the software in the
-    product that is covered by this License, on a durable physical
-    medium customarily used for software interchange, for a price no
-    more than your reasonable cost of physically performing this
-    conveying of source, or (2) access to copy the
-    Corresponding Source from a network server at no charge.
-
-    c) Convey individual copies of the object code with a copy of the
-    written offer to provide the Corresponding Source.  This
-    alternative is allowed only occasionally and noncommercially, and
-    only if you received the object code with such an offer, in accord
-    with subsection 6b.
-
-    d) Convey the object code by offering access from a designated
-    place (gratis or for a charge), and offer equivalent access to the
-    Corresponding Source in the same way through the same place at no
-    further charge.  You need not require recipients to copy the
-    Corresponding Source along with the object code.  If the place to
-    copy the object code is a network server, the Corresponding Source
-    may be on a different server (operated by you or a third party)
-    that supports equivalent copying facilities, provided you maintain
-    clear directions next to the object code saying where to find the
-    Corresponding Source.  Regardless of what server hosts the
-    Corresponding Source, you remain obligated to ensure that it is
-    available for as long as needed to satisfy these requirements.
-
-    e) Convey the object code using peer-to-peer transmission, provided
-    you inform other peers where the object code and Corresponding
-    Source of the work are being offered to the general public at no
-    charge under subsection 6d.
-
-  A separable portion of the object code, whose source code is excluded
-from the Corresponding Source as a System Library, need not be
-included in conveying the object code work.
-
-  A "User Product" is either (1) a "consumer product", which means any
-tangible personal property which is normally used for personal, family,
-or household purposes, or (2) anything designed or sold for incorporation
-into a dwelling.  In determining whether a product is a consumer product,
-doubtful cases shall be resolved in favor of coverage.  For a particular
-product received by a particular user, "normally used" refers to a
-typical or common use of that class of product, regardless of the status
-of the particular user or of the way in which the particular user
-actually uses, or expects or is expected to use, the product.  A product
-is a consumer product regardless of whether the product has substantial
-commercial, industrial or non-consumer uses, unless such uses represent
-the only significant mode of use of the product.
-
-  "Installation Information" for a User Product means any methods,
-procedures, authorization keys, or other information required to install
-and execute modified versions of a covered work in that User Product from
-a modified version of its Corresponding Source.  The information must
-suffice to ensure that the continued functioning of the modified object
-code is in no case prevented or interfered with solely because
-modification has been made.
-
-  If you convey an object code work under this section in, or with, or
-specifically for use in, a User Product, and the conveying occurs as
-part of a transaction in which the right of possession and use of the
-User Product is transferred to the recipient in perpetuity or for a
-fixed term (regardless of how the transaction is characterized), the
-Corresponding Source conveyed under this section must be accompanied
-by the Installation Information.  But this requirement does not apply
-if neither you nor any third party retains the ability to install
-modified object code on the User Product (for example, the work has
-been installed in ROM).
-
-  The requirement to provide Installation Information does not include a
-requirement to continue to provide support service, warranty, or updates
-for a work that has been modified or installed by the recipient, or for
-the User Product in which it has been modified or installed.  Access to a
-network may be denied when the modification itself materially and
-adversely affects the operation of the network or violates the rules and
-protocols for communication across the network.
-
-  Corresponding Source conveyed, and Installation Information provided,
-in accord with this section must be in a format that is publicly
-documented (and with an implementation available to the public in
-source code form), and must require no special password or key for
-unpacking, reading or copying.
-
-  7. Additional Terms.
-
-  "Additional permissions" are terms that supplement the terms of this
-License by making exceptions from one or more of its conditions.
-Additional permissions that are applicable to the entire Program shall
-be treated as though they were included in this License, to the extent
-that they are valid under applicable law.  If additional permissions
-apply only to part of the Program, that part may be used separately
-under those permissions, but the entire Program remains governed by
-this License without regard to the additional permissions.
-
-  When you convey a copy of a covered work, you may at your option
-remove any additional permissions from that copy, or from any part of
-it.  (Additional permissions may be written to require their own
-removal in certain cases when you modify the work.)  You may place
-additional permissions on material, added by you to a covered work,
-for which you have or can give appropriate copyright permission.
-
-  Notwithstanding any other provision of this License, for material you
-add to a covered work, you may (if authorized by the copyright holders of
-that material) supplement the terms of this License with terms:
-
-    a) Disclaiming warranty or limiting liability differently from the
-    terms of sections 15 and 16 of this License; or
-
-    b) Requiring preservation of specified reasonable legal notices or
-    author attributions in that material or in the Appropriate Legal
-    Notices displayed by works containing it; or
-
-    c) Prohibiting misrepresentation of the origin of that material, or
-    requiring that modified versions of such material be marked in
-    reasonable ways as different from the original version; or
-
-    d) Limiting the use for publicity purposes of names of licensors or
-    authors of the material; or
-
-    e) Declining to grant rights under trademark law for use of some
-    trade names, trademarks, or service marks; or
-
-    f) Requiring indemnification of licensors and authors of that
-    material by anyone who conveys the material (or modified versions of
-    it) with contractual assumptions of liability to the recipient, for
-    any liability that these contractual assumptions directly impose on
-    those licensors and authors.
-
-  All other non-permissive additional terms are considered "further
-restrictions" within the meaning of section 10.  If the Program as you
-received it, or any part of it, contains a notice stating that it is
-governed by this License along with a term that is a further
-restriction, you may remove that term.  If a license document contains
-a further restriction but permits relicensing or conveying under this
-License, you may add to a covered work material governed by the terms
-of that license document, provided that the further restriction does
-not survive such relicensing or conveying.
-
-  If you add terms to a covered work in accord with this section, you
-must place, in the relevant source files, a statement of the
-additional terms that apply to those files, or a notice indicating
-where to find the applicable terms.
-
-  Additional terms, permissive or non-permissive, may be stated in the
-form of a separately written license, or stated as exceptions;
-the above requirements apply either way.
-
-  8. Termination.
-
-  You may not propagate or modify a covered work except as expressly
-provided under this License.  Any attempt otherwise to propagate or
-modify it is void, and will automatically terminate your rights under
-this License (including any patent licenses granted under the third
-paragraph of section 11).
-
-  However, if you cease all violation of this License, then your
-license from a particular copyright holder is reinstated (a)
-provisionally, unless and until the copyright holder explicitly and
-finally terminates your license, and (b) permanently, if the copyright
-holder fails to notify you of the violation by some reasonable means
-prior to 60 days after the cessation.
-
-  Moreover, your license from a particular copyright holder is
-reinstated permanently if the copyright holder notifies you of the
-violation by some reasonable means, this is the first time you have
-received notice of violation of this License (for any work) from that
-copyright holder, and you cure the violation prior to 30 days after
-your receipt of the notice.
-
-  Termination of your rights under this section does not terminate the
-licenses of parties who have received copies or rights from you under
-this License.  If your rights have been terminated and not permanently
-reinstated, you do not qualify to receive new licenses for the same
-material under section 10.
-
-  9. Acceptance Not Required for Having Copies.
-
-  You are not required to accept this License in order to receive or
-run a copy of the Program.  Ancillary propagation of a covered work
-occurring solely as a consequence of using peer-to-peer transmission
-to receive a copy likewise does not require acceptance.  However,
-nothing other than this License grants you permission to propagate or
-modify any covered work.  These actions infringe copyright if you do
-not accept this License.  Therefore, by modifying or propagating a
-covered work, you indicate your acceptance of this License to do so.
-
-  10. Automatic Licensing of Downstream Recipients.
-
-  Each time you convey a covered work, the recipient automatically
-receives a license from the original licensors, to run, modify and
-propagate that work, subject to this License.  You are not responsible
-for enforcing compliance by third parties with this License.
-
-  An "entity transaction" is a transaction transferring control of an
-organization, or substantially all assets of one, or subdividing an
-organization, or merging organizations.  If propagation of a covered
-work results from an entity transaction, each party to that
-transaction who receives a copy of the work also receives whatever
-licenses to the work the party's predecessor in interest had or could
-give under the previous paragraph, plus a right to possession of the
-Corresponding Source of the work from the predecessor in interest, if
-the predecessor has it or can get it with reasonable efforts.
-
-  You may not impose any further restrictions on the exercise of the
-rights granted or affirmed under this License.  For example, you may
-not impose a license fee, royalty, or other charge for exercise of
-rights granted under this License, and you may not initiate litigation
-(including a cross-claim or counterclaim in a lawsuit) alleging that
-any patent claim is infringed by making, using, selling, offering for
-sale, or importing the Program or any portion of it.
-
-  11. Patents.
-
-  A "contributor" is a copyright holder who authorizes use under this
-License of the Program or a work on which the Program is based.  The
-work thus licensed is called the contributor's "contributor version".
-
-  A contributor's "essential patent claims" are all patent claims
-owned or controlled by the contributor, whether already acquired or
-hereafter acquired, that would be infringed by some manner, permitted
-by this License, of making, using, or selling its contributor version,
-but do not include claims that would be infringed only as a
-consequence of further modification of the contributor version.  For
-purposes of this definition, "control" includes the right to grant
-patent sublicenses in a manner consistent with the requirements of
-this License.
-
-  Each contributor grants you a non-exclusive, worldwide, royalty-free
-patent license under the contributor's essential patent claims, to
-make, use, sell, offer for sale, import and otherwise run, modify and
-propagate the contents of its contributor version.
-
-  In the following three paragraphs, a "patent license" is any express
-agreement or commitment, however denominated, not to enforce a patent
-(such as an express permission to practice a patent or covenant not to
-sue for patent infringement).  To "grant" such a patent license to a
-party means to make such an agreement or commitment not to enforce a
-patent against the party.
-
-  If you convey a covered work, knowingly relying on a patent license,
-and the Corresponding Source of the work is not available for anyone
-to copy, free of charge and under the terms of this License, through a
-publicly available network server or other readily accessible means,
-then you must either (1) cause the Corresponding Source to be so
-available, or (2) arrange to deprive yourself of the benefit of the
-patent license for this particular work, or (3) arrange, in a manner
-consistent with the requirements of this License, to extend the patent
-license to downstream recipients.  "Knowingly relying" means you have
-actual knowledge that, but for the patent license, your conveying the
-covered work in a country, or your recipient's use of the covered work
-in a country, would infringe one or more identifiable patents in that
-country that you have reason to believe are valid.
-
-  If, pursuant to or in connection with a single transaction or
-arrangement, you convey, or propagate by procuring conveyance of, a
-covered work, and grant a patent license to some of the parties
-receiving the covered work authorizing them to use, propagate, modify
-or convey a specific copy of the covered work, then the patent license
-you grant is automatically extended to all recipients of the covered
-work and works based on it.
-
-  A patent license is "discriminatory" if it does not include within
-the scope of its coverage, prohibits the exercise of, or is
-conditioned on the non-exercise of one or more of the rights that are
-specifically granted under this License.  You may not convey a covered
-work if you are a party to an arrangement with a third party that is
-in the business of distributing software, under which you make payment
-to the third party based on the extent of your activity of conveying
-the work, and under which the third party grants, to any of the
-parties who would receive the covered work from you, a discriminatory
-patent license (a) in connection with copies of the covered work
-conveyed by you (or copies made from those copies), or (b) primarily
-for and in connection with specific products or compilations that
-contain the covered work, unless you entered into that arrangement,
-or that patent license was granted, prior to 28 March 2007.
-
-  Nothing in this License shall be construed as excluding or limiting
-any implied license or other defenses to infringement that may
-otherwise be available to you under applicable patent law.
-
-  12. No Surrender of Others' Freedom.
-
-  If conditions are imposed on you (whether by court order, agreement or
-otherwise) that contradict the conditions of this License, they do not
-excuse you from the conditions of this License.  If you cannot convey a
-covered work so as to satisfy simultaneously your obligations under this
-License and any other pertinent obligations, then as a consequence you may
-not convey it at all.  For example, if you agree to terms that obligate you
-to collect a royalty for further conveying from those to whom you convey
-the Program, the only way you could satisfy both those terms and this
-License would be to refrain entirely from conveying the Program.
-
-  13. Remote Network Interaction; Use with the GNU General Public License.
-
-  Notwithstanding any other provision of this License, if you modify the
-Program, your modified version must prominently offer all users
-interacting with it remotely through a computer network (if your version
-supports such interaction) an opportunity to receive the Corresponding
-Source of your version by providing access to the Corresponding Source
-from a network server at no charge, through some standard or customary
-means of facilitating copying of software.  This Corresponding Source
-shall include the Corresponding Source for any work covered by version 3
-of the GNU General Public License that is incorporated pursuant to the
-following paragraph.
-
-  Notwithstanding any other provision of this License, you have
-permission to link or combine any covered work with a work licensed
-under version 3 of the GNU General Public License into a single
-combined work, and to convey the resulting work.  The terms of this
-License will continue to apply to the part which is the covered work,
-but the work with which it is combined will remain governed by version
-3 of the GNU General Public License.
-
-  14. Revised Versions of this License.
-
-  The Free Software Foundation may publish revised and/or new versions of
-the GNU Affero General Public License from time to time.  Such new versions
-will be similar in spirit to the present version, but may differ in detail to
-address new problems or concerns.
-
-  Each version is given a distinguishing version number.  If the
-Program specifies that a certain numbered version of the GNU Affero General
-Public License "or any later version" applies to it, you have the
-option of following the terms and conditions either of that numbered
-version or of any later version published by the Free Software
-Foundation.  If the Program does not specify a version number of the
-GNU Affero General Public License, you may choose any version ever published
-by the Free Software Foundation.
-
-  If the Program specifies that a proxy can decide which future
-versions of the GNU Affero General Public License can be used, that proxy's
-public statement of acceptance of a version permanently authorizes you
-to choose that version for the Program.
-
-  Later license versions may give you additional or different
-permissions.  However, no additional obligations are imposed on any
-author or copyright holder as a result of your choosing to follow a
-later version.
-
-  15. Disclaimer of Warranty.
-
-  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
-APPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
-HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
-OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
-THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
-PURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
-IS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
-ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
-
-  16. Limitation of Liability.
-
-  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
-WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
-THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
-GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
-USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
-DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
-PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
-EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
-SUCH DAMAGES.
-
-  17. Interpretation of Sections 15 and 16.
-
-  If the disclaimer of warranty and limitation of liability provided
-above cannot be given local legal effect according to their terms,
-reviewing courts shall apply local law that most closely approximates
-an absolute waiver of all civil liability in connection with the
-Program, unless a warranty or assumption of liability accompanies a
-copy of the Program in return for a fee.
-
-                     END OF TERMS AND CONDITIONS
-
-            How to Apply These Terms to Your New Programs
-
-  If you develop a new program, and you want it to be of the greatest
-possible use to the public, the best way to achieve this is to make it
-free software which everyone can redistribute and change under these terms.
-
-  To do so, attach the following notices to the program.  It is safest
-to attach them to the start of each source file to most effectively
-state the exclusion of warranty; and each file should have at least
-the "copyright" line and a pointer to where the full notice is found.
-
-    <one line to give the program's name and a brief idea of what it does.>
-    Copyright (C) <year>  <name of author>
-
-    This program is free software: you can redistribute it and/or modify
-    it under the terms of the GNU Affero General Public License as published by
-    the Free Software Foundation, either version 3 of the License, or
-    (at your option) any later version.
-
-    This program is distributed in the hope that it will be useful,
-    but WITHOUT ANY WARRANTY; without even the implied warranty of
-    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-    GNU Affero General Public License for more details.
-
-    You should have received a copy of the GNU Affero General Public License
-    along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-Also add information on how to contact you by electronic and paper mail.
-
-  If your software can interact with users remotely through a computer
-network, you should also make sure that it provides a way for users to
-get its source.  For example, if your program is a web application, its
-interface could display a "Source" link that leads users to an archive
-of the code.  There are many ways you could offer source, and different
-solutions will be better for different programs; see section 13 for the
-specific requirements.
-
-  You should also get your employer (if you work as a programmer) or school,
-if any, to sign a "copyright disclaimer" for the program, if necessary.
-For more information on this, and how to apply and follow the GNU AGPL, see
-<http://www.gnu.org/licenses/>.
diff --git a/guacamole/LICENSE b/guacamole/LICENSE
new file mode 100644
index 0000000..540cdcf
--- /dev/null
+++ b/guacamole/LICENSE
@@ -0,0 +1,19 @@
+Copyright (C) 2013 Glyptodon LLC
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/guacamole/doc/example/guacamole.properties b/guacamole/doc/example/guacamole.properties
deleted file mode 100644
index ace043f..0000000
--- a/guacamole/doc/example/guacamole.properties
+++ /dev/null
@@ -1,26 +0,0 @@
-
-#    Guacamole - Clientless Remote Desktop
-#    Copyright (C) 2010  Michael Jumper
-#
-#    This program is free software: you can redistribute it and/or modify
-#    it under the terms of the GNU Affero General Public License as published by
-#    the Free Software Foundation, either version 3 of the License, or
-#    (at your option) any later version.
-#
-#    This program is distributed in the hope that it will be useful,
-#    but WITHOUT ANY WARRANTY; without even the implied warranty of
-#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-#    GNU Affero General Public License for more details.
-#
-#    You should have received a copy of the GNU Affero General Public License
-#    along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-
-# Hostname and port of guacamole proxy
-guacd-hostname: localhost
-guacd-port:     4822
-
-# Auth provider class (authenticates user/pass combination, needed if using the provided login screen)
-auth-provider: net.sourceforge.guacamole.net.basic.BasicFileAuthenticationProvider
-basic-user-mapping: /path/to/user-mapping.xml
-
diff --git a/guacamole/nb-configuration.xml b/guacamole/nb-configuration.xml
new file mode 100644
index 0000000..4da1f6c
--- /dev/null
+++ b/guacamole/nb-configuration.xml
@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project-shared-configuration>
+    <!--
+This file contains additional configuration written by modules in the NetBeans IDE.
+The configuration is intended to be shared among all the users of project and
+therefore it is assumed to be part of version control checkout.
+Without this configuration present, some functionality in the IDE may be limited or fail altogether.
+-->
+    <properties xmlns="http://www.netbeans.org/ns/maven-properties-data/1">
+        <!--
+Properties that influence various parts of the IDE, especially code formatting and the like. 
+You can copy and paste the single properties, into the pom.xml file and the IDE will pick them up.
+That way multiple projects can share the same settings (useful for formatting rules for example).
+Any value defined here will override the pom.xml file value but is only applicable to the current project.
+-->
+        <org-netbeans-modules-maven-jaxws.rest_2e_config_2e_type>ide</org-netbeans-modules-maven-jaxws.rest_2e_config_2e_type>
+    </properties>
+</project-shared-configuration>
diff --git a/guacamole/pom.xml b/guacamole/pom.xml
index f8afcbf..80b18fd 100644
--- a/guacamole/pom.xml
+++ b/guacamole/pom.xml
@@ -5,7 +5,7 @@
     <groupId>org.glyptodon.guacamole</groupId>
     <artifactId>guacamole</artifactId>
     <packaging>war</packaging>
-    <version>0.8.3</version>
+    <version>0.9.9</version>
     <name>guacamole</name>
     <url>http://guac-dev.org/</url>
 
@@ -17,8 +17,8 @@
     <!-- All applicable licenses -->
     <licenses>
         <license>
-            <name>GNU Affero General Public License</name>
-            <url>http://www.gnu.org/licenses/agpl-3.0.html</url>
+            <name>The MIT License</name>
+            <url>http://www.opensource.org/licenses/mit-license.php</url>
             <distribution>repo</distribution>
         </license>
     </licenses>
@@ -54,25 +54,36 @@
             <plugin>
                 <groupId>org.apache.maven.plugins</groupId>
                 <artifactId>maven-compiler-plugin</artifactId>
+                <version>3.3</version>
                 <configuration>
                     <source>1.6</source>
                     <target>1.6</target>
+                    <compilerArgs>
+                        <arg>-Xlint:all</arg>
+                        <arg>-Werror</arg>
+                    </compilerArgs>
+                    <fork>true</fork>
                 </configuration>
             </plugin>
 
             <plugin>
                 <groupId>org.apache.maven.plugins</groupId>
                 <artifactId>maven-war-plugin</artifactId>
+                <version>2.6</version>
                 <configuration>
 
-                    <!-- Filter webapp dir --> 
+                    <!-- Filter translation strings -->
                     <webResources>
                         <resource>
                             <directory>src/main/webapp</directory>
                             <filtering>true</filtering>
+                            <includes>
+                                <include>translations/*.json</include>
+                                <include>index.html</include>
+                            </includes>
                         </resource>
                     </webResources>
-                    
+
                     <!-- Add files from guacamole-common-js -->
                     <overlays>
                         <overlay>
@@ -81,10 +92,98 @@
                             <type>zip</type>
                         </overlay>
                     </overlays>
-                    
+            
+                </configuration>
+                <executions>
+                    <execution>
+                        <id>default-cli</id>
+                        <phase>process-resources</phase>
+                        <goals>
+                            <goal>exploded</goal>
+                        </goals>
+                    </execution>
+                </executions>
+            </plugin>
+
+            <!-- Pre-cache Angular templates with maven-angular-plugin -->
+            <plugin>
+                <groupId>com.keithbranton.mojo</groupId>
+                <artifactId>angular-maven-plugin</artifactId>
+                <version>0.3.2</version>
+                <executions>
+                    <execution>
+                        <phase>generate-resources</phase>
+                        <goals>
+                            <goal>html2js</goal>
+                        </goals>
+                    </execution>
+                </executions>
+                <configuration>
+                    <sourceDir>${basedir}/src/main/webapp/app/</sourceDir>
+                    <include>**/*.html</include>
+                    <target>${basedir}/src/main/webapp/generated/templates-main/templates.js</target>
+                    <prefix>app</prefix>
                 </configuration>
             </plugin>
 
+            <!-- JS/CSS Minification Plugin -->
+            <plugin>
+                <groupId>com.samaxes.maven</groupId>
+                <artifactId>minify-maven-plugin</artifactId>
+                <version>1.6.1</version>
+                <executions>
+                    <execution>
+                        <id>default-cli</id>
+                        <configuration>
+                            <charset>UTF-8</charset>
+
+                            <webappSourceDir>${project.build.directory}/${project.build.finalName}</webappSourceDir>
+
+                            <cssSourceDir>/</cssSourceDir>
+                            <cssTargetDir>/</cssTargetDir>
+                            <cssFinalFile>guacamole.css</cssFinalFile>
+
+                            <cssSourceFiles>
+                                <cssSourceFile>license.txt</cssSourceFile>
+                            </cssSourceFiles>
+                            
+                            <cssSourceIncludes>
+                                <cssSourceInclude>app/**/*.css</cssSourceInclude>
+                            </cssSourceIncludes>
+
+                            <jsSourceDir>/</jsSourceDir>
+                            <jsTargetDir>/</jsTargetDir>
+                            <jsFinalFile>guacamole.js</jsFinalFile>
+                            
+                            <jsSourceFiles>
+                                <jsSourceFile>lib/jquery/jquery.js</jsSourceFile>
+                                <jsSourceFile>lib/angular/angular.min.js</jsSourceFile>
+                                <jsSourceFile>lib/angular-module-shim/angular-module-shim.js</jsSourceFile>
+                                <jsSourceFile>lib/messageformat/messageformat.js</jsSourceFile>
+                                <jsSourceFile>license.txt</jsSourceFile>
+                                <jsSourceFile>guacamole-common-js/all.js</jsSourceFile>
+                            </jsSourceFiles>
+
+                            <jsSourceIncludes>
+                                <jsSourceInclude>lib/**/*.js</jsSourceInclude>
+                                <jsSourceInclude>app/**/*.js</jsSourceInclude>
+                                <jsSourceInclude>generated/**/*.js</jsSourceInclude>
+                            </jsSourceIncludes>
+
+                            <!-- Do not minify and include tests -->
+                            <jsSourceExcludes>
+                                <jsSourceExclude>**/*.test.js</jsSourceExclude>
+                            </jsSourceExcludes>
+                            <jsEngine>CLOSURE</jsEngine>
+                            
+                        </configuration>
+                        <goals>
+                            <goal>minify</goal>
+                        </goals>
+                    </execution>
+                </executions>
+            </plugin>
+
         </plugins>
     </build>
 
@@ -98,42 +197,142 @@
             <scope>provided</scope>
         </dependency>
 
-        <!-- SLF4J - logging -->
+        <!-- JSR 356 WebSocket API -->
+        <dependency>
+            <groupId>javax.websocket</groupId>
+            <artifactId>javax.websocket-api</artifactId>
+            <version>1.0</version>
+            <scope>provided</scope>
+        </dependency>
+
+        <!-- Logging -->
         <dependency>
             <groupId>org.slf4j</groupId>
             <artifactId>slf4j-api</artifactId>
-            <version>1.6.1</version>
+            <version>1.7.7</version>
         </dependency>
         <dependency>
-            <groupId>org.slf4j</groupId>
-            <artifactId>slf4j-jcl</artifactId>
-            <version>1.6.1</version>
-            <scope>runtime</scope>
+            <groupId>ch.qos.logback</groupId>
+            <artifactId>logback-classic</artifactId>
+            <version>1.1.2</version>
         </dependency>
-
+        
         <!-- Guacamole Java API -->
         <dependency>
             <groupId>org.glyptodon.guacamole</groupId>
             <artifactId>guacamole-common</artifactId>
-            <version>0.8.0</version>
+            <version>0.9.9</version>
         </dependency>
 
         <!-- Guacamole Extension API -->
         <dependency>
             <groupId>org.glyptodon.guacamole</groupId>
             <artifactId>guacamole-ext</artifactId>
-            <version>0.8.1</version>
+            <version>0.9.9</version>
         </dependency>
 
         <!-- Guacamole JavaScript API -->
         <dependency>
             <groupId>org.glyptodon.guacamole</groupId>
             <artifactId>guacamole-common-js</artifactId>
-            <version>0.7.4</version>
+            <version>0.9.9</version>
             <type>zip</type>
             <scope>runtime</scope>
         </dependency>
 
+        <!-- Jetty 8 servlet API (websocket)  -->
+        <dependency>
+            <groupId>org.eclipse.jetty</groupId>
+            <artifactId>jetty-websocket</artifactId>
+            <version>8.1.1.v20120215</version>
+            <scope>provided</scope>
+        </dependency>
+
+        <!-- Jetty 9.0 servlet API (websocket)  -->
+        <dependency>
+            <groupId>org.eclipse.jetty</groupId>
+            <artifactId>jetty-parent</artifactId>
+            <version>20</version>
+            <scope>provided</scope>
+            <type>pom</type>
+        </dependency>
+        <dependency>
+            <groupId>org.eclipse.jetty.websocket</groupId>
+            <artifactId>websocket-api</artifactId>
+            <version>9.0.7.v20131107</version>
+            <scope>provided</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.eclipse.jetty.websocket</groupId>
+            <artifactId>websocket-servlet</artifactId>
+            <version>9.0.7.v20131107</version>
+            <scope>provided</scope>
+        </dependency>
+
+        <!-- Tomcat servlet API (websocket)  -->
+        <dependency>
+            <groupId>org.apache.tomcat</groupId>
+            <artifactId>tomcat-catalina</artifactId>
+            <version>7.0.37</version>
+            <scope>provided</scope>
+        </dependency>
+
+        <dependency>
+            <groupId>org.apache.tomcat</groupId>
+            <artifactId>tomcat-coyote</artifactId>
+            <version>7.0.37</version>
+            <scope>provided</scope>
+        </dependency>
+
+        <!-- Guice - Dependency Injection -->
+        <dependency>
+            <groupId>com.google.inject</groupId>
+            <artifactId>guice</artifactId>
+            <version>3.0</version>
+        </dependency>
+        
+        <!-- Guice Servlet -->
+        <dependency>
+            <groupId>com.google.inject.extensions</groupId>
+            <artifactId>guice-servlet</artifactId>
+            <version>3.0</version>
+        </dependency>
+        
+        <!-- Jersey - JAX-RS Implementation -->
+        <dependency>
+            <groupId>com.sun.jersey</groupId>
+            <artifactId>jersey-server</artifactId>
+            <version>1.17.1</version>
+        </dependency>
+
+        <!-- Jersey - Guice extension -->
+        <dependency>
+            <groupId>com.sun.jersey.contribs</groupId>
+            <artifactId>jersey-guice</artifactId>
+            <version>1.17.1</version>
+        </dependency> 
+        
+        <!-- JSR-250 annotations -->
+        <dependency>
+            <groupId>javax.annotation</groupId>
+            <artifactId>jsr250-api</artifactId>
+            <version>1.0</version>
+        </dependency>
+        
+        <!-- Apache commons codec library -->
+        <dependency>
+            <groupId>commons-codec</groupId>
+            <artifactId>commons-codec</artifactId>
+            <version>1.4</version>
+        </dependency>
+        
+        <!-- Jackson for JSON support -->
+        <dependency>
+            <groupId>com.sun.jersey</groupId>
+            <artifactId>jersey-json</artifactId>
+            <version>1.17.1</version>
+        </dependency>
+
     </dependencies>
 
 </project>
diff --git a/guacamole/src/main/java/net/sourceforge/guacamole/net/basic/BasicFileAuthenticationProvider.java b/guacamole/src/main/java/net/sourceforge/guacamole/net/basic/BasicFileAuthenticationProvider.java
index d7da4a9..ba3d441 100644
--- a/guacamole/src/main/java/net/sourceforge/guacamole/net/basic/BasicFileAuthenticationProvider.java
+++ b/guacamole/src/main/java/net/sourceforge/guacamole/net/basic/BasicFileAuthenticationProvider.java
@@ -1,24 +1,27 @@
-
-package net.sourceforge.guacamole.net.basic;
-
 /*
- *  Guacamole - Clientless Remote Desktop
- *  Copyright (C) 2010  Michael Jumper
+ * Copyright (C) 2013 Glyptodon LLC
  *
- *  This program is free software: you can redistribute it and/or modify
- *  it under the terms of the GNU Affero General Public License as published by
- *  the Free Software Foundation, either version 3 of the License, or
- *  (at your option) any later version.
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
  *
- *  This program is distributed in the hope that it will be useful,
- *  but WITHOUT ANY WARRANTY; without even the implied warranty of
- *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- *  GNU Affero General Public License for more details.
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
  *
- *  You should have received a copy of the GNU Affero General Public License
- *  along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
  */
 
+package net.sourceforge.guacamole.net.basic;
+
 import java.io.BufferedInputStream;
 import java.io.File;
 import java.io.FileInputStream;
@@ -26,14 +29,15 @@ import java.io.IOException;
 import java.io.InputStream;
 import java.util.Map;
 import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.environment.Environment;
+import org.glyptodon.guacamole.environment.LocalEnvironment;
 import org.glyptodon.guacamole.net.auth.Credentials;
 import org.glyptodon.guacamole.net.auth.simple.SimpleAuthenticationProvider;
 import org.glyptodon.guacamole.net.basic.auth.Authorization;
 import org.glyptodon.guacamole.net.basic.auth.UserMapping;
-import org.glyptodon.guacamole.net.basic.xml.DocumentHandler;
-import org.glyptodon.guacamole.net.basic.xml.user_mapping.UserMappingTagHandler;
+import org.glyptodon.guacamole.xml.DocumentHandler;
+import org.glyptodon.guacamole.net.basic.xml.usermapping.UserMappingTagHandler;
 import org.glyptodon.guacamole.properties.FileGuacamoleProperty;
-import org.glyptodon.guacamole.properties.GuacamoleProperties;
 import org.glyptodon.guacamole.protocol.GuacamoleConfiguration;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -54,20 +58,27 @@ public class BasicFileAuthenticationProvider extends SimpleAuthenticationProvide
     /**
      * Logger for this class.
      */
-    private Logger logger = LoggerFactory.getLogger(BasicFileAuthenticationProvider.class);
+    private final Logger logger = LoggerFactory.getLogger(BasicFileAuthenticationProvider.class);
 
     /**
-     * The time the user mapping file was last modified.
+     * The time the user mapping file was last modified. If the file has never
+     * been read, and thus no modification time exists, this will be
+     * Long.MIN_VALUE.
      */
-    private long mod_time;
+    private long lastModified = Long.MIN_VALUE;
 
     /**
      * The parsed UserMapping read when the user mapping file was last parsed.
      */
-    private UserMapping user_mapping;
+    private UserMapping cachedUserMapping;
 
     /**
-     * The filename of the XML file to read the user user_mapping from.
+     * Guacamole server environment.
+     */
+    private final Environment environment;
+
+    /**
+     * The XML file to read the user mapping from.
      */
     public static final FileGuacamoleProperty BASIC_USER_MAPPING = new FileGuacamoleProperty() {
 
@@ -77,28 +88,65 @@ public class BasicFileAuthenticationProvider extends SimpleAuthenticationProvide
     };
 
     /**
+     * The default filename to use for the user mapping, if not defined within
+     * guacamole.properties.
+     */
+    public static final String DEFAULT_USER_MAPPING = "user-mapping.xml";
+    
+    /**
+     * Creates a new BasicFileAuthenticationProvider that authenticates users
+     * against simple, monolithic XML file.
+     *
+     * @throws GuacamoleException
+     *     If a required property is missing, or an error occurs while parsing
+     *     a property.
+     */
+    public BasicFileAuthenticationProvider() throws GuacamoleException {
+        environment = new LocalEnvironment();
+    }
+
+    @Override
+    public String getIdentifier() {
+        return "default";
+    }
+
+    /**
      * Returns a UserMapping containing all authorization data given within
      * the XML file specified by the "basic-user-mapping" property in
      * guacamole.properties. If the XML file has been modified or has not yet
      * been read, this function may reread the file.
      *
-     * @return A UserMapping containing all authorization data within the
-     *         user mapping XML file.
-     * @throws GuacamoleException If the user mapping property is missing or
-     *                            an error occurs while parsing the XML file.
+     * @return
+     *     A UserMapping containing all authorization data within the user
+     *     mapping XML file, or null if the file cannot be found/parsed.
      */
-    private UserMapping getUserMapping() throws GuacamoleException {
+    private UserMapping getUserMapping() {
+
+        // Get user mapping file, defaulting to GUACAMOLE_HOME/user-mapping.xml
+        File userMappingFile;
+        try {
+            userMappingFile = environment.getProperty(BASIC_USER_MAPPING);
+            if (userMappingFile == null)
+                userMappingFile = new File(environment.getGuacamoleHome(), DEFAULT_USER_MAPPING);
+        }
 
-        // Get user user_mapping file
-        File user_mapping_file =
-                GuacamoleProperties.getRequiredProperty(BASIC_USER_MAPPING);
+        // Abort if property cannot be parsed
+        catch (GuacamoleException e) {
+            logger.warn("Unable to read user mapping filename from properties: {}", e.getMessage());
+            logger.debug("Error parsing user mapping property.", e);
+            return null;
+        }
+
+        // Abort if user mapping does not exist
+        if (!userMappingFile.exists()) {
+            logger.debug("User mapping file \"{}\" does not exist and will not be read.", userMappingFile);
+            return null;
+        }
 
-        // If user_mapping not yet read, or user_mapping has been modified, reread
-        if (user_mapping == null ||
-                (user_mapping_file.exists()
-                 && mod_time < user_mapping_file.lastModified())) {
+        // Refresh user mapping if file has changed
+        if (lastModified < userMappingFile.lastModified()) {
 
-            logger.info("Reading user mapping file: {}", user_mapping_file);
+            logger.debug("Reading user mapping file: \"{}\"", userMappingFile);
 
             // Parse document
             try {
@@ -116,26 +164,34 @@ public class BasicFileAuthenticationProvider extends SimpleAuthenticationProvide
                 parser.setContentHandler(contentHandler);
 
                 // Read and parse file
-                InputStream input = new BufferedInputStream(new FileInputStream(user_mapping_file));
+                InputStream input = new BufferedInputStream(new FileInputStream(userMappingFile));
                 parser.parse(new InputSource(input));
                 input.close();
 
                 // Store mod time and user mapping
-                mod_time = user_mapping_file.lastModified();
-                user_mapping = userMappingHandler.asUserMapping();
+                lastModified = userMappingFile.lastModified();
+                cachedUserMapping = userMappingHandler.asUserMapping();
 
             }
+
+            // If the file is unreadable, return no mapping
             catch (IOException e) {
-                throw new GuacamoleException("Error reading basic user mapping file.", e);
+                logger.warn("Unable to read user mapping file \"{}\": {}", userMappingFile, e.getMessage());
+                logger.debug("Error reading user mapping file.", e);
+                return null;
             }
+
+            // If the file cannot be parsed, return no mapping
             catch (SAXException e) {
-                throw new GuacamoleException("Error parsing basic user mapping XML.", e);
+                logger.warn("User mapping file \"{}\" is not valid: {}", userMappingFile, e.getMessage());
+                logger.debug("Error parsing user mapping file.", e);
+                return null;
             }
 
         }
 
         // Return (possibly cached) user mapping
-        return user_mapping;
+        return cachedUserMapping;
 
     }
 
@@ -144,8 +200,13 @@ public class BasicFileAuthenticationProvider extends SimpleAuthenticationProvide
             getAuthorizedConfigurations(Credentials credentials)
             throws GuacamoleException {
 
+        // Abort authorization if no user mapping exists
+        UserMapping userMapping = getUserMapping();
+        if (userMapping == null)
+            return null;
+
         // Validate and return info for given user and pass
-        Authorization auth = getUserMapping().getAuthorization(credentials.getUsername());
+        Authorization auth = userMapping.getAuthorization(credentials.getUsername());
         if (auth != null && auth.validate(credentials.getUsername(), credentials.getPassword()))
             return auth.getConfigurations();
 
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/AuthenticatingHttpServlet.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/AuthenticatingHttpServlet.java
deleted file mode 100644
index f42d88b..0000000
--- a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/AuthenticatingHttpServlet.java
+++ /dev/null
@@ -1,354 +0,0 @@
-
-package org.glyptodon.guacamole.net.basic;
-
-/*
- *  Guacamole - Clientless Remote Desktop
- *  Copyright (C) 2010  Michael Jumper
- *
- *  This program is free software: you can redistribute it and/or modify
- *  it under the terms of the GNU Affero General Public License as published by
- *  the Free Software Foundation, either version 3 of the License, or
- *  (at your option) any later version.
- *
- *  This program is distributed in the hope that it will be useful,
- *  but WITHOUT ANY WARRANTY; without even the implied warranty of
- *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- *  GNU Affero General Public License for more details.
- *
- *  You should have received a copy of the GNU Affero General Public License
- *  along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-import java.io.IOException;
-import java.io.UnsupportedEncodingException;
-import java.util.Collection;
-import javax.servlet.ServletException;
-import javax.servlet.http.HttpServlet;
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-import javax.servlet.http.HttpSession;
-import org.glyptodon.guacamole.GuacamoleClientException;
-import org.glyptodon.guacamole.GuacamoleException;
-import org.glyptodon.guacamole.GuacamoleResourceNotFoundException;
-import org.glyptodon.guacamole.GuacamoleSecurityException;
-import org.glyptodon.guacamole.net.auth.AuthenticationProvider;
-import org.glyptodon.guacamole.net.auth.Credentials;
-import org.glyptodon.guacamole.net.auth.UserContext;
-import org.glyptodon.guacamole.net.basic.event.SessionListenerCollection;
-import org.glyptodon.guacamole.net.basic.properties.BasicGuacamoleProperties;
-import org.glyptodon.guacamole.net.event.AuthenticationFailureEvent;
-import org.glyptodon.guacamole.net.event.AuthenticationSuccessEvent;
-import org.glyptodon.guacamole.net.event.listener.AuthenticationFailureListener;
-import org.glyptodon.guacamole.net.event.listener.AuthenticationSuccessListener;
-import org.glyptodon.guacamole.properties.GuacamoleProperties;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/**
- * Abstract servlet which provides an authenticatedService() function that
- * is only called if the HTTP request is authenticated, or the current
- * HTTP session has already been authenticated.
- *
- * The user context is retrieved using the authentication provider defined in
- * guacamole.properties. The authentication provider has access to the request
- * and session, in addition to any submitted username and password, in order
- * to authenticate the user.
- *
- * The user context will be stored in the current HttpSession.
- *
- * Success and failure are logged.
- *
- * @author Michael Jumper
- */
-public abstract class AuthenticatingHttpServlet extends HttpServlet {
-
-    /**
-     * Logger for this class.
-     */
-    private Logger logger = LoggerFactory.getLogger(AuthenticatingHttpServlet.class);
-
-    /**
-     * The session attribute holding the current UserContext.
-     */
-    private static final String CONTEXT_ATTRIBUTE = "GUAC_CONTEXT";
-
-    /**
-     * The session attribute holding the credentials authorizing this session.
-     */
-    private static final String CREDENTIALS_ATTRIBUTE = "GUAC_CREDS";
-
-    /**
-     * The AuthenticationProvider to use to authenticate all requests.
-     */
-    private AuthenticationProvider authProvider;
-
-    @Override
-    public void init() throws ServletException {
-
-        // Get auth provider instance
-        try {
-            authProvider = GuacamoleProperties.getRequiredProperty(BasicGuacamoleProperties.AUTH_PROVIDER);
-        }
-        catch (GuacamoleException e) {
-            logger.error("Error getting authentication provider from properties.", e);
-            throw new ServletException(e);
-        }
-
-    }
-
-    /**
-     * Notifies all listeners in the given collection that authentication has
-     * failed.
-     *
-     * @param listeners A collection of all listeners that should be notified.
-     * @param credentials The credentials associated with the authentication
-     *                    request that failed.
-     */
-    private void notifyFailed(Collection listeners, Credentials credentials) {
-
-        // Build event for auth failure
-        AuthenticationFailureEvent event = new AuthenticationFailureEvent(credentials);
-
-        // Notify all listeners
-        for (Object listener : listeners) {
-            try {
-                if (listener instanceof AuthenticationFailureListener)
-                    ((AuthenticationFailureListener) listener).authenticationFailed(event);
-            }
-            catch (GuacamoleException e) {
-                logger.error("Error notifying AuthenticationFailureListener.", e);
-            }
-        }
-
-    }
-
-    /**
-     * Notifies all listeners in the given collection that authentication was
-     * successful.
-     *
-     * @param listeners A collection of all listeners that should be notified.
-     * @param context The UserContext created as a result of authentication
-     *                success.
-     * @param credentials The credentials associated with the authentication
-     *                    request that succeeded.
-     * @return true if all listeners are allowing the authentication success,
-     *         or if there are no listeners, and false if any listener is
-     *         canceling the authentication success. Note that once one
-     *         listener cancels, no other listeners will run.
-     * @throws GuacamoleException If any listener throws an error while being
-     *                            notified. Note that if any listener throws an
-     *                            error, the success is canceled, and no other
-     *                            listeners will run.
-     */
-    private boolean notifySuccess(Collection listeners, UserContext context,
-            Credentials credentials) throws GuacamoleException {
-
-        // Build event for auth success
-        AuthenticationSuccessEvent event =
-                new AuthenticationSuccessEvent(context, credentials);
-
-        // Notify all listeners
-        for (Object listener : listeners) {
-            if (listener instanceof AuthenticationSuccessListener) {
-
-                // Cancel immediately if hook returns false
-                if (!((AuthenticationSuccessListener) listener).authenticationSucceeded(event))
-                    return false;
-
-            }
-        }
-
-        return true;
-
-    }
-
-    /**
-     * Sends an error on the given HTTP response with the given integer error
-     * code.
-     *
-     * @param response The HTTP response to use to send the error.
-     * @param code The HTTP status code of the error.
-     * @param message A human-readable message that can be presented to the
-     *                user.
-     * @throws ServletException If an error prevents sending of the error
-     *                          code.
-     */
-    private void sendError(HttpServletResponse response, int code,
-            String message) throws ServletException {
-
-        try {
-
-            // If response not committed, send error code
-            if (!response.isCommitted()) {
-                response.addHeader("Guacamole-Error-Message", message);
-                response.sendError(code);
-            }
-
-        }
-        catch (IOException ioe) {
-
-            // If unable to send error at all due to I/O problems,
-            // rethrow as servlet exception
-            throw new ServletException(ioe);
-
-        }
-
-    }
-
-    /**
-     * Returns the credentials associated with the given session.
-     *
-     * @param session The session to retrieve credentials from.
-     * @return The credentials associated with the given session.
-     */
-    protected Credentials getCredentials(HttpSession session) {
-        return (Credentials) session.getAttribute(CREDENTIALS_ATTRIBUTE);
-    }
-
-    /**
-     * Returns the UserContext associated with the given session.
-     *
-     * @param session The session to retrieve UserContext from.
-     * @return The UserContext associated with the given session.
-     */
-    protected UserContext getUserContext(HttpSession session) {
-        return (UserContext) session.getAttribute(CONTEXT_ATTRIBUTE);
-    }
-
-    /**
-     * Returns whether the request given has updated credentials. If this
-     * function returns false, the UserContext will not be updated.
-     * 
-     * @param request The request to check for credentials.
-     * @return true if the request contains credentials, false otherwise.
-     */
-    protected boolean hasNewCredentials(HttpServletRequest request) {
-        return true;
-    }
-    
-    @Override
-    protected void service(HttpServletRequest request, HttpServletResponse response)
-    throws IOException, ServletException {
-        
-        // Set character encoding to UTF-8 if it's not already set
-        if(request.getCharacterEncoding() == null) {
-            try {
-                request.setCharacterEncoding("UTF-8");
-            } catch (UnsupportedEncodingException exception) {
-               throw new ServletException(exception);
-            }
-        }
-
-        try {
-
-            // Obtain context from session
-            HttpSession httpSession = request.getSession(true);
-            UserContext context = getUserContext(httpSession);
-
-            // If new credentials present, update/create context
-            if (hasNewCredentials(request)) {
-
-                // Retrieve username and password from parms
-                String username = request.getParameter("username");
-                String password = request.getParameter("password");
-
-                // Build credentials object
-                Credentials credentials = new Credentials();
-                credentials.setSession(httpSession);
-                credentials.setRequest(request);
-                credentials.setUsername(username);
-                credentials.setPassword(password);
-
-                SessionListenerCollection listeners = new SessionListenerCollection(httpSession);
-
-                // If no cached context, attempt to get new context
-                if (context == null) {
-
-                    context = authProvider.getUserContext(credentials);
-
-                    // Log successful authentication
-                    if (context != null)
-                        logger.info("User \"{}\" successfully authenticated from {}.",
-                                context.self().getUsername(), request.getRemoteAddr());
-                    
-                }
-
-                // Otherwise, update existing context
-                else
-                    context = authProvider.updateUserContext(context, credentials);
-
-                // If auth failed, notify listeners
-                if (context == null) {
-                    logger.warn("Authentication attempt from {} for user \"{}\" failed.",
-                            request.getRemoteAddr(), credentials.getUsername());
-
-                    notifyFailed(listeners, credentials);
-                }
-
-                // If auth succeeded, notify and check with listeners
-                else if (!notifySuccess(listeners, context, credentials)) {
-                    logger.info("Successful authentication canceled by hook.");
-                    context = null;
-                }
-
-                // If auth still OK, associate context with session
-                else {
-                    httpSession.setAttribute(CONTEXT_ATTRIBUTE,     context);
-                    httpSession.setAttribute(CREDENTIALS_ATTRIBUTE, credentials);
-                }
-
-            } // end if credentials present
-
-            // If no context, no authorizaton present
-            if (context == null)
-                throw new GuacamoleSecurityException("Not authenticated");
-
-            // Allow servlet to run now that authentication has been validated
-            authenticatedService(context, request, response);
-
-        }
-
-        // Catch any thrown guacamole exception and attempt to pass within the
-        // HTTP response, logging each error appropriately.
-        catch (GuacamoleSecurityException e) {
-            logger.warn("Permission denied: {}", e.getMessage());
-            sendError(response, HttpServletResponse.SC_FORBIDDEN,
-                    "Permission denied.");
-        }
-        catch (GuacamoleResourceNotFoundException e) {
-            logger.debug("Resource not found: {}", e.getMessage());
-            sendError(response, HttpServletResponse.SC_NOT_FOUND,
-                    e.getMessage());
-        }
-        catch (GuacamoleClientException e) {
-            logger.warn("Error in client request: {}", e.getMessage());
-            sendError(response, HttpServletResponse.SC_BAD_REQUEST,
-                    e.getMessage());
-        }
-        catch (GuacamoleException e) {
-            logger.error("Internal server error.", e);
-            sendError(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
-                      "Internal server error.");
-        }
-
-    }
-
-    /**
-     * Function called after the credentials given in the request (if any)
-     * are authenticated. If the current session is not associated with
-     * valid credentials, this function will not be called.
-     *
-     * @param context The current UserContext.
-     * @param request The HttpServletRequest being serviced.
-     * @param response An HttpServletResponse which controls the HTTP response
-     *                 of this servlet.
-     *
-     * @throws GuacamoleException If an error occurs that interferes with the
-     *                            normal operation of this servlet.
-     */
-    protected abstract void authenticatedService(
-            UserContext context,
-            HttpServletRequest request, HttpServletResponse response)
-            throws GuacamoleException;
-
-}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/BasicGuacamoleTunnelServlet.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/BasicGuacamoleTunnelServlet.java
index 015a01c..be6e328 100644
--- a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/BasicGuacamoleTunnelServlet.java
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/BasicGuacamoleTunnelServlet.java
@@ -1,46 +1,32 @@
-package org.glyptodon.guacamole.net.basic;
-
 /*
- *  Guacamole - Clientless Remote Desktop
- *  Copyright (C) 2010  Michael Jumper
+ * Copyright (C) 2013 Glyptodon LLC
  *
- *  This program is free software: you can redistribute it and/or modify
- *  it under the terms of the GNU Affero General Public License as published by
- *  the Free Software Foundation, either version 3 of the License, or
- *  (at your option) any later version.
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
  *
- *  This program is distributed in the hope that it will be useful,
- *  but WITHOUT ANY WARRANTY; without even the implied warranty of
- *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- *  GNU Affero General Public License for more details.
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
  *
- *  You should have received a copy of the GNU Affero General Public License
- *  along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
  */
 
-import java.io.IOException;
-import java.util.Arrays;
-import java.util.Collection;
-import javax.servlet.ServletException;
+package org.glyptodon.guacamole.net.basic;
+
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
 import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-import javax.servlet.http.HttpSession;
-import org.glyptodon.guacamole.GuacamoleClientException;
 import org.glyptodon.guacamole.GuacamoleException;
-import org.glyptodon.guacamole.GuacamoleSecurityException;
-import org.glyptodon.guacamole.net.GuacamoleSocket;
 import org.glyptodon.guacamole.net.GuacamoleTunnel;
-import org.glyptodon.guacamole.net.auth.Connection;
-import org.glyptodon.guacamole.net.auth.ConnectionGroup;
-import org.glyptodon.guacamole.net.auth.Credentials;
-import org.glyptodon.guacamole.net.auth.Directory;
-import org.glyptodon.guacamole.net.auth.UserContext;
-import org.glyptodon.guacamole.net.basic.event.SessionListenerCollection;
-import org.glyptodon.guacamole.net.event.TunnelCloseEvent;
-import org.glyptodon.guacamole.net.event.TunnelConnectEvent;
-import org.glyptodon.guacamole.net.event.listener.TunnelCloseListener;
-import org.glyptodon.guacamole.net.event.listener.TunnelConnectListener;
-import org.glyptodon.guacamole.protocol.GuacamoleClientInformation;
 import org.glyptodon.guacamole.servlet.GuacamoleHTTPTunnelServlet;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -51,329 +37,31 @@ import org.slf4j.LoggerFactory;
  *
  * @author Michael Jumper
  */
-public class BasicGuacamoleTunnelServlet extends AuthenticatingHttpServlet {
-
-    /**
-     * Logger for this class.
-     */
-    private Logger logger = LoggerFactory.getLogger(BasicGuacamoleTunnelServlet.class);
+ at Singleton
+public class BasicGuacamoleTunnelServlet extends GuacamoleHTTPTunnelServlet {
 
     /**
-     * All supported identifier types.
+     * Service for handling tunnel requests.
      */
-    private static enum IdentifierType {
-
-        /**
-         * The unique identifier of a connection.
-         */
-        CONNECTION("c/"),
-
-        /**
-         * The unique identifier of a connection group.
-         */
-        CONNECTION_GROUP("g/");
-        
-        /**
-         * The prefix which precedes an identifier of this type.
-         */
-        final String PREFIX;
-        
-        /**
-         * Defines an IdentifierType having the given prefix.
-         * @param prefix The prefix which will precede any identifier of this
-         *               type, thus differentiating it from other identifier
-         *               types.
-         */
-        IdentifierType(String prefix) {
-            PREFIX = prefix;
-        }
-
-        /**
-         * Given an identifier, determines the corresponding identifier type.
-         * 
-         * @param identifier The identifier whose type should be identified.
-         * @return The identified identifier type.
-         */
-        static IdentifierType getType(String identifier) {
-
-            // If null, no known identifier
-            if (identifier == null)
-                return null;
-
-            // Connection identifiers
-            if (identifier.startsWith(CONNECTION.PREFIX))
-                return CONNECTION;
-            
-            // Connection group identifiers
-            if (identifier.startsWith(CONNECTION_GROUP.PREFIX))
-                return CONNECTION_GROUP;
-            
-            // Otherwise, unknown
-            return null;
-            
-        }
-        
-    };
+    @Inject
+    private TunnelRequestService tunnelRequestService;
     
-    @Override
-    protected void authenticatedService(
-            UserContext context,
-            HttpServletRequest request, HttpServletResponse response)
-    throws GuacamoleException {
-
-        try {
-
-            // If authenticated, respond as tunnel
-            tunnelServlet.service(request, response);
-        }
-
-        catch (ServletException e) {
-            logger.info("Error from tunnel (see previous log messages): {}",
-                    e.getMessage());
-        }
-
-        catch (IOException e) {
-            logger.info("I/O error from tunnel (see previous log messages): {}",
-                    e.getMessage());
-        }
-
-    }
-
-    /**
-     * Notifies all listeners in the given collection that a tunnel has been
-     * connected.
-     *
-     * @param listeners A collection of all listeners that should be notified.
-     * @param context The UserContext associated with the current session.
-     * @param credentials The credentials associated with the current session.
-     * @param tunnel The tunnel being connected.
-     * @return true if all listeners are allowing the tunnel to connect,
-     *         or if there are no listeners, and false if any listener is
-     *         canceling the connection. Note that once one listener cancels,
-     *         no other listeners will run.
-     * @throws GuacamoleException If any listener throws an error while being
-     *                            notified. Note that if any listener throws an
-     *                            error, the connect is canceled, and no other
-     *                            listeners will run.
-     */
-    private boolean notifyConnect(Collection listeners, UserContext context,
-            Credentials credentials, GuacamoleTunnel tunnel)
-            throws GuacamoleException {
-
-        // Build event for auth success
-        TunnelConnectEvent event = new TunnelConnectEvent(context,
-                credentials, tunnel);
-
-        // Notify all listeners
-        for (Object listener : listeners) {
-            if (listener instanceof TunnelConnectListener) {
-
-                // Cancel immediately if hook returns false
-                if (!((TunnelConnectListener) listener).tunnelConnected(event))
-                    return false;
-
-            }
-        }
-
-        return true;
-
-    }
-
     /**
-     * Notifies all listeners in the given collection that a tunnel has been
-     * closed.
-     *
-     * @param listeners A collection of all listeners that should be notified.
-     * @param context The UserContext associated with the current session.
-     * @param credentials The credentials associated with the current session.
-     * @param tunnel The tunnel being closed.
-     * @return true if all listeners are allowing the tunnel to close,
-     *         or if there are no listeners, and false if any listener is
-     *         canceling the close. Note that once one listener cancels,
-     *         no other listeners will run.
-     * @throws GuacamoleException If any listener throws an error while being
-     *                            notified. Note that if any listener throws an
-     *                            error, the close is canceled, and no other
-     *                            listeners will run.
-     */
-    private boolean notifyClose(Collection listeners, UserContext context,
-            Credentials credentials, GuacamoleTunnel tunnel)
-            throws GuacamoleException {
-
-        // Build event for auth success
-        TunnelCloseEvent event = new TunnelCloseEvent(context,
-                credentials, tunnel);
-
-        // Notify all listeners
-        for (Object listener : listeners) {
-            if (listener instanceof TunnelCloseListener) {
-
-                // Cancel immediately if hook returns false
-                if (!((TunnelCloseListener) listener).tunnelClosed(event))
-                    return false;
-
-            }
-        }
-
-        return true;
-
-    }
-
-    /**
-     * Wrapped GuacamoleHTTPTunnelServlet which will handle all authenticated
-     * requests.
+     * Logger for this class.
      */
-    private GuacamoleHTTPTunnelServlet tunnelServlet = new GuacamoleHTTPTunnelServlet() {
-
-        @Override
-        protected GuacamoleTunnel doConnect(HttpServletRequest request) throws GuacamoleException {
-
-            HttpSession httpSession = request.getSession(true);
-
-            // Get listeners
-            final SessionListenerCollection listeners;
-            try {
-                listeners = new SessionListenerCollection(httpSession);
-            }
-            catch (GuacamoleException e) {
-                logger.error("Failed to retrieve listeners. Authentication canceled.", e);
-                throw e;
-            }
-
-            // Get ID of connection
-            String id = request.getParameter("id");
-            IdentifierType id_type = IdentifierType.getType(id);
-
-            // Do not continue if unable to determine type
-            if (id_type == null)
-                throw new GuacamoleClientException("Illegal identifier - unknown type.");
-
-            // Remove prefix
-            id = id.substring(id_type.PREFIX.length());
-
-            // Get credentials
-            final Credentials credentials = getCredentials(httpSession);
-
-            // Get context
-            final UserContext context = getUserContext(httpSession);
-
-            // If no context or no credentials, not logged in
-            if (context == null || credentials == null)
-                throw new GuacamoleSecurityException("Cannot connect - user not logged in.");
-
-            // Get client information
-            GuacamoleClientInformation info = new GuacamoleClientInformation();
-
-            // Set width if provided
-            String width  = request.getParameter("width");
-            if (width != null)
-                info.setOptimalScreenWidth(Integer.parseInt(width));
-
-            // Set height if provided
-            String height = request.getParameter("height");
-            if (height != null)
-                info.setOptimalScreenHeight(Integer.parseInt(height));
-
-            // Add audio mimetypes
-            String[] audio_mimetypes = request.getParameterValues("audio");
-            if (audio_mimetypes != null)
-                info.getAudioMimetypes().addAll(Arrays.asList(audio_mimetypes));
-
-            // Add video mimetypes
-            String[] video_mimetypes = request.getParameterValues("video");
-            if (video_mimetypes != null)
-                info.getVideoMimetypes().addAll(Arrays.asList(video_mimetypes));
-
-            // Create connected socket from identifier
-            GuacamoleSocket socket;
-            switch (id_type) {
-
-                // Connection identifiers
-                case CONNECTION: {
-
-                    // Get connection directory
-                    Directory<String, Connection> directory =
-                        context.getRootConnectionGroup().getConnectionDirectory();
-
-                    // Get authorized connection
-                    Connection connection = directory.get(id);
-                    if (connection == null) {
-                        logger.warn("Connection id={} not found.", id);
-                        throw new GuacamoleSecurityException("Requested connection is not authorized.");
-                    }
-
-                    // Connect socket
-                    socket = connection.connect(info);
-                    logger.info("Successful connection from {} to \"{}\".", request.getRemoteAddr(), id);
-                    break;
-                }
-
-                // Connection group identifiers
-                case CONNECTION_GROUP: {
-
-                    // Get connection group directory
-                    Directory<String, ConnectionGroup> directory =
-                        context.getRootConnectionGroup().getConnectionGroupDirectory();
-
-                    // Get authorized connection group
-                    ConnectionGroup group = directory.get(id);
-                    if (group == null) {
-                        logger.warn("Connection group id={} not found.", id);
-                        throw new GuacamoleSecurityException("Requested connection group is not authorized.");
-                    }
-
-                    // Connect socket
-                    socket = group.connect(info);
-                    logger.info("Successful connection from {} to group \"{}\".", request.getRemoteAddr(), id);
-                    break;
-                }
-
-                // Fail if unsupported type
-                default:
-                    throw new GuacamoleClientException("Connection not supported for provided identifier type.");
-
-            }
-
-            // Associate socket with tunnel
-            GuacamoleTunnel tunnel = new GuacamoleTunnel(socket) {
-
-                @Override
-                public void close() throws GuacamoleException {
-
-                    // Only close if not canceled
-                    if (!notifyClose(listeners, context, credentials, this))
-                        throw new GuacamoleException("Tunnel close canceled by listener.");
-
-                    // Close if no exception due to listener
-                    super.close();
-
-                }
-
-            };
-
-            // Notify listeners about connection
-            if (!notifyConnect(listeners, context, credentials, tunnel)) {
-                logger.info("Connection canceled by listener.");
-                return null;
-            }
-
-            return tunnel;
-
-        }
-
-    };
+    private static final Logger logger = LoggerFactory.getLogger(BasicGuacamoleTunnelServlet.class);
 
     @Override
-    protected boolean hasNewCredentials(HttpServletRequest request) {
+    protected GuacamoleTunnel doConnect(HttpServletRequest request) throws GuacamoleException {
 
-        String query = request.getQueryString();
-        if (query == null)
-            return false;
+        // Attempt to create HTTP tunnel
+        GuacamoleTunnel tunnel = tunnelRequestService.createTunnel(new HTTPTunnelRequest(request));
 
-        // Only connections are given new credentials
-        return query.equals("connect");
+        // If successful, warn of lack of WebSocket
+        logger.info("Using HTTP tunnel (not WebSocket). Performance may be sub-optimal.");
+
+        return tunnel;
 
     }
 
 }
-
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/BasicLogin.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/BasicLogin.java
deleted file mode 100644
index af9d22f..0000000
--- a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/BasicLogin.java
+++ /dev/null
@@ -1,48 +0,0 @@
-package org.glyptodon.guacamole.net.basic;
-
-/*
- *  Guacamole - Clientless Remote Desktop
- *  Copyright (C) 2010  Michael Jumper
- *
- *  This program is free software: you can redistribute it and/or modify
- *  it under the terms of the GNU Affero General Public License as published by
- *  the Free Software Foundation, either version 3 of the License, or
- *  (at your option) any later version.
- *
- *  This program is distributed in the hope that it will be useful,
- *  but WITHOUT ANY WARRANTY; without even the implied warranty of
- *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- *  GNU Affero General Public License for more details.
- *
- *  You should have received a copy of the GNU Affero General Public License
- *  along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-import org.glyptodon.guacamole.net.auth.UserContext;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/**
- * Simple dummy AuthenticatingHttpServlet which provides an endpoint for arbitrary
- * authentication requests that do not expect a response.
- *
- * @author Michael Jumper
- */
-public class BasicLogin extends AuthenticatingHttpServlet {
-
-    /**
-     * Logger for this class.
-     */
-    private Logger logger = LoggerFactory.getLogger(BasicLogin.class);
-
-    @Override
-    protected void authenticatedService(
-            UserContext context,
-            HttpServletRequest request, HttpServletResponse response) {
-        logger.info("Login was successful.");
-    }
-
-}
-
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/BasicLogout.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/BasicLogout.java
deleted file mode 100644
index 6a5ab20..0000000
--- a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/BasicLogout.java
+++ /dev/null
@@ -1,50 +0,0 @@
-package org.glyptodon.guacamole.net.basic;
-
-/*
- *  Guacamole - Clientless Remote Desktop
- *  Copyright (C) 2010  Michael Jumper
- *
- *  This program is free software: you can redistribute it and/or modify
- *  it under the terms of the GNU Affero General Public License as published by
- *  the Free Software Foundation, either version 3 of the License, or
- *  (at your option) any later version.
- *
- *  This program is distributed in the hope that it will be useful,
- *  but WITHOUT ANY WARRANTY; without even the implied warranty of
- *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- *  GNU Affero General Public License for more details.
- *
- *  You should have received a copy of the GNU Affero General Public License
- *  along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-import java.io.IOException;
-import javax.servlet.http.HttpServlet;
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-import javax.servlet.http.HttpSession;
-
-/**
- * Logs out the current user by invalidating the associated HttpSession and
- * redirecting the user to the login page.
- *
- * @author Michael Jumper
- */
-public class BasicLogout extends HttpServlet {
-
-    @Override
-    protected void service(HttpServletRequest request, HttpServletResponse response)
-    throws IOException {
-
-        // Invalidate session, if any
-        HttpSession httpSession = request.getSession(false);
-        if (httpSession != null)
-            httpSession.invalidate();
-
-        // Redirect to index
-        response.sendRedirect("index.xhtml");
-
-    }
-
-}
-
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/BasicServletContextListener.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/BasicServletContextListener.java
new file mode 100644
index 0000000..d9962fe
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/BasicServletContextListener.java
@@ -0,0 +1,103 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.basic;
+
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+import com.google.inject.Stage;
+import com.google.inject.servlet.GuiceServletContextListener;
+import javax.servlet.ServletContextEvent;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.environment.Environment;
+import org.glyptodon.guacamole.environment.LocalEnvironment;
+import org.glyptodon.guacamole.net.basic.extension.ExtensionModule;
+import org.glyptodon.guacamole.net.basic.log.LogModule;
+import org.glyptodon.guacamole.net.basic.rest.RESTServiceModule;
+import org.glyptodon.guacamole.net.basic.rest.auth.BasicTokenSessionMap;
+import org.glyptodon.guacamole.net.basic.rest.auth.TokenSessionMap;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * A ServletContextListener to listen for initialization of the servlet context
+ * in order to set up dependency injection.
+ *
+ * @author James Muehlner
+ */
+public class BasicServletContextListener extends GuiceServletContextListener {
+
+    /**
+     * Logger for this class.
+     */
+    private final Logger logger = LoggerFactory.getLogger(BasicServletContextListener.class);
+
+    /**
+     * The Guacamole server environment.
+     */
+    private Environment environment;
+
+    /**
+     * Singleton instance of a TokenSessionMap.
+     */
+    private TokenSessionMap sessionMap;
+
+    @Override
+    public void contextInitialized(ServletContextEvent servletContextEvent) {
+
+        try {
+            environment = new LocalEnvironment();
+            sessionMap = new BasicTokenSessionMap(environment);
+        }
+        catch (GuacamoleException e) {
+            logger.error("Unable to read guacamole.properties: {}", e.getMessage());
+            logger.debug("Error reading guacamole.properties.", e);
+            throw new RuntimeException(e);
+        }
+
+        super.contextInitialized(servletContextEvent);
+
+    }
+
+    @Override
+    protected Injector getInjector() {
+        return Guice.createInjector(Stage.PRODUCTION,
+            new EnvironmentModule(environment),
+            new LogModule(environment),
+            new ExtensionModule(environment),
+            new RESTServiceModule(sessionMap),
+            new TunnelModule()
+        );
+    }
+
+    @Override
+    public void contextDestroyed(ServletContextEvent servletContextEvent) {
+
+        super.contextDestroyed(servletContextEvent);
+
+        // Shutdown TokenSessionMap
+        if (sessionMap != null)
+            sessionMap.shutdown();
+
+    }
+
+}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/ClipboardState.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/ClipboardState.java
new file mode 100644
index 0000000..8a1fca1
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/ClipboardState.java
@@ -0,0 +1,154 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC.
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.basic;
+
+/**
+ * Provides central storage for a cross-connection clipboard state. This
+ * clipboard state is shared only for a single HTTP session. Multiple HTTP
+ * sessions will all have their own state.
+ * 
+ * @author Michael Jumper
+ */
+public class ClipboardState {
+
+    /**
+     * The maximum number of bytes to track.
+     */
+    private static final int MAXIMUM_LENGTH = 262144;
+
+     /**
+     * The mimetype of the current contents.
+     */
+    private String mimetype = "text/plain";
+
+    /**
+     * The mimetype of the pending contents.
+     */
+    private String pending_mimetype = "text/plain";
+    
+    /**
+     * The current contents.
+     */
+    private byte[] contents = new byte[0];
+
+    /**
+     * The pending clipboard contents.
+     */
+    private final byte[] pending = new byte[MAXIMUM_LENGTH];
+
+    /**
+     * The length of the pending data, in bytes.
+     */
+    private int pending_length = 0;
+    
+    /**
+     * The timestamp of the last contents update.
+     */
+    private long last_update = 0;
+    
+    /**
+     * Returns the current clipboard contents.
+     * @return The current clipboard contents
+     */
+    public synchronized byte[] getContents() {
+        return contents;
+    }
+
+    /**
+     * Returns the mimetype of the current clipboard contents.
+     * @return The mimetype of the current clipboard contents.
+     */
+    public synchronized String getMimetype() {
+        return mimetype;
+    }
+
+    /**
+     * Begins a new update of the clipboard contents. The actual contents will
+     * not be saved until commit() is called.
+     * 
+     * @param mimetype The mimetype of the contents being added.
+     */
+    public synchronized void begin(String mimetype) {
+        pending_length = 0;
+        this.pending_mimetype = mimetype;
+    }
+
+    /**
+     * Appends the given data to the clipboard contents.
+     * 
+     * @param data The raw data to append.
+     */
+    public synchronized void append(byte[] data) {
+
+        // Calculate size of copy
+        int length = data.length;
+        int remaining = pending.length - pending_length;
+        if (remaining < length)
+            length = remaining;
+    
+        // Append data
+        System.arraycopy(data, 0, pending, pending_length, length);
+        pending_length += length;
+
+    }
+
+    /**
+     * Commits the pending contents to the clipboard, notifying any threads
+     * waiting for clipboard updates.
+     */
+    public synchronized void commit() {
+
+        // Commit contents
+        mimetype = pending_mimetype;
+        contents = new byte[pending_length];
+        System.arraycopy(pending, 0, contents, 0, pending_length);
+
+        // Notify of update
+        last_update = System.currentTimeMillis();
+        this.notifyAll();
+
+    }
+    
+    /**
+     * Wait up to the given timeout for new clipboard data.
+     * 
+     * @param timeout The amount of time to wait, in milliseconds.
+     * @return true if the contents were updated within the timeframe given,
+     *         false otherwise.
+     */
+    public synchronized boolean waitForContents(int timeout) {
+
+        // Wait for new contents if it's been a while
+        if (System.currentTimeMillis() - last_update > timeout) {
+            try {
+                this.wait(timeout);
+                return true;
+            }
+            catch (InterruptedException e) { /* ignore */ }
+        }
+
+        return false;
+
+    }
+    
+}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/EnvironmentModule.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/EnvironmentModule.java
new file mode 100644
index 0000000..7032d5e
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/EnvironmentModule.java
@@ -0,0 +1,60 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.basic;
+
+import com.google.inject.AbstractModule;
+import org.glyptodon.guacamole.environment.Environment;
+
+/**
+ * Guice module which binds the base Guacamole server environment.
+ *
+ * @author Michael Jumper
+ */
+public class EnvironmentModule extends AbstractModule {
+
+    /**
+     * The Guacamole server environment.
+     */
+    private final Environment environment;
+
+    /**
+     * Creates a new EnvironmentModule which will bind the given environment
+     * for future injection.
+     *
+     * @param environment
+     *     The environment to bind.
+     */
+    public EnvironmentModule(Environment environment) {
+        this.environment = environment;
+    }
+
+    @Override
+    protected void configure() {
+
+        // Bind environment
+        bind(Environment.class).toInstance(environment);
+
+    }
+
+}
+
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/GuacamoleClassLoader.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/GuacamoleClassLoader.java
index 547431f..8c3f40c 100644
--- a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/GuacamoleClassLoader.java
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/GuacamoleClassLoader.java
@@ -1,24 +1,27 @@
-
-package org.glyptodon.guacamole.net.basic;
-
 /*
- *  Guacamole - Clientless Remote Desktop
- *  Copyright (C) 2010  Michael Jumper
+ * Copyright (C) 2013 Glyptodon LLC
  *
- *  This program is free software: you can redistribute it and/or modify
- *  it under the terms of the GNU Affero General Public License as published by
- *  the Free Software Foundation, either version 3 of the License, or
- *  (at your option) any later version.
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
  *
- *  This program is distributed in the hope that it will be useful,
- *  but WITHOUT ANY WARRANTY; without even the implied warranty of
- *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- *  GNU Affero General Public License for more details.
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
  *
- *  You should have received a copy of the GNU Affero General Public License
- *  along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
  */
 
+package org.glyptodon.guacamole.net.basic;
+
 import java.io.File;
 import java.io.FilenameFilter;
 import java.net.MalformedURLException;
@@ -30,15 +33,19 @@ import java.security.PrivilegedExceptionAction;
 import java.util.ArrayList;
 import java.util.Collection;
 import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.environment.Environment;
+import org.glyptodon.guacamole.environment.LocalEnvironment;
 import org.glyptodon.guacamole.net.basic.properties.BasicGuacamoleProperties;
-import org.glyptodon.guacamole.properties.GuacamoleProperties;
 
 /**
  * A ClassLoader implementation which finds classes within a configurable
- * directory. This directory is set within guacamole.properties.
+ * directory. This directory is set within guacamole.properties. This class
+ * is deprecated in favor of DirectoryClassLoader, which is automatically
+ * configured based on the presence/absence of GUACAMOLE_HOME/lib.
  *
  * @author Michael Jumper
  */
+ at Deprecated
 public class GuacamoleClassLoader extends ClassLoader {
 
     /**
@@ -66,9 +73,14 @@ public class GuacamoleClassLoader extends ClassLoader {
 
                 @Override
                 public GuacamoleClassLoader run() throws GuacamoleException {
+
+                    // TODONT: This should be injected, but GuacamoleClassLoader will be removed soon.
+                    Environment environment = new LocalEnvironment();
+                    
                     return new GuacamoleClassLoader(
-                        GuacamoleProperties.getProperty(BasicGuacamoleProperties.LIB_DIRECTORY)
+                        environment.getProperty(BasicGuacamoleProperties.LIB_DIRECTORY)
                     );
+
                 }
 
             });
@@ -102,7 +114,7 @@ public class GuacamoleClassLoader extends ClassLoader {
 
         // Get list of URLs for all .jar's in the lib directory
         Collection<URL> jarURLs = new ArrayList<URL>();
-        for (File file : libDirectory.listFiles(new FilenameFilter() {
+        File[] files = libDirectory.listFiles(new FilenameFilter() {
 
             @Override
             public boolean accept(File dir, String name) {
@@ -112,13 +124,17 @@ public class GuacamoleClassLoader extends ClassLoader {
 
             }
 
-        })) {
+        });
 
-            try {
+        // Verify directory was successfully read
+        if (files == null)
+            throw new GuacamoleException("Unable to read contents of directory " + libDirectory);
 
-                // Add URL for the .jar to the jar URL list
-                jarURLs.add(file.toURI().toURL());
+        // Add the URL for each .jar to the jar URL list
+        for (File file : files) {
 
+            try {
+                jarURLs.add(file.toURI().toURL());
             }
             catch (MalformedURLException e) {
                 throw new GuacamoleException(e);
@@ -155,7 +171,7 @@ public class GuacamoleClassLoader extends ClassLoader {
     }
 
     @Override
-    protected Class<?> findClass(String name) throws ClassNotFoundException {
+    public Class<?> findClass(String name) throws ClassNotFoundException {
 
         // If no classloader, use default loader
         if (classLoader == null)
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/GuacamoleSession.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/GuacamoleSession.java
new file mode 100644
index 0000000..dab8395
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/GuacamoleSession.java
@@ -0,0 +1,222 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.basic;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.environment.Environment;
+import org.glyptodon.guacamole.net.GuacamoleTunnel;
+import org.glyptodon.guacamole.net.auth.AuthenticatedUser;
+import org.glyptodon.guacamole.net.auth.UserContext;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Contains Guacamole-specific user information which is tied to the current
+ * session, such as the UserContext and current clipboard state.
+ *
+ * @author Michael Jumper
+ */
+public class GuacamoleSession {
+
+    /**
+     * Logger for this class.
+     */
+    private static final Logger logger = LoggerFactory.getLogger(GuacamoleSession.class);
+
+    /**
+     * The user associated with this session.
+     */
+    private AuthenticatedUser authenticatedUser;
+    
+    /**
+     * All UserContexts associated with this session. Each
+     * AuthenticationProvider may provide its own UserContext.
+     */
+    private List<UserContext> userContexts;
+
+    /**
+     * All currently-active tunnels, indexed by tunnel UUID.
+     */
+    private final Map<String, GuacamoleTunnel> tunnels = new ConcurrentHashMap<String, GuacamoleTunnel>();
+
+    /**
+     * The last time this session was accessed.
+     */
+    private long lastAccessedTime;
+    
+    /**
+     * Creates a new Guacamole session associated with the given
+     * AuthenticatedUser and UserContexts.
+     *
+     * @param environment
+     *     The environment of the Guacamole server associated with this new
+     *     session.
+     *
+     * @param authenticatedUser
+     *     The authenticated user to associate this session with.
+     *
+     * @param userContexts
+     *     The List of UserContexts to associate with this session.
+     *
+     * @throws GuacamoleException
+     *     If an error prevents the session from being created.
+     */
+    public GuacamoleSession(Environment environment,
+            AuthenticatedUser authenticatedUser,
+            List<UserContext> userContexts)
+            throws GuacamoleException {
+        this.lastAccessedTime = System.currentTimeMillis();
+        this.authenticatedUser = authenticatedUser;
+        this.userContexts = userContexts;
+    }
+
+    /**
+     * Returns the authenticated user associated with this session.
+     *
+     * @return
+     *     The authenticated user associated with this session.
+     */
+    public AuthenticatedUser getAuthenticatedUser() {
+        return authenticatedUser;
+    }
+
+    /**
+     * Replaces the authenticated user associated with this session with the
+     * given authenticated user.
+     *
+     * @param authenticatedUser
+     *     The authenticated user to associated with this session.
+     */
+    public void setAuthenticatedUser(AuthenticatedUser authenticatedUser) {
+        this.authenticatedUser = authenticatedUser;
+    }
+
+    /**
+     * Returns a list of all UserContexts associated with this session. Each
+     * AuthenticationProvider currently loaded by Guacamole may provide its own
+     * UserContext for any successfully-authenticated user.
+     *
+     * @return
+     *     An unmodifiable list of all UserContexts associated with this
+     *     session.
+     */
+    public List<UserContext> getUserContexts() {
+        return Collections.unmodifiableList(userContexts);
+    }
+
+    /**
+     * Replaces all UserContexts associated with this session with the given
+     * List of UserContexts.
+     *
+     * @param userContexts
+     *     The List of UserContexts to associate with this session.
+     */
+    public void setUserContexts(List<UserContext> userContexts) {
+        this.userContexts = userContexts;
+    }
+    
+    /**
+     * Returns whether this session has any associated active tunnels.
+     *
+     * @return true if this session has any associated active tunnels,
+     *         false otherwise.
+     */
+    public boolean hasTunnels() {
+        return !tunnels.isEmpty();
+    }
+
+    /**
+     * Returns a map of all active tunnels associated with this session, where
+     * each key is the String representation of the tunnel's UUID. Changes to
+     * this map immediately affect the set of tunnels associated with this
+     * session. A tunnel need not be present here to be used by the user
+     * associated with this session, but tunnels not in this set will not
+     * be taken into account when determining whether a session is in use.
+     *
+     * @return A map of all active tunnels associated with this session.
+     */
+    public Map<String, GuacamoleTunnel> getTunnels() {
+        return tunnels;
+    }
+
+    /**
+     * Associates the given tunnel with this session, such that it is taken
+     * into account when determining session activity.
+     *
+     * @param tunnel The tunnel to associate with this session.
+     */
+    public void addTunnel(GuacamoleTunnel tunnel) {
+        tunnels.put(tunnel.getUUID().toString(), tunnel);
+    }
+
+    /**
+     * Disassociates the tunnel having the given UUID from this session.
+     *
+     * @param uuid The UUID of the tunnel to disassociate from this session.
+     * @return true if the tunnel existed and was removed, false otherwise.
+     */
+    public boolean removeTunnel(String uuid) {
+        return tunnels.remove(uuid) != null;
+    }
+
+    /**
+     * Updates this session, marking it as accessed.
+     */
+    public void access() {
+        lastAccessedTime = System.currentTimeMillis();
+    }
+
+    /**
+     * Returns the time this session was last accessed, as the number of
+     * milliseconds since midnight January 1, 1970 GMT. Session access must
+     * be explicitly marked through calls to the access() function.
+     *
+     * @return The time this session was last accessed.
+     */
+    public long getLastAccessedTime() {
+        return lastAccessedTime;
+    }
+
+    /**
+     * Closes all associated tunnels and prevents any further use of this
+     * session.
+     */
+    public void invalidate() {
+
+        // Close all associated tunnels, if possible
+        for (GuacamoleTunnel tunnel : tunnels.values()) {
+            try {
+                tunnel.close();
+            }
+            catch (GuacamoleException e) {
+                logger.debug("Unable to close tunnel.", e);
+            }
+        }
+
+    }
+    
+}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/HTTPTunnelRequest.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/HTTPTunnelRequest.java
new file mode 100644
index 0000000..af7b7f0
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/HTTPTunnelRequest.java
@@ -0,0 +1,90 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.basic;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import javax.servlet.http.HttpServletRequest;
+
+/**
+ * HTTP-specific implementation of TunnelRequest.
+ *
+ * @author Michael Jumper
+ */
+public class HTTPTunnelRequest extends TunnelRequest {
+
+    /**
+     * A copy of the parameters obtained from the HttpServletRequest used to
+     * construct the HTTPTunnelRequest.
+     */
+    private final Map<String, List<String>> parameterMap =
+            new HashMap<String, List<String>>();
+
+    /**
+     * Creates a HTTPTunnelRequest which copies and exposes the parameters
+     * from the given HttpServletRequest.
+     *
+     * @param request
+     *     The HttpServletRequest to copy parameter values from.
+     */
+    @SuppressWarnings("unchecked") // getParameterMap() is defined as returning Map<String, String[]>
+    public HTTPTunnelRequest(HttpServletRequest request) {
+
+        // For each parameter
+        for (Map.Entry<String, String[]> mapEntry : ((Map<String, String[]>)
+                request.getParameterMap()).entrySet()) {
+
+            // Get parameter name and corresponding values
+            String parameterName = mapEntry.getKey();
+            List<String> parameterValues = Arrays.asList(mapEntry.getValue());
+
+            // Store copy of all values in our own map
+            parameterMap.put(
+                parameterName,
+                new ArrayList<String>(parameterValues)
+            );
+
+        }
+
+    }
+
+    @Override
+    public String getParameter(String name) {
+        List<String> values = getParameterValues(name);
+
+        // Return the first value from the list if available
+        if (values != null && !values.isEmpty())
+            return values.get(0);
+
+        return null;
+    }
+
+    @Override
+    public List<String> getParameterValues(String name) {
+        return parameterMap.get(name);
+    }
+    
+}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/ProtocolInfo.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/ProtocolInfo.java
deleted file mode 100644
index 149190d..0000000
--- a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/ProtocolInfo.java
+++ /dev/null
@@ -1,99 +0,0 @@
-
-package org.glyptodon.guacamole.net.basic;
-
-/*
- *  Guacamole - Clientless Remote Desktop
- *  Copyright (C) 2010  Michael Jumper
- *
- *  This program is free software: you can redistribute it and/or modify
- *  it under the terms of the GNU Affero General Public License as published by
- *  the Free Software Foundation, either version 3 of the License, or
- *  (at your option) any later version.
- *
- *  This program is distributed in the hope that it will be useful,
- *  but WITHOUT ANY WARRANTY; without even the implied warranty of
- *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- *  GNU Affero General Public License for more details.
- *
- *  You should have received a copy of the GNU Affero General Public License
- *  along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-import java.util.ArrayList;
-import java.util.Collection;
-
-/**
- * Describes a protocol and all parameters associated with it, as required by
- * a protocol plugin for guacd. This class allows known parameters for a
- * protocol to be exposed to the user as friendly fields.
- *
- * @author Michael Jumper
- */
-public class ProtocolInfo {
-
-    /**
-     * The human-readable title associated with this protocol.
-     */
-    private String title;
-
-    /**
-     * The unique name associated with this protocol.
-     */
-    private String name;
-
-    /**
-     * A collection of all associated protocol parameters.
-     */
-    private Collection<ProtocolParameter> parameters =
-            new ArrayList<ProtocolParameter>();
-
-    /**
-     * Returns the human-readable title associated with this protocol.
-     *
-     * @return The human-readable title associated with this protocol.
-     */
-    public String getTitle() {
-        return title;
-    }
-
-    /**
-     * Sets the human-readable title associated with this protocol.
-     *
-     * @param title The human-readable title to associate with this protocol.
-     */
-    public void setTitle(String title) {
-        this.title = title;
-    }
-
-    /**
-     * Returns the unique name of this protocol. The protocol name is the
-     * value required by the corresponding protocol plugin for guacd.
-     *
-     * @return The unique name of this protocol.
-     */
-    public String getName() {
-        return name;
-    }
-
-    /**
-     * Sets the unique name of this protocol. The protocol name is the value
-     * required by the corresponding protocol plugin for guacd.
-     *
-     * @param name The unique name of this protocol.
-     */
-    public void setName(String name) {
-        this.name = name;
-    }
-
-    /**
-     * Returns a mutable collection of the protocol parameters associated with
-     * this protocol. Changes to this collection affect the parameters exposed
-     * to the user.
-     *
-     * @return A mutable collection of protocol parameters.
-     */
-    public Collection<ProtocolParameter> getParameters() {
-        return parameters;
-    }
-
-}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/ProtocolParameter.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/ProtocolParameter.java
deleted file mode 100644
index 4b2319e..0000000
--- a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/ProtocolParameter.java
+++ /dev/null
@@ -1,171 +0,0 @@
-
-package org.glyptodon.guacamole.net.basic;
-
-/*
- *  Guacamole - Clientless Remote Desktop
- *  Copyright (C) 2010  Michael Jumper
- *
- *  This program is free software: you can redistribute it and/or modify
- *  it under the terms of the GNU Affero General Public License as published by
- *  the Free Software Foundation, either version 3 of the License, or
- *  (at your option) any later version.
- *
- *  This program is distributed in the hope that it will be useful,
- *  but WITHOUT ANY WARRANTY; without even the implied warranty of
- *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- *  GNU Affero General Public License for more details.
- *
- *  You should have received a copy of the GNU Affero General Public License
- *  along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-import java.util.ArrayList;
-import java.util.Collection;
-
-/**
- * Represents a parameter of a protocol.
- *
- * @author Michael Jumper
- */
-public class ProtocolParameter {
-
-    /**
-     * All possible types of protocol parameter.
-     */
-    public enum Type {
-
-        /**
-         * A text parameter, accepting arbitrary values.
-         */
-        TEXT,
-
-        /**
-         * A password parameter, whose value is sensitive and must be hidden.
-         */
-        PASSWORD,
-
-        /**
-         * A numeric parameter, whose value must contain only digits.
-         */
-        NUMERIC,
-
-        /**
-         * A boolean parameter, whose value is either blank or "true".
-         */
-        BOOLEAN,
-
-        /**
-         * An enumerated parameter, whose legal values are fully enumerated
-         * by a provided, finite list.
-         */
-        ENUM
-    }
-
-    /**
-     * The unique name that identifies this parameter to the protocol plugin.
-     */
-    private String name;
-
-    /**
-     * A human-readable name to be presented to the user.
-     */
-    private String title;
-
-    /**
-     * The type of this field.
-     */
-    private Type type;
-
-    /**
-     * The value of this parameter, for boolean parameters.
-     */
-    private String value;
-
-    /**
-     * A collection of all associated parameter options.
-     */
-    private Collection<ProtocolParameterOption> options =
-            new ArrayList<ProtocolParameterOption>();
-
-    /**
-     * Returns the name associated with this protocol parameter.
-     * @return The name associated with this protocol parameter.
-     */
-    public String getName() {
-        return name;
-    }
-
-    /**
-     * Sets the name associated with this protocol parameter. This name must
-     * uniquely identify this parameter among the others accepted by the
-     * corresponding protocol.
-     *
-     * @param name The name to assign to this protocol parameter.
-     */
-    public void setName(String name) {
-        this.name = name;
-    }
-
-    /**
-     * Returns the title associated with this protocol parameter.
-     * @return The title associated with this protocol parameter.
-     */
-    public String getTitle() {
-        return title;
-    }
-
-    /**
-     * Sets the title associated with this protocol parameter. The title must
-     * be a human-readable string which describes accurately this parameter.
-     *
-     * @param title A human-readable string describing this parameter.
-     */
-    public void setTitle(String title) {
-        this.title = title;
-    }
-
-    /**
-     * Returns the value associated with this protocol parameter.
-     * @return The value associated with this protocol parameter.
-     */
-    public String getValue() {
-        return value;
-    }
-
-    /**
-     * Sets the value associated with this protocol parameter. The value must
-     * be a human-readable string which describes accurately this parameter.
-     *
-     * @param value A human-readable string describing this parameter.
-     */
-    public void setValue(String value) {
-        this.value = value;
-    }
-
-    /**
-     * Returns the type of this parameter.
-     * @return The type of this parameter.
-     */
-    public Type getType() {
-        return type;
-    }
-
-    /**
-     * Sets the type of this parameter.
-     * @param type The type of this parameter.
-     */
-    public void setType(Type type) {
-        this.type = type;
-    }
-
-    /**
-     * Returns a mutable collection of protocol parameter options. Changes to
-     * this collection directly affect the available options.
-     *
-     * @return A mutable collection of parameter options.
-     */
-    public Collection<ProtocolParameterOption> getOptions() {
-        return options;
-    }
-
-}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/ProtocolParameterOption.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/ProtocolParameterOption.java
deleted file mode 100644
index 5e7231e..0000000
--- a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/ProtocolParameterOption.java
+++ /dev/null
@@ -1,76 +0,0 @@
-
-package org.glyptodon.guacamole.net.basic;
-
-/*
- *  Guacamole - Clientless Remote Desktop
- *  Copyright (C) 2010  Michael Jumper
- *
- *  This program is free software: you can redistribute it and/or modify
- *  it under the terms of the GNU Affero General Public License as published by
- *  the Free Software Foundation, either version 3 of the License, or
- *  (at your option) any later version.
- *
- *  This program is distributed in the hope that it will be useful,
- *  but WITHOUT ANY WARRANTY; without even the implied warranty of
- *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- *  GNU Affero General Public License for more details.
- *
- *  You should have received a copy of the GNU Affero General Public License
- *  along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-/**
- * Describes an available legal value for an enumerated protocol parameter.
- *
- * @author Michael Jumper
- */
-public class ProtocolParameterOption {
-
-    /**
-     * The value that will be sent to the client plugin if this option is
-     * chosen.
-     */
-    private String value;
-
-    /**
-     * A human-readable title describing the effect of the value.
-     */
-    private String title;
-
-    /**
-     * Returns the value that will be sent to the client plugin if this option
-     * is chosen.
-     *
-     * @return The value that will be sent if this option is chosen.
-     */
-    public String getValue() {
-        return value;
-    }
-
-    /**
-     * Sets the value that will be sent to the client plugin if this option is
-     * chosen.
-     *
-     * @param value The value to send if this option is chosen.
-     */
-    public void setValue(String value) {
-        this.value = value;
-    }
-
-    /**
-     * Returns the human-readable title describing the effect of this option.
-     * @return The human-readable title describing the effect of this option.
-     */
-    public String getTitle() {
-        return title;
-    }
-
-    /**
-     * Sets the human-readable title describing the effect of this option.
-     * @param title A human-readable title describing the effect of this option.
-     */
-    public void setTitle(String title) {
-        this.title = title;
-    }
-
-}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/TunnelLoader.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/TunnelLoader.java
new file mode 100644
index 0000000..efcc8a3
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/TunnelLoader.java
@@ -0,0 +1,44 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.basic;
+
+import com.google.inject.Module;
+
+/**
+ * Generic means of loading a tunnel without adding explicit dependencies within
+ * the main ServletModule, as not all servlet containers may have the classes
+ * required by all tunnel implementations.
+ *
+ * @author Michael Jumper
+ */
+public interface TunnelLoader extends Module {
+
+    /**
+     * Checks whether this type of tunnel is supported by the servlet container.
+     * 
+     * @return true if this type of tunnel is supported and can be loaded
+     *         without errors, false otherwise.
+     */
+    public boolean isSupported();
+
+}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/TunnelModule.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/TunnelModule.java
new file mode 100644
index 0000000..9ee4646
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/TunnelModule.java
@@ -0,0 +1,113 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.basic;
+
+import com.google.inject.servlet.ServletModule;
+import java.lang.reflect.InvocationTargetException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Module which loads tunnel implementations.
+ *
+ * @author Michael Jumper
+ */
+public class TunnelModule extends ServletModule {
+
+    /**
+     * Logger for this class.
+     */
+    private final Logger logger = LoggerFactory.getLogger(TunnelModule.class);
+
+    /**
+     * Classnames of all implementation-specific WebSocket tunnel modules.
+     */
+    private static final String[] WEBSOCKET_MODULES = {
+        "org.glyptodon.guacamole.net.basic.websocket.WebSocketTunnelModule",
+        "org.glyptodon.guacamole.net.basic.websocket.jetty8.WebSocketTunnelModule",
+        "org.glyptodon.guacamole.net.basic.websocket.jetty9.WebSocketTunnelModule",
+        "org.glyptodon.guacamole.net.basic.websocket.tomcat.WebSocketTunnelModule"
+    };
+
+    private boolean loadWebSocketModule(String classname) {
+
+        try {
+
+            // Attempt to find WebSocket module
+            Class<?> module = Class.forName(classname);
+
+            // Create loader
+            TunnelLoader loader = (TunnelLoader) module.getConstructor().newInstance();
+
+            // Install module, if supported
+            if (loader.isSupported()) {
+                install(loader);
+                return true;
+            }
+
+        }
+
+        // If no such class or constructor, etc., then this particular
+        // WebSocket support is not present
+        catch (ClassNotFoundException e) {}
+        catch (NoClassDefFoundError e) {}
+        catch (NoSuchMethodException e) {}
+
+        // Log errors which indicate bugs
+        catch (InstantiationException e) {
+            logger.debug("Error instantiating WebSocket module.", e);
+        }
+        catch (IllegalAccessException e) {
+            logger.debug("Error instantiating WebSocket module.", e);
+        }
+        catch (InvocationTargetException e) {
+            logger.debug("Error instantiating WebSocket module.", e);
+        }
+
+        // Load attempt failed
+        return false;
+
+    }
+
+    @Override
+    protected void configureServlets() {
+
+        bind(TunnelRequestService.class);
+
+        // Set up HTTP tunnel
+        serve("/tunnel").with(BasicGuacamoleTunnelServlet.class);
+
+        // Try to load each WebSocket tunnel in sequence
+        for (String classname : WEBSOCKET_MODULES) {
+            if (loadWebSocketModule(classname)) {
+                logger.debug("WebSocket module loaded: {}", classname);
+                return;
+            }
+        }
+
+        // Warn of lack of WebSocket
+        logger.info("WebSocket support NOT present. Only HTTP will be used.");
+
+    }
+
+}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/TunnelRequest.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/TunnelRequest.java
new file mode 100644
index 0000000..bb4d4eb
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/TunnelRequest.java
@@ -0,0 +1,372 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.basic;
+
+import java.util.List;
+import org.glyptodon.guacamole.GuacamoleClientException;
+import org.glyptodon.guacamole.GuacamoleException;
+
+/**
+ * A request object which provides only the functions absolutely required to
+ * retrieve and connect to a tunnel.
+ *
+ * @author Michael Jumper
+ */
+public abstract class TunnelRequest {
+
+    /**
+     * The name of the request parameter containing the user's authentication
+     * token.
+     */
+    public static final String AUTH_TOKEN_PARAMETER = "token";
+
+    /**
+     * The name of the parameter containing the identifier of the
+     * AuthenticationProvider associated with the UserContext containing the
+     * object to which a tunnel is being requested.
+     */
+    public static final String AUTH_PROVIDER_IDENTIFIER_PARAMETER = "GUAC_DATA_SOURCE";
+
+    /**
+     * The name of the parameter specifying the type of object to which a
+     * tunnel is being requested. Currently, this may be "c" for a Guacamole
+     * connection, or "g" for a Guacamole connection group.
+     */
+    public static final String TYPE_PARAMETER = "GUAC_TYPE";
+
+    /**
+     * The name of the parameter containing the unique identifier of the object
+     * to which a tunnel is being requested.
+     */
+    public static final String IDENTIFIER_PARAMETER = "GUAC_ID";
+
+    /**
+     * The name of the parameter containing the desired display width, in
+     * pixels.
+     */
+    public static final String WIDTH_PARAMETER = "GUAC_WIDTH";
+
+    /**
+     * The name of the parameter containing the desired display height, in
+     * pixels.
+     */
+    public static final String HEIGHT_PARAMETER = "GUAC_HEIGHT";
+
+    /**
+     * The name of the parameter containing the desired display resolution, in
+     * DPI.
+     */
+    public static final String DPI_PARAMETER = "GUAC_DPI";
+
+    /**
+     * The name of the parameter specifying one supported audio mimetype. This
+     * will normally appear multiple times within a single tunnel request -
+     * once for each mimetype.
+     */
+    public static final String AUDIO_PARAMETER = "GUAC_AUDIO";
+
+    /**
+     * The name of the parameter specifying one supported video mimetype. This
+     * will normally appear multiple times within a single tunnel request -
+     * once for each mimetype.
+     */
+    public static final String VIDEO_PARAMETER = "GUAC_VIDEO";
+
+    /**
+     * The name of the parameter specifying one supported image mimetype. This
+     * will normally appear multiple times within a single tunnel request -
+     * once for each mimetype.
+     */
+    public static final String IMAGE_PARAMETER = "GUAC_IMAGE";
+
+    /**
+     * All supported object types that can be used as the destination of a
+     * tunnel.
+     */
+    public static enum Type {
+
+        /**
+         * A Guacamole connection.
+         */
+        CONNECTION("c"),
+
+        /**
+         * A Guacamole connection group.
+         */
+        CONNECTION_GROUP("g");
+
+        /**
+         * The parameter value which denotes a destination object of this type.
+         */
+        final String PARAMETER_VALUE;
+        
+        /**
+         * Defines a Type having the given corresponding parameter value.
+         *
+         * @param value
+         *     The parameter value which denotes a destination object of this
+         *     type.
+         */
+        Type(String value) {
+            PARAMETER_VALUE = value;
+        }
+
+    };
+
+    /**
+     * Returns the value of the parameter having the given name.
+     *
+     * @param name
+     *     The name of the parameter to return.
+     *
+     * @return
+     *     The value of the parameter having the given name, or null if no such
+     *     parameter was specified.
+     */
+    public abstract String getParameter(String name);
+
+    /**
+     * Returns a list of all values specified for the given parameter.
+     *
+     * @param name
+     *     The name of the parameter to return.
+     *
+     * @return
+     *     All values of the parameter having the given name , or null if no
+     *     such parameter was specified.
+     */
+    public abstract List<String> getParameterValues(String name);
+
+    /**
+     * Returns the value of the parameter having the given name, throwing an
+     * exception if the parameter is missing.
+     *
+     * @param name
+     *     The name of the parameter to return.
+     *
+     * @return
+     *     The value of the parameter having the given name.
+     *
+     * @throws GuacamoleException
+     *     If the parameter is not present in the request.
+     */
+    public String getRequiredParameter(String name) throws GuacamoleException {
+
+        // Pull requested parameter, aborting if absent
+        String value = getParameter(name);
+        if (value == null)
+            throw new GuacamoleClientException("Parameter \"" + name + "\" is required.");
+
+        return value;
+
+    }
+
+    /**
+     * Returns the integer value of the parameter having the given name,
+     * throwing an exception if the parameter cannot be parsed.
+     *
+     * @param name
+     *     The name of the parameter to return.
+     *
+     * @return
+     *     The integer value of the parameter having the given name, or null if
+     *     the parameter is missing.
+     *
+     * @throws GuacamoleException
+     *     If the parameter is not a valid integer.
+     */
+    public Integer getIntegerParameter(String name) throws GuacamoleException {
+
+        // Pull requested parameter
+        String value = getParameter(name);
+        if (value == null)
+            return null;
+
+        // Attempt to parse as an integer
+        try {
+            return Integer.parseInt(value);
+        }
+
+        // Rethrow any parsing error as a GuacamoleClientException
+        catch (NumberFormatException e) {
+            throw new GuacamoleClientException("Parameter \"" + name + "\" must be a valid integer.", e);
+        }
+
+    }
+
+    /**
+     * Returns the authentication token associated with this tunnel request.
+     *
+     * @return
+     *     The authentication token associated with this tunnel request, or
+     *     null if no authentication token is present.
+     */
+    public String getAuthenticationToken() {
+        return getParameter(AUTH_TOKEN_PARAMETER);
+    }
+
+    /**
+     * Returns the identifier of the AuthenticationProvider associated with the
+     * UserContext from which the connection or connection group is to be
+     * retrieved when the tunnel is created. In the context of the REST API and
+     * the JavaScript side of the web application, this is referred to as the
+     * data source identifier.
+     *
+     * @return
+     *     The identifier of the AuthenticationProvider associated with the
+     *     UserContext from which the connection or connection group is to be
+     *     retrieved when the tunnel is created.
+     *
+     * @throws GuacamoleException
+     *     If the identifier was not present in the request.
+     */
+    public String getAuthenticationProviderIdentifier()
+            throws GuacamoleException {
+        return getRequiredParameter(AUTH_PROVIDER_IDENTIFIER_PARAMETER);
+    }
+
+    /**
+     * Returns the type of object for which the tunnel is being requested.
+     *
+     * @return
+     *     The type of object for which the tunnel is being requested.
+     *
+     * @throws GuacamoleException
+     *     If the type was not present in the request, or if the type requested
+     *     is in the wrong format.
+     */
+    public Type getType() throws GuacamoleException {
+
+        String type = getRequiredParameter(TYPE_PARAMETER);
+
+        // For each possible object type
+        for (Type possibleType : Type.values()) {
+
+            // Match against defined parameter value
+            if (type.equals(possibleType.PARAMETER_VALUE))
+                return possibleType;
+
+        }
+
+        throw new GuacamoleClientException("Illegal identifier - unknown type.");
+
+    }
+
+    /**
+     * Returns the identifier of the destination of the tunnel being requested.
+     * As there are multiple types of destination objects available, and within
+     * multiple data sources, the associated object type and data source are
+     * also necessary to determine what this identifier refers to.
+     *
+     * @return
+     *     The identifier of the destination of the tunnel being requested.
+     *
+     * @throws GuacamoleException
+     *     If the identifier was not present in the request.
+     */
+    public String getIdentifier() throws GuacamoleException {
+        return getRequiredParameter(IDENTIFIER_PARAMETER);
+    }
+
+    /**
+     * Returns the display width desired for the Guacamole session over the
+     * tunnel being requested.
+     *
+     * @return
+     *     The display width desired for the Guacamole session over the tunnel
+     *     being requested, or null if no width was given.
+     *
+     * @throws GuacamoleException
+     *     If the width specified was not a valid integer.
+     */
+    public Integer getWidth() throws GuacamoleException {
+        return getIntegerParameter(WIDTH_PARAMETER);
+    }
+
+    /**
+     * Returns the display height desired for the Guacamole session over the
+     * tunnel being requested.
+     *
+     * @return
+     *     The display height desired for the Guacamole session over the tunnel
+     *     being requested, or null if no width was given.
+     *
+     * @throws GuacamoleException
+     *     If the height specified was not a valid integer.
+     */
+    public Integer getHeight() throws GuacamoleException {
+        return getIntegerParameter(HEIGHT_PARAMETER);
+    }
+
+    /**
+     * Returns the display resolution desired for the Guacamole session over
+     * the tunnel being requested, in DPI.
+     *
+     * @return
+     *     The display resolution desired for the Guacamole session over the
+     *     tunnel being requested, or null if no resolution was given.
+     *
+     * @throws GuacamoleException
+     *     If the resolution specified was not a valid integer.
+     */
+    public Integer getDPI() throws GuacamoleException {
+        return getIntegerParameter(DPI_PARAMETER);
+    }
+
+    /**
+     * Returns a list of all audio mimetypes declared as supported within the
+     * tunnel request.
+     *
+     * @return
+     *     A list of all audio mimetypes declared as supported within the
+     *     tunnel request, or null if no mimetypes were specified.
+     */
+    public List<String> getAudioMimetypes() {
+        return getParameterValues(AUDIO_PARAMETER);
+    }
+
+    /**
+     * Returns a list of all video mimetypes declared as supported within the
+     * tunnel request.
+     *
+     * @return
+     *     A list of all video mimetypes declared as supported within the
+     *     tunnel request, or null if no mimetypes were specified.
+     */
+    public List<String> getVideoMimetypes() {
+        return getParameterValues(VIDEO_PARAMETER);
+    }
+
+    /**
+     * Returns a list of all image mimetypes declared as supported within the
+     * tunnel request.
+     *
+     * @return
+     *     A list of all image mimetypes declared as supported within the
+     *     tunnel request, or null if no mimetypes were specified.
+     */
+    public List<String> getImageMimetypes() {
+        return getParameterValues(IMAGE_PARAMETER);
+    }
+
+}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/TunnelRequestService.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/TunnelRequestService.java
new file mode 100644
index 0000000..6a2ef7a
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/TunnelRequestService.java
@@ -0,0 +1,359 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.basic;
+
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.List;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.GuacamoleSecurityException;
+import org.glyptodon.guacamole.GuacamoleUnauthorizedException;
+import org.glyptodon.guacamole.net.DelegatingGuacamoleTunnel;
+import org.glyptodon.guacamole.net.GuacamoleTunnel;
+import org.glyptodon.guacamole.net.auth.Connection;
+import org.glyptodon.guacamole.net.auth.ConnectionGroup;
+import org.glyptodon.guacamole.net.auth.Directory;
+import org.glyptodon.guacamole.net.auth.UserContext;
+import org.glyptodon.guacamole.net.basic.rest.ObjectRetrievalService;
+import org.glyptodon.guacamole.net.basic.rest.auth.AuthenticationService;
+import org.glyptodon.guacamole.protocol.GuacamoleClientInformation;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Utility class that takes a standard request from the Guacamole JavaScript
+ * client and produces the corresponding GuacamoleTunnel. The implementation
+ * of this utility is specific to the form of request used by the upstream
+ * Guacamole web application, and is not necessarily useful to applications
+ * that use purely the Guacamole API.
+ *
+ * @author Michael Jumper
+ * @author Vasily Loginov
+ */
+ at Singleton
+public class TunnelRequestService {
+
+    /**
+     * Logger for this class.
+     */
+    private final Logger logger = LoggerFactory.getLogger(TunnelRequestService.class);
+
+    /**
+     * A service for authenticating users from auth tokens.
+     */
+    @Inject
+    private AuthenticationService authenticationService;
+
+    /**
+     * Service for convenient retrieval of objects.
+     */
+    @Inject
+    private ObjectRetrievalService retrievalService;
+
+    /**
+     * Reads and returns the client information provided within the given
+     * request.
+     *
+     * @param request
+     *     The request describing tunnel to create.
+     *
+     * @return GuacamoleClientInformation
+     *     An object containing information about the client sending the tunnel
+     *     request.
+     *
+     * @throws GuacamoleException
+     *     If the parameters of the tunnel request are invalid.
+     */
+    protected GuacamoleClientInformation getClientInformation(TunnelRequest request)
+        throws GuacamoleException {
+
+        // Get client information
+        GuacamoleClientInformation info = new GuacamoleClientInformation();
+
+        // Set width if provided
+        Integer width = request.getWidth();
+        if (width != null)
+            info.setOptimalScreenWidth(width);
+
+        // Set height if provided
+        Integer height = request.getHeight();
+        if (height != null)
+            info.setOptimalScreenHeight(height);
+
+        // Set resolution if provided
+        Integer dpi = request.getDPI();
+        if (dpi != null)
+            info.setOptimalResolution(dpi);
+
+        // Add audio mimetypes
+        List<String> audioMimetypes = request.getAudioMimetypes();
+        if (audioMimetypes != null)
+            info.getAudioMimetypes().addAll(audioMimetypes);
+
+        // Add video mimetypes
+        List<String> videoMimetypes = request.getVideoMimetypes();
+        if (videoMimetypes != null)
+            info.getVideoMimetypes().addAll(videoMimetypes);
+
+        // Add image mimetypes
+        List<String> imageMimetypes = request.getImageMimetypes();
+        if (imageMimetypes != null)
+            info.getImageMimetypes().addAll(imageMimetypes);
+
+        return info;
+    }
+
+    /**
+     * Creates a new tunnel using which is connected to the connection or
+     * connection group identifier by the given ID. Client information
+     * is specified in the {@code info} parameter.
+     *
+     * @param context
+     *     The UserContext associated with the user for whom the tunnel is
+     *     being created.
+     *
+     * @param type
+     *     The type of object being connected to (connection or group).
+     *
+     * @param id
+     *     The id of the connection or group being connected to.
+     *
+     * @param info
+     *     Information describing the connected Guacamole client.
+     *
+     * @return
+     *     A new tunnel, connected as required by the request.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while creating the tunnel.
+     */
+    protected GuacamoleTunnel createConnectedTunnel(UserContext context,
+            final TunnelRequest.Type type, String id,
+            GuacamoleClientInformation info)
+            throws GuacamoleException {
+
+        // Create connected tunnel from identifier
+        GuacamoleTunnel tunnel = null;
+        switch (type) {
+
+            // Connection identifiers
+            case CONNECTION: {
+
+                // Get connection directory
+                Directory<Connection> directory = context.getConnectionDirectory();
+
+                // Get authorized connection
+                Connection connection = directory.get(id);
+                if (connection == null) {
+                    logger.info("Connection \"{}\" does not exist for user \"{}\".", id, context.self().getIdentifier());
+                    throw new GuacamoleSecurityException("Requested connection is not authorized.");
+                }
+
+                // Connect tunnel
+                tunnel = connection.connect(info);
+                logger.info("User \"{}\" connected to connection \"{}\".", context.self().getIdentifier(), id);
+                break;
+            }
+
+            // Connection group identifiers
+            case CONNECTION_GROUP: {
+
+                // Get connection group directory
+                Directory<ConnectionGroup> directory = context.getConnectionGroupDirectory();
+
+                // Get authorized connection group
+                ConnectionGroup group = directory.get(id);
+                if (group == null) {
+                    logger.info("Connection group \"{}\" does not exist for user \"{}\".", id, context.self().getIdentifier());
+                    throw new GuacamoleSecurityException("Requested connection group is not authorized.");
+                }
+
+                // Connect tunnel
+                tunnel = group.connect(info);
+                logger.info("User \"{}\" connected to group \"{}\".", context.self().getIdentifier(), id);
+                break;
+            }
+
+            // Type is guaranteed to be one of the above
+            default:
+                assert(false);
+
+        }
+
+        return tunnel;
+
+    }
+
+    /**
+     * Associates the given tunnel with the given session, returning a wrapped
+     * version of the same tunnel which automatically handles closure and
+     * removal from the session.
+     *
+     * @param tunnel
+     *     The connected tunnel to wrap and monitor.
+     *
+     * @param authToken
+     *     The authentication token associated with the given session. If
+     *     provided, this token will be automatically invalidated (and the
+     *     corresponding session destroyed) if tunnel errors imply that the
+     *     user is no longer authorized.
+     *
+     * @param session
+     *     The Guacamole session to associate the tunnel with.
+     *
+     * @param type
+     *     The type of object being connected to (connection or group).
+     *
+     * @param id
+     *     The id of the connection or group being connected to.
+     *
+     * @return
+     *     A new tunnel, associated with the given session, which delegates all
+     *     functionality to the given tunnel while monitoring and automatically
+     *     handling closure.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while obtaining the tunnel.
+     */
+    protected GuacamoleTunnel createAssociatedTunnel(GuacamoleTunnel tunnel,
+            final String authToken,  final GuacamoleSession session,
+            final TunnelRequest.Type type, final String id)
+            throws GuacamoleException {
+
+        // Monitor tunnel closure and data
+        GuacamoleTunnel monitoredTunnel = new DelegatingGuacamoleTunnel(tunnel) {
+
+            /**
+             * The time the connection began, measured in milliseconds since
+             * midnight, January 1, 1970 UTC.
+             */
+            private final long connectionStartTime = System.currentTimeMillis();
+
+            @Override
+            public void close() throws GuacamoleException {
+
+                long connectionEndTime = System.currentTimeMillis();
+                long duration = connectionEndTime - connectionStartTime;
+
+                // Log closure
+                switch (type) {
+
+                    // Connection identifiers
+                    case CONNECTION:
+                        logger.info("User \"{}\" disconnected from connection \"{}\". Duration: {} milliseconds",
+                                session.getAuthenticatedUser().getIdentifier(), id, duration);
+                        break;
+
+                    // Connection group identifiers
+                    case CONNECTION_GROUP:
+                        logger.info("User \"{}\" disconnected from connection group \"{}\". Duration: {} milliseconds",
+                                session.getAuthenticatedUser().getIdentifier(), id, duration);
+                        break;
+
+                    // Type is guaranteed to be one of the above
+                    default:
+                        assert(false);
+
+                }
+
+                try {
+
+                    // Close and clean up tunnel
+                    session.removeTunnel(getUUID().toString());
+                    super.close();
+
+                }
+
+                // Ensure any associated session is invalidated if unauthorized
+                catch (GuacamoleUnauthorizedException e) {
+
+                    // If there is an associated auth token, invalidate it
+                    if (authenticationService.destroyGuacamoleSession(authToken))
+                        logger.debug("Implicitly invalidated session for token \"{}\".", authToken);
+
+                    // Continue with exception processing
+                    throw e;
+
+                }
+
+            }
+
+        };
+
+        // Associate tunnel with session
+        session.addTunnel(monitoredTunnel);
+        return monitoredTunnel;
+        
+    }
+
+    /**
+     * Creates a new tunnel using the parameters and credentials present in
+     * the given request.
+     *
+     * @param request
+     *     The request describing the tunnel to create.
+     *
+     * @return
+     *     The created tunnel, or null if the tunnel could not be created.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while creating the tunnel.
+     */
+    public GuacamoleTunnel createTunnel(TunnelRequest request)
+            throws GuacamoleException {
+
+        // Parse request parameters
+        String authToken                = request.getAuthenticationToken();
+        String id                       = request.getIdentifier();
+        TunnelRequest.Type type         = request.getType();
+        String authProviderIdentifier   = request.getAuthenticationProviderIdentifier();
+        GuacamoleClientInformation info = getClientInformation(request);
+
+        GuacamoleSession session = authenticationService.getGuacamoleSession(authToken);
+        UserContext userContext = retrievalService.retrieveUserContext(session, authProviderIdentifier);
+
+        try {
+
+            // Create connected tunnel using provided connection ID and client information
+            GuacamoleTunnel tunnel = createConnectedTunnel(userContext, type, id, info);
+
+            // Associate tunnel with session
+            return createAssociatedTunnel(tunnel, authToken, session, type, id);
+
+        }
+
+        // Ensure any associated session is invalidated if unauthorized
+        catch (GuacamoleUnauthorizedException e) {
+
+            // If there is an associated auth token, invalidate it
+            if (authenticationService.destroyGuacamoleSession(authToken))
+                logger.debug("Implicitly invalidated session for token \"{}\".", authToken);
+
+            // Continue with exception processing
+            throw e;
+
+        }
+
+    }
+
+}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/WebSocketSupportLoader.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/WebSocketSupportLoader.java
deleted file mode 100644
index ecf88c2..0000000
--- a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/WebSocketSupportLoader.java
+++ /dev/null
@@ -1,114 +0,0 @@
-package org.glyptodon.guacamole.net.basic;
-
-/*
- *  Guacamole - Clientless Remote Desktop
- *  Copyright (C) 2010  Michael Jumper
- *
- *  This program is free software: you can redistribute it and/or modify
- *  it under the terms of the GNU Affero General Public License as published by
- *  the Free Software Foundation, either version 3 of the License, or
- *  (at your option) any later version.
- *
- *  This program is distributed in the hope that it will be useful,
- *  but WITHOUT ANY WARRANTY; without even the implied warranty of
- *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- *  GNU Affero General Public License for more details.
- *
- *  You should have received a copy of the GNU Affero General Public License
- *  along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-import java.lang.reflect.InvocationTargetException;
-import java.lang.reflect.Method;
-import javax.servlet.Servlet;
-import javax.servlet.ServletContext;
-import javax.servlet.ServletContextEvent;
-import javax.servlet.ServletContextListener;
-import org.glyptodon.guacamole.GuacamoleException;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/**
- * Simple ServletContextListener which loads a WebSocket tunnel implementation
- * if available, using the Servlet 3.0 API to dynamically load and install
- * the tunnel servlet.
- *
- * Note that because Guacamole depends on the Servlet 2.5 API, and 3.0 may
- * not be available or needed if WebSocket is not desired, the 3.0 API is
- * detected and invoked dynamically via reflection.
- *
- * @author Michael Jumper
- */
-public class WebSocketSupportLoader implements ServletContextListener {
-
-    /**
-     * Logger for this class.
-     */
-    private Logger logger = LoggerFactory.getLogger(WebSocketSupportLoader.class);
-
-    @Override
-    public void contextDestroyed(ServletContextEvent sce) {
-    }
-
-    @Override
-    public void contextInitialized(ServletContextEvent sce) {
-
-        try {
-
-            // Attempt to find WebSocket servlet
-            Class<Servlet> servlet = (Class<Servlet>) GuacamoleClassLoader.getInstance().findClass(
-                "org.glyptodon.guacamole.net.basic.BasicGuacamoleWebSocketTunnelServlet"
-            );
-
-            // Dynamically add servlet IF SERVLET 3.0 API AVAILABLE!
-            try {
-
-                // Get servlet registration class
-                Class regClass = Class.forName("javax.servlet.ServletRegistration");
-
-                // Get and invoke addServlet()
-                Method addServlet = ServletContext.class.getMethod("addServlet", String.class, Class.class);
-                Object reg = addServlet.invoke(sce.getServletContext(), "WebSocketTunnel", servlet);
-
-                // Get and invoke addMapping()
-                Method addMapping = regClass.getMethod("addMapping", String[].class);
-                addMapping.invoke(reg, (Object) new String[]{"/websocket-tunnel"});
-
-                // If we succesfully load and register the WebSocket tunnel servlet,
-                // WebSocket is supported.
-                logger.info("WebSocket support found and loaded.");
-
-            }
-
-            // Servlet API 3.0 unsupported
-            catch (ClassNotFoundException e) {
-                logger.info("Servlet API 3.0 not found.", e);
-            }
-            catch (NoSuchMethodException e) {
-                logger.warn("Servlet API 3.0 found, but incomplete.", e);
-            }
-
-            // Servlet API 3.0 found, but errors during use
-            catch (IllegalAccessException e) {
-                logger.error("Unable to load WebSocket tunnel servlet.", e);
-            }
-            catch (InvocationTargetException e) {
-                logger.error("Internal error loading WebSocket tunnel servlet.", e);
-            }
-
-        }
-
-        // If no such servlet class, WebSocket support not present
-        catch (ClassNotFoundException e) {
-            logger.info("WebSocket support not found.");
-        }
-
-        // Log all GuacamoleExceptions
-        catch (GuacamoleException e) {
-            logger.error("Unable to load/detect WebSocket support.", e);
-        }
-
-    }
-
-}
-
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/auth/Authorization.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/auth/Authorization.java
index 4293f08..1e88ef0 100644
--- a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/auth/Authorization.java
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/auth/Authorization.java
@@ -1,23 +1,27 @@
-package org.glyptodon.guacamole.net.basic.auth;
-
 /*
- *  Guacamole - Clientless Remote Desktop
- *  Copyright (C) 2010  Michael Jumper
+ * Copyright (C) 2013 Glyptodon LLC
  *
- *  This program is free software: you can redistribute it and/or modify
- *  it under the terms of the GNU Affero General Public License as published by
- *  the Free Software Foundation, either version 3 of the License, or
- *  (at your option) any later version.
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
  *
- *  This program is distributed in the hope that it will be useful,
- *  but WITHOUT ANY WARRANTY; without even the implied warranty of
- *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- *  GNU Affero General Public License for more details.
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
  *
- *  You should have received a copy of the GNU Affero General Public License
- *  along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
  */
 
+package org.glyptodon.guacamole.net.basic.auth;
+
 import java.io.UnsupportedEncodingException;
 import java.security.MessageDigest;
 import java.security.NoSuchAlgorithmException;
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/auth/UserMapping.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/auth/UserMapping.java
index f130f2e..7a18103 100644
--- a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/auth/UserMapping.java
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/auth/UserMapping.java
@@ -1,26 +1,29 @@
-package org.glyptodon.guacamole.net.basic.auth;
-
-import java.util.HashMap;
-import java.util.Map;
-
 /*
- *  Guacamole - Clientless Remote Desktop
- *  Copyright (C) 2010  Michael Jumper
+ * Copyright (C) 2013 Glyptodon LLC
  *
- *  This program is free software: you can redistribute it and/or modify
- *  it under the terms of the GNU Affero General Public License as published by
- *  the Free Software Foundation, either version 3 of the License, or
- *  (at your option) any later version.
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
  *
- *  This program is distributed in the hope that it will be useful,
- *  but WITHOUT ANY WARRANTY; without even the implied warranty of
- *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- *  GNU Affero General Public License for more details.
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
  *
- *  You should have received a copy of the GNU Affero General Public License
- *  along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
  */
 
+package org.glyptodon.guacamole.net.basic.auth;
+
+import java.util.HashMap;
+import java.util.Map;
 
 /**
  * Mapping of all usernames to corresponding authorizations.
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/auth/package-info.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/auth/package-info.java
index df9a376..0611b37 100644
--- a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/auth/package-info.java
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/auth/package-info.java
@@ -1,3 +1,24 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
 
 /**
  * Classes which drive the default, basic authentication of the Guacamole
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/crud/connectiongroups/ConnectionGroupUtility.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/crud/connectiongroups/ConnectionGroupUtility.java
deleted file mode 100644
index f1a6c8e..0000000
--- a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/crud/connectiongroups/ConnectionGroupUtility.java
+++ /dev/null
@@ -1,67 +0,0 @@
-
-/*
- *  Guacamole - Clientless Remote Desktop
- *  Copyright (C) 2010  Michael Jumper
- *
- *  This program is free software: you can redistribute it and/or modify
- *  it under the terms of the GNU Affero General Public License as published by
- *  the Free Software Foundation, either version 3 of the License, or
- *  (at your option) any later version.
- *
- *  This program is distributed in the hope that it will be useful,
- *  but WITHOUT ANY WARRANTY; without even the implied warranty of
- *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- *  GNU Affero General Public License for more details.
- *
- *  You should have received a copy of the GNU Affero General Public License
- *  along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-package org.glyptodon.guacamole.net.basic.crud.connectiongroups;
-
-import org.glyptodon.guacamole.GuacamoleException;
-import org.glyptodon.guacamole.net.auth.ConnectionGroup;
-import org.glyptodon.guacamole.net.auth.Directory;
-import org.glyptodon.guacamole.net.auth.UserContext;
-
-/**
- * A class that provides helper methods for the ConnectionGroup CRUD servlets.
- * 
- * @author James Muehlner
- */
-class ConnectionGroupUtility {
-    
-    // This class should not be instantiated
-    private ConnectionGroupUtility() {}
-    
-    /**
-     * Get the ConnectionGroupDirectory with the parent connection group
-     * specified by parentID.
-     * 
-     * @param context The UserContext to search for the connectionGroup directory.
-     * @param parentID The ID of the parent connection group to search for.
-     * 
-     * @return The ConnectionGroupDirectory with the parent connection group,
-     *         if found.
-     * @throws GuacamoleException If an error is encountered while getting the
-     *                            connection group directory.
-     */
-    static Directory<String, ConnectionGroup> findConnectionGroupDirectory(
-            UserContext context, String parentID) throws GuacamoleException {
-        
-        // Find the correct connection group directory
-        ConnectionGroup rootGroup = context.getRootConnectionGroup();
-        Directory<String, ConnectionGroup> directory;
-        
-        Directory<String, ConnectionGroup> connectionGroupDirectory = 
-            rootGroup.getConnectionGroupDirectory();
-
-        ConnectionGroup parentGroup = connectionGroupDirectory.get(parentID);
-
-        if(parentGroup == null)
-            return null;
-
-        directory = parentGroup.getConnectionGroupDirectory();
-        
-        return directory;
-    }
-}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/crud/connectiongroups/Create.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/crud/connectiongroups/Create.java
deleted file mode 100644
index 514b646..0000000
--- a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/crud/connectiongroups/Create.java
+++ /dev/null
@@ -1,71 +0,0 @@
-package org.glyptodon.guacamole.net.basic.crud.connectiongroups;
-
-/*
- *  Guacamole - Clientless Remote Desktop
- *  Copyright (C) 2010  Michael Jumper
- *
- *  This program is free software: you can redistribute it and/or modify
- *  it under the terms of the GNU Affero General Public License as published by
- *  the Free Software Foundation, either version 3 of the License, or
- *  (at your option) any later version.
- *
- *  This program is distributed in the hope that it will be useful,
- *  but WITHOUT ANY WARRANTY; without even the implied warranty of
- *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- *  GNU Affero General Public License for more details.
- *
- *  You should have received a copy of the GNU Affero General Public License
- *  along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-import org.glyptodon.guacamole.GuacamoleException;
-import org.glyptodon.guacamole.net.auth.ConnectionGroup;
-import org.glyptodon.guacamole.net.auth.Directory;
-import org.glyptodon.guacamole.net.auth.UserContext;
-import org.glyptodon.guacamole.net.basic.AuthenticatingHttpServlet;
-
-/**
- * Simple HttpServlet which handles connection group creation.
- *
- * @author James Muehlner
- */
-public class Create extends AuthenticatingHttpServlet {
-
-    @Override
-    protected void authenticatedService(
-            UserContext context,
-            HttpServletRequest request, HttpServletResponse response)
-    throws GuacamoleException {
-
-        // Get name and type
-        String name     = request.getParameter("name");
-        String type     = request.getParameter("type");
-        
-        // Get the ID of the parent connection group
-        String parentID = request.getParameter("parentID");
-
-        // Find the correct connection group directory
-        Directory<String, ConnectionGroup> directory = 
-                ConnectionGroupUtility.findConnectionGroupDirectory(context, parentID);
-        
-        if(directory == null)
-            throw new GuacamoleException("Connection group directory not found.");
-
-        // Create connection skeleton
-        ConnectionGroup connectionGroup = new DummyConnectionGroup();
-        connectionGroup.setName(name);
-        
-        if("balancing".equals(type))
-            connectionGroup.setType(ConnectionGroup.Type.BALANCING);
-        else if("organizational".equals(type))
-            connectionGroup.setType(ConnectionGroup.Type.ORGANIZATIONAL);
-
-        // Add connection
-        directory.add(connectionGroup);
-
-    }
-
-}
-
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/crud/connectiongroups/Delete.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/crud/connectiongroups/Delete.java
deleted file mode 100644
index f8a9dba..0000000
--- a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/crud/connectiongroups/Delete.java
+++ /dev/null
@@ -1,56 +0,0 @@
-package org.glyptodon.guacamole.net.basic.crud.connectiongroups;
-
-/*
- *  Guacamole - Clientless Remote Desktop
- *  Copyright (C) 2010  Michael Jumper
- *
- *  This program is free software: you can redistribute it and/or modify
- *  it under the terms of the GNU Affero General Public License as published by
- *  the Free Software Foundation, either version 3 of the License, or
- *  (at your option) any later version.
- *
- *  This program is distributed in the hope that it will be useful,
- *  but WITHOUT ANY WARRANTY; without even the implied warranty of
- *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- *  GNU Affero General Public License for more details.
- *
- *  You should have received a copy of the GNU Affero General Public License
- *  along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-import org.glyptodon.guacamole.GuacamoleException;
-import org.glyptodon.guacamole.net.auth.ConnectionGroup;
-import org.glyptodon.guacamole.net.auth.Directory;
-import org.glyptodon.guacamole.net.auth.UserContext;
-import org.glyptodon.guacamole.net.basic.AuthenticatingHttpServlet;
-
-/**
- * Simple HttpServlet which handles connection group deletion.
- *
- * @author Michael Jumper
- */
-public class Delete extends AuthenticatingHttpServlet {
-
-    @Override
-    protected void authenticatedService(
-            UserContext context,
-            HttpServletRequest request, HttpServletResponse response)
-    throws GuacamoleException {
-
-        // Get ID
-        String identifier = request.getParameter("id");
-
-        // Attempt to get connection group directory
-        Directory<String, ConnectionGroup> directory =
-                context.getRootConnectionGroup().getConnectionGroupDirectory();
-
-        // Remove connection
-        directory.remove(identifier);
-
-
-    }
-
-}
-
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/crud/connectiongroups/DummyConnectionGroup.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/crud/connectiongroups/DummyConnectionGroup.java
deleted file mode 100644
index 19a93d4..0000000
--- a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/crud/connectiongroups/DummyConnectionGroup.java
+++ /dev/null
@@ -1,39 +0,0 @@
-
-package org.glyptodon.guacamole.net.basic.crud.connectiongroups;
-
-import org.glyptodon.guacamole.GuacamoleException;
-import org.glyptodon.guacamole.net.GuacamoleSocket;
-import org.glyptodon.guacamole.net.auth.AbstractConnectionGroup;
-import org.glyptodon.guacamole.net.auth.Connection;
-import org.glyptodon.guacamole.net.auth.ConnectionGroup;
-import org.glyptodon.guacamole.net.auth.Directory;
-import org.glyptodon.guacamole.protocol.GuacamoleClientInformation;
-
-/**
- * Basic ConnectionGroup skeleton, providing a means of storing Connection data
- * prior to CRUD operations. This ConnectionGroup has no functionality for actually
- * performing a connection operation, and does not promote any of the
- * semantics that would otherwise be present because of the authentication
- * provider. It is up to the authentication provider to create a new
- * ConnectionGroup based on the information contained herein.
- *
- * @author James Muehlner
- */
-public class DummyConnectionGroup extends AbstractConnectionGroup {
-
-    @Override
-    public GuacamoleSocket connect(GuacamoleClientInformation info) throws GuacamoleException {
-        throw new UnsupportedOperationException("Connection unsupported in DummyConnectionGroup.");
-    }
-
-    @Override
-    public Directory<String, Connection> getConnectionDirectory() throws GuacamoleException {
-        throw new UnsupportedOperationException("Connection directory unsupported in DummyConnectionGroup.");
-    }
-
-    @Override
-    public Directory<String, ConnectionGroup> getConnectionGroupDirectory() throws GuacamoleException {
-        throw new UnsupportedOperationException("Connection group directory unsuppprted in DummyConnectionGroup.");
-    }
-
-}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/crud/connectiongroups/List.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/crud/connectiongroups/List.java
deleted file mode 100644
index cbce896..0000000
--- a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/crud/connectiongroups/List.java
+++ /dev/null
@@ -1,214 +0,0 @@
-package org.glyptodon.guacamole.net.basic.crud.connectiongroups;
-
-/*
- *  Guacamole - Clientless Remote Desktop
- *  Copyright (C) 2010  Michael Jumper
- *
- *  This program is free software: you can redistribute it and/or modify
- *  it under the terms of the GNU Affero General Public License as published by
- *  the Free Software Foundation, either version 3 of the License, or
- *  (at your option) any later version.
- *
- *  This program is distributed in the hope that it will be useful,
- *  but WITHOUT ANY WARRANTY; without even the implied warranty of
- *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- *  GNU Affero General Public License for more details.
- *
- *  You should have received a copy of the GNU Affero General Public License
- *  along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-import java.io.IOException;
-import java.util.Set;
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-import javax.xml.stream.XMLOutputFactory;
-import javax.xml.stream.XMLStreamException;
-import javax.xml.stream.XMLStreamWriter;
-import org.glyptodon.guacamole.GuacamoleException;
-import org.glyptodon.guacamole.GuacamoleSecurityException;
-import org.glyptodon.guacamole.GuacamoleServerException;
-import org.glyptodon.guacamole.net.auth.ConnectionGroup;
-import org.glyptodon.guacamole.net.auth.Directory;
-import org.glyptodon.guacamole.net.auth.User;
-import org.glyptodon.guacamole.net.auth.UserContext;
-import org.glyptodon.guacamole.net.auth.permission.ConnectionPermission;
-import org.glyptodon.guacamole.net.auth.permission.ObjectPermission;
-import org.glyptodon.guacamole.net.auth.permission.Permission;
-import org.glyptodon.guacamole.net.auth.permission.SystemPermission;
-import org.glyptodon.guacamole.net.basic.AuthenticatingHttpServlet;
-
-/**
- * Simple HttpServlet which outputs XML containing a list of all authorized
- * connection groups for the current user.
- *
- * @author Michael Jumper
- */
-public class List extends AuthenticatingHttpServlet {
-
-    /**
-     * System administration permission.
-     */
-    private static final Permission SYSTEM_PERMISSION = 
-                new SystemPermission(SystemPermission.Type.ADMINISTER);
-
-    
-    /**
-     * Checks whether the given user has permission to perform the given
-     * object operation. Security exceptions are handled appropriately - only
-     * non-security exceptions pass through.
-     *
-     * @param user The user whose permissions should be verified.
-     * @param type The type of operation to check for permission for.
-     * @param identifier The identifier of the connection the operation
-     *                   would be performed upon.
-     * @return true if permission is granted, false otherwise.
-     *
-     * @throws GuacamoleException If an error occurs while checking permissions.
-     */
-    private boolean hasConfigPermission(User user, ObjectPermission.Type type,
-            String identifier)
-    throws GuacamoleException {
-
-        // Build permission
-        Permission permission = new ConnectionPermission(
-            type,
-            identifier
-        );
-
-        try {
-            // Return result of permission check, if possible
-            return user.hasPermission(permission);
-        }
-        catch (GuacamoleSecurityException e) {
-            // If cannot check due to security restrictions, no permission
-            return false;
-        }
-
-    }
-
-    /**
-     * Writes the XML for the given connection group.
-     * 
-     * @param self The user whose permissions dictate the availability of the
-     *             data written.
-     * @param xml The XMLStremWriter to use when writing the data.
-     * @param group The connection group whose XML representation will be
-     *              written.
-     * @throws GuacamoleException If an error occurs while reading the
-     *                            requested data.
-     * @throws XMLStreamException If an error occurs while writing the XML.
-     */
-    private void writeConnectionGroup(User self, XMLStreamWriter xml,
-            ConnectionGroup group) throws GuacamoleException, XMLStreamException {
-
-        // Write group 
-        xml.writeStartElement("group");
-        xml.writeAttribute("id", group.getIdentifier());
-        xml.writeAttribute("name", group.getName());
-
-        // Write group type
-        switch (group.getType()) {
-
-            case ORGANIZATIONAL:
-                xml.writeAttribute("type", "organizational");
-                break;
-
-            case BALANCING:
-                xml.writeAttribute("type", "balancing");
-                break;
-
-        }
-
-        // Write contained connection groups
-        writeConnectionGroups(self, xml, group.getConnectionGroupDirectory());
-
-        // End of group
-        xml.writeEndElement();
-
-    }
-
-    /**
-     * Writes the XML for the given directory of connection groups.
-     * 
-     * @param self The user whose permissions dictate the availability of the
-     *             data written.
-     * @param xml The XMLStremWriter to use when writing the data.
-     * @param directory The directory whose XML representation will be
-     *                  written.
-     * @throws GuacamoleException If an error occurs while reading the
-     *                            requested data.
-     * @throws XMLStreamException If an error occurs while writing the XML.
-     */
-    private void writeConnectionGroups(User self, XMLStreamWriter xml,
-            Directory<String, ConnectionGroup> directory)
-            throws GuacamoleException, XMLStreamException {
-
-        // If no connections, write nothing
-        Set<String> identifiers = directory.getIdentifiers();
-        if (identifiers.isEmpty())
-            return;
-        
-        // Begin connections
-        xml.writeStartElement("groups");
-
-        // For each entry, write corresponding connection element
-        for (String identifier : identifiers) {
-
-            // Write each group
-            ConnectionGroup group = directory.get(identifier);
-            writeConnectionGroup(self, xml, group);
-
-        }
-
-        // End connections
-        xml.writeEndElement();
-
-    }
-
-    @Override
-    protected void authenticatedService(
-            UserContext context,
-            HttpServletRequest request, HttpServletResponse response)
-    throws GuacamoleException {
-
-        // Do not cache
-        response.setHeader("Cache-Control", "no-cache");
-
-        // Write XML content type
-        response.setHeader("Content-Type", "text/xml");
-        
-        // Set encoding
-        response.setCharacterEncoding("UTF-8");
-
-        // Get root group
-        ConnectionGroup root = context.getRootConnectionGroup();
-
-        // Write actual XML
-        try {
-
-            // Get self
-            User self = context.self();
-
-            XMLOutputFactory outputFactory = XMLOutputFactory.newInstance();
-            XMLStreamWriter xml = outputFactory.createXMLStreamWriter(response.getWriter());
-
-            // Write content of root group
-            xml.writeStartDocument();
-            writeConnectionGroup(self, xml, root);
-            xml.writeEndDocument();
-
-        }
-        catch (XMLStreamException e) {
-            throw new GuacamoleServerException(
-                    "Unable to write connection group list XML.", e);
-        }
-        catch (IOException e) {
-            throw new GuacamoleServerException(
-                    "I/O error writing connection group list XML.", e);
-        }
-
-    }
-
-}
-
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/crud/connectiongroups/Move.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/crud/connectiongroups/Move.java
deleted file mode 100644
index 51df4b1..0000000
--- a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/crud/connectiongroups/Move.java
+++ /dev/null
@@ -1,62 +0,0 @@
-package org.glyptodon.guacamole.net.basic.crud.connectiongroups;
-
-/*
- *  Guacamole - Clientless Remote Desktop
- *  Copyright (C) 2010  Michael Jumper
- *
- *  This program is free software: you can redistribute it and/or modify
- *  it under the terms of the GNU Affero General Public License as published by
- *  the Free Software Foundation, either version 3 of the License, or
- *  (at your option) any later version.
- *
- *  This program is distributed in the hope that it will be useful,
- *  but WITHOUT ANY WARRANTY; without even the implied warranty of
- *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- *  GNU Affero General Public License for more details.
- *
- *  You should have received a copy of the GNU Affero General Public License
- *  along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-import org.glyptodon.guacamole.GuacamoleException;
-import org.glyptodon.guacamole.net.auth.ConnectionGroup;
-import org.glyptodon.guacamole.net.auth.Directory;
-import org.glyptodon.guacamole.net.auth.UserContext;
-import org.glyptodon.guacamole.net.basic.AuthenticatingHttpServlet;
-
-/**
- * Simple HttpServlet which handles moving connection groups.
- *
- * @author James Muehlner
- */
-public class Move extends AuthenticatingHttpServlet {
-
-    @Override
-    protected void authenticatedService(
-            UserContext context,
-            HttpServletRequest request, HttpServletResponse response)
-    throws GuacamoleException {
-
-        // Get ID
-        String identifier = request.getParameter("id");
-        
-        // Get the identifier of the new parent connection group
-        String parentID   = request.getParameter("parentID");
-
-        // Attempt to get the new parent connection group directory
-        Directory<String, ConnectionGroup> newParentDirectory =
-                ConnectionGroupUtility.findConnectionGroupDirectory(context, parentID);
-
-        // Attempt to get root connection group directory
-        Directory<String, ConnectionGroup> directory =
-                context.getRootConnectionGroup().getConnectionGroupDirectory();
-
-        // Move connection group
-        directory.move(identifier, newParentDirectory);
-
-    }
-
-}
-
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/crud/connectiongroups/Update.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/crud/connectiongroups/Update.java
deleted file mode 100644
index 7eeb184..0000000
--- a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/crud/connectiongroups/Update.java
+++ /dev/null
@@ -1,66 +0,0 @@
-package org.glyptodon.guacamole.net.basic.crud.connectiongroups;
-
-/*
- *  Guacamole - Clientless Remote Desktop
- *  Copyright (C) 2010  Michael Jumper
- *
- *  This program is free software: you can redistribute it and/or modify
- *  it under the terms of the GNU Affero General Public License as published by
- *  the Free Software Foundation, either version 3 of the License, or
- *  (at your option) any later version.
- *
- *  This program is distributed in the hope that it will be useful,
- *  but WITHOUT ANY WARRANTY; without even the implied warranty of
- *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- *  GNU Affero General Public License for more details.
- *
- *  You should have received a copy of the GNU Affero General Public License
- *  along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-import org.glyptodon.guacamole.GuacamoleException;
-import org.glyptodon.guacamole.net.auth.ConnectionGroup;
-import org.glyptodon.guacamole.net.auth.Directory;
-import org.glyptodon.guacamole.net.auth.UserContext;
-import org.glyptodon.guacamole.net.basic.AuthenticatingHttpServlet;
-
-/**
- * Simple HttpServlet which handles connection group update.
- *
- * @author James Muehlner
- */
-public class Update extends AuthenticatingHttpServlet {
-
-    @Override
-    protected void authenticatedService(
-            UserContext context,
-            HttpServletRequest request, HttpServletResponse response)
-    throws GuacamoleException {
-
-        // Get ID, name, and type
-        String identifier = request.getParameter("id");
-        String name       = request.getParameter("name");
-        String type       = request.getParameter("type");
-
-        // Attempt to get connection group directory
-        Directory<String, ConnectionGroup> directory =
-                context.getRootConnectionGroup().getConnectionGroupDirectory();
-
-        // Create connection group skeleton
-        ConnectionGroup connectionGroup = directory.get(identifier);
-        connectionGroup.setName(name);
-        
-        if("balancing".equals(type))
-            connectionGroup.setType(ConnectionGroup.Type.BALANCING);
-        else if("organizational".equals(type))
-            connectionGroup.setType(ConnectionGroup.Type.ORGANIZATIONAL);
-
-        // Update connection group
-        directory.update(connectionGroup);
-
-    }
-
-}
-
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/crud/connectiongroups/package-info.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/crud/connectiongroups/package-info.java
deleted file mode 100644
index af319d4..0000000
--- a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/crud/connectiongroups/package-info.java
+++ /dev/null
@@ -1,6 +0,0 @@
-
-/**
- * Servlets dedicated to CRUD operations related to ConnectionGroups.
- */
-package org.glyptodon.guacamole.net.basic.crud.connectiongroups;
-
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/crud/connections/ConnectionUtility.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/crud/connections/ConnectionUtility.java
deleted file mode 100644
index 0b3f8dd..0000000
--- a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/crud/connections/ConnectionUtility.java
+++ /dev/null
@@ -1,68 +0,0 @@
-
-/*
- *  Guacamole - Clientless Remote Desktop
- *  Copyright (C) 2010  Michael Jumper
- *
- *  This program is free software: you can redistribute it and/or modify
- *  it under the terms of the GNU Affero General Public License as published by
- *  the Free Software Foundation, either version 3 of the License, or
- *  (at your option) any later version.
- *
- *  This program is distributed in the hope that it will be useful,
- *  but WITHOUT ANY WARRANTY; without even the implied warranty of
- *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- *  GNU Affero General Public License for more details.
- *
- *  You should have received a copy of the GNU Affero General Public License
- *  along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-package org.glyptodon.guacamole.net.basic.crud.connections;
-
-import org.glyptodon.guacamole.GuacamoleException;
-import org.glyptodon.guacamole.net.auth.Connection;
-import org.glyptodon.guacamole.net.auth.ConnectionGroup;
-import org.glyptodon.guacamole.net.auth.Directory;
-import org.glyptodon.guacamole.net.auth.UserContext;
-
-/**
- * A class that provides helper methods for the Connection CRUD servlets.
- * 
- * @author James Muehlner
- */
-class ConnectionUtility {
-    
-    // This class should not be instantiated
-    private ConnectionUtility() {}
-    
-    /**
-     * Get the ConnectionDirectory with the parent connection group specified by
-     * parentID.
-     * 
-     * @param context The UserContext to search for the connection directory.
-     * @param parentID The ID of the parent connection group to search for.
-     * 
-     * @return The ConnectionDirectory with the parent connection group,
-     *         if found.
-     * @throws GuacamoleException If an error is encountered while getting the
-     *                            connection directory.
-     */
-    static Directory<String, Connection> findConnectionDirectory(
-            UserContext context, String parentID) throws GuacamoleException {
-        
-        // Find the correct connection directory
-        ConnectionGroup rootGroup = context.getRootConnectionGroup();
-        Directory<String, Connection> directory;
-        
-        Directory<String, ConnectionGroup> connectionGroupDirectory = 
-            rootGroup.getConnectionGroupDirectory();
-
-        ConnectionGroup parentGroup = connectionGroupDirectory.get(parentID);
-
-        if(parentGroup == null)
-            return null;
-
-        directory = parentGroup.getConnectionDirectory();
-        
-        return directory;
-    }
-}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/crud/connections/Create.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/crud/connections/Create.java
deleted file mode 100644
index e01972e..0000000
--- a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/crud/connections/Create.java
+++ /dev/null
@@ -1,93 +0,0 @@
-package org.glyptodon.guacamole.net.basic.crud.connections;
-
-/*
- *  Guacamole - Clientless Remote Desktop
- *  Copyright (C) 2010  Michael Jumper
- *
- *  This program is free software: you can redistribute it and/or modify
- *  it under the terms of the GNU Affero General Public License as published by
- *  the Free Software Foundation, either version 3 of the License, or
- *  (at your option) any later version.
- *
- *  This program is distributed in the hope that it will be useful,
- *  but WITHOUT ANY WARRANTY; without even the implied warranty of
- *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- *  GNU Affero General Public License for more details.
- *
- *  You should have received a copy of the GNU Affero General Public License
- *  along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-import java.util.Enumeration;
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-import org.glyptodon.guacamole.GuacamoleException;
-import org.glyptodon.guacamole.net.auth.Connection;
-import org.glyptodon.guacamole.net.auth.Directory;
-import org.glyptodon.guacamole.net.auth.UserContext;
-import org.glyptodon.guacamole.net.basic.AuthenticatingHttpServlet;
-import org.glyptodon.guacamole.protocol.GuacamoleConfiguration;
-
-/**
- * Simple HttpServlet which handles connection creation.
- *
- * @author Michael Jumper
- */
-public class Create extends AuthenticatingHttpServlet {
-
-    /**
-     * Prefix given to a parameter name when that parameter is a protocol-
-     * specific parameter meant for the configuration.
-     */
-    public static final String PARAMETER_PREFIX = "_";
-
-    @Override
-    protected void authenticatedService(
-            UserContext context,
-            HttpServletRequest request, HttpServletResponse response)
-    throws GuacamoleException {
-
-        // Get name and protocol
-        String name     = request.getParameter("name");
-        String protocol = request.getParameter("protocol");
-        
-        // Get the ID of the parent connection group
-        String parentID = request.getParameter("parentID");
-
-        // Find the correct connection directory
-        Directory<String, Connection> directory = 
-                ConnectionUtility.findConnectionDirectory(context, parentID);
-        
-        if(directory == null)
-            throw new GuacamoleException("Connection directory not found.");
-
-        // Create config
-        GuacamoleConfiguration config = new GuacamoleConfiguration();
-        config.setProtocol(protocol);
-
-        // Load parameters into config
-        Enumeration<String> params = request.getParameterNames();
-        while (params.hasMoreElements()) {
-
-            // If parameter starts with prefix, load corresponding parameter
-            // value into config
-            String param = params.nextElement();
-            if (param.startsWith(PARAMETER_PREFIX))
-                config.setParameter(
-                    param.substring(PARAMETER_PREFIX.length()),
-                    request.getParameter(param));
-
-        }
-
-        // Create connection skeleton
-        Connection connection = new DummyConnection();
-        connection.setName(name);
-        connection.setConfiguration(config);
-
-        // Add connection
-        directory.add(connection);
-
-    }
-
-}
-
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/crud/connections/Delete.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/crud/connections/Delete.java
deleted file mode 100644
index 5383346..0000000
--- a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/crud/connections/Delete.java
+++ /dev/null
@@ -1,56 +0,0 @@
-package org.glyptodon.guacamole.net.basic.crud.connections;
-
-/*
- *  Guacamole - Clientless Remote Desktop
- *  Copyright (C) 2010  Michael Jumper
- *
- *  This program is free software: you can redistribute it and/or modify
- *  it under the terms of the GNU Affero General Public License as published by
- *  the Free Software Foundation, either version 3 of the License, or
- *  (at your option) any later version.
- *
- *  This program is distributed in the hope that it will be useful,
- *  but WITHOUT ANY WARRANTY; without even the implied warranty of
- *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- *  GNU Affero General Public License for more details.
- *
- *  You should have received a copy of the GNU Affero General Public License
- *  along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-import org.glyptodon.guacamole.GuacamoleException;
-import org.glyptodon.guacamole.net.auth.Connection;
-import org.glyptodon.guacamole.net.auth.Directory;
-import org.glyptodon.guacamole.net.auth.UserContext;
-import org.glyptodon.guacamole.net.basic.AuthenticatingHttpServlet;
-
-/**
- * Simple HttpServlet which handles connection deletion.
- *
- * @author Michael Jumper
- */
-public class Delete extends AuthenticatingHttpServlet {
-
-    @Override
-    protected void authenticatedService(
-            UserContext context,
-            HttpServletRequest request, HttpServletResponse response)
-    throws GuacamoleException {
-
-        // Get ID
-        String identifier = request.getParameter("id");
-
-        // Attempt to get connection directory
-        Directory<String, Connection> directory =
-                context.getRootConnectionGroup().getConnectionDirectory();
-
-        // Remove connection
-        directory.remove(identifier);
-
-
-    }
-
-}
-
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/crud/connections/DummyConnection.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/crud/connections/DummyConnection.java
deleted file mode 100644
index 10218fa..0000000
--- a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/crud/connections/DummyConnection.java
+++ /dev/null
@@ -1,33 +0,0 @@
-
-package org.glyptodon.guacamole.net.basic.crud.connections;
-
-import java.util.List;
-import org.glyptodon.guacamole.GuacamoleException;
-import org.glyptodon.guacamole.net.GuacamoleSocket;
-import org.glyptodon.guacamole.net.auth.AbstractConnection;
-import org.glyptodon.guacamole.net.auth.ConnectionRecord;
-import org.glyptodon.guacamole.protocol.GuacamoleClientInformation;
-
-/**
- * Basic Connection skeleton, providing a means of storing Connection data
- * prior to CRUD operations. This Connection has no functionality for actually
- * performing a connection operation, and does not promote any of the
- * semantics that would otherwise be present because of the authentication
- * provider. It is up to the authentication provider to create a new
- * Connection based on the information contained herein.
- *
- * @author Michael Jumper
- */
-public class DummyConnection extends AbstractConnection {
-
-    @Override
-    public GuacamoleSocket connect(GuacamoleClientInformation info) throws GuacamoleException {
-        throw new UnsupportedOperationException("Connection unsupported in DummyConnection.");
-    }
-
-    @Override
-    public List<ConnectionRecord> getHistory() throws GuacamoleException {
-        throw new UnsupportedOperationException("History unsupported in DummyConnection.");
-    }
-
-}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/crud/connections/List.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/crud/connections/List.java
deleted file mode 100644
index e8f20c9..0000000
--- a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/crud/connections/List.java
+++ /dev/null
@@ -1,338 +0,0 @@
-package org.glyptodon.guacamole.net.basic.crud.connections;
-
-/*
- *  Guacamole - Clientless Remote Desktop
- *  Copyright (C) 2010  Michael Jumper
- *
- *  This program is free software: you can redistribute it and/or modify
- *  it under the terms of the GNU Affero General Public License as published by
- *  the Free Software Foundation, either version 3 of the License, or
- *  (at your option) any later version.
- *
- *  This program is distributed in the hope that it will be useful,
- *  but WITHOUT ANY WARRANTY; without even the implied warranty of
- *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- *  GNU Affero General Public License for more details.
- *
- *  You should have received a copy of the GNU Affero General Public License
- *  along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-import java.io.IOException;
-import java.util.Set;
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-import javax.xml.stream.XMLOutputFactory;
-import javax.xml.stream.XMLStreamException;
-import javax.xml.stream.XMLStreamWriter;
-import org.glyptodon.guacamole.GuacamoleException;
-import org.glyptodon.guacamole.GuacamoleSecurityException;
-import org.glyptodon.guacamole.GuacamoleServerException;
-import org.glyptodon.guacamole.net.auth.Connection;
-import org.glyptodon.guacamole.net.auth.ConnectionGroup;
-import org.glyptodon.guacamole.net.auth.ConnectionRecord;
-import org.glyptodon.guacamole.net.auth.Directory;
-import org.glyptodon.guacamole.net.auth.User;
-import org.glyptodon.guacamole.net.auth.UserContext;
-import org.glyptodon.guacamole.net.auth.permission.ConnectionGroupPermission;
-import org.glyptodon.guacamole.net.auth.permission.ConnectionPermission;
-import org.glyptodon.guacamole.net.auth.permission.ObjectPermission;
-import org.glyptodon.guacamole.net.auth.permission.Permission;
-import org.glyptodon.guacamole.net.auth.permission.SystemPermission;
-import org.glyptodon.guacamole.net.basic.AuthenticatingHttpServlet;
-import org.glyptodon.guacamole.protocol.GuacamoleConfiguration;
-
-/**
- * Simple HttpServlet which outputs XML containing a list of all authorized
- * configurations for the current user.
- *
- * @author Michael Jumper
- */
-public class List extends AuthenticatingHttpServlet {
-
-    /**
-     * System administration permission.
-     */
-    private static final Permission SYSTEM_PERMISSION = 
-                new SystemPermission(SystemPermission.Type.ADMINISTER);
-
-    
-    /**
-     * Checks whether the given user has permission to perform the given
-     * object operation. Security exceptions are handled appropriately - only
-     * non-security exceptions pass through.
-     *
-     * @param user The user whose permissions should be verified.
-     * @param type The type of operation to check for permission for.
-     * @param identifier The identifier of the connection the operation
-     *                   would be performed upon.
-     * @return true if permission is granted, false otherwise.
-     *
-     * @throws GuacamoleException If an error occurs while checking permissions.
-     */
-    private boolean hasConfigPermission(User user, ObjectPermission.Type type,
-            String identifier)
-    throws GuacamoleException {
-
-        // Build permission
-        Permission permission = new ConnectionPermission(
-            type,
-            identifier
-        );
-
-        try {
-            // Return result of permission check, if possible
-            return user.hasPermission(permission);
-        }
-        catch (GuacamoleSecurityException e) {
-            // If cannot check due to security restrictions, no permission
-            return false;
-        }
-
-    }
-
-    /**
-     * Writes the XML for the given connection group.
-     * 
-     * @param self The user whose permissions dictate the availability of the
-     *             data written.
-     * @param xml The XMLStremWriter to use when writing the data.
-     * @param group The connection group whose XML representation will be
-     *              written.
-     * @throws GuacamoleException If an error occurs while reading the
-     *                            requested data.
-     * @throws XMLStreamException If an error occurs while writing the XML.
-     */
-    private void writeConnectionGroup(User self, XMLStreamWriter xml,
-            ConnectionGroup group) throws GuacamoleException, XMLStreamException {
-
-        // Write group 
-        xml.writeStartElement("group");
-        xml.writeAttribute("id", group.getIdentifier());
-        xml.writeAttribute("name", group.getName());
-
-        // Write group type
-        switch (group.getType()) {
-
-            case ORGANIZATIONAL:
-                xml.writeAttribute("type", "organizational");
-                break;
-
-            case BALANCING:
-                xml.writeAttribute("type", "balancing");
-                break;
-
-        }
-
-        Permission group_admin_permission = new ConnectionGroupPermission(
-                ObjectPermission.Type.ADMINISTER, group.getIdentifier());
-
-        // Attempt to list contained groups and connections ONLY if the group
-        // is organizational or we have admin rights to it
-        if (group.getType() == ConnectionGroup.Type.ORGANIZATIONAL
-                || self.hasPermission(SYSTEM_PERMISSION)
-                || self.hasPermission(group_admin_permission)) {
-            writeConnections(self, xml, group.getConnectionDirectory());
-            writeConnectionGroups(self, xml, group.getConnectionGroupDirectory());
-        }
-
-        // End of group
-        xml.writeEndElement();
-
-    }
-
-    /**
-     * Writes the XML for the given connection.
-     * 
-     * @param self The user whose permissions dictate the availability of the
-     *             data written.
-     * @param xml The XMLStremWriter to use when writing the data.
-     * @param connection The connection whose XML representation will be
-     *                   written.
-     * @throws GuacamoleException If an error occurs while reading the
-     *                            requested data.
-     * @throws XMLStreamException If an error occurs while writing the XML.
-     */
-    private void writeConnection(User self, XMLStreamWriter xml,
-            Connection connection) throws GuacamoleException, XMLStreamException {
-
-        // Write connection
-        xml.writeStartElement("connection");
-        xml.writeAttribute("id", connection.getIdentifier());
-        xml.writeAttribute("name", connection.getName());
-        xml.writeAttribute("protocol",
-                connection.getConfiguration().getProtocol());
-
-        // If update permission available, include parameters
-        if (self.hasPermission(SYSTEM_PERMISSION) ||
-                hasConfigPermission(self, ObjectPermission.Type.UPDATE,
-                connection.getIdentifier())) {
-
-            // As update permission is present, also list parameters
-            GuacamoleConfiguration config = connection.getConfiguration();
-            for (String name : config.getParameterNames()) {
-
-                String value = connection.getConfiguration().getParameter(name);
-                xml.writeStartElement("param");
-                xml.writeAttribute("name", name);
-
-                if (value != null)
-                    xml.writeCharacters(value);
-
-                xml.writeEndElement();
-            }
-
-        }
-
-        // Write history
-        xml.writeStartElement("history");
-        for (ConnectionRecord record : connection.getHistory()) {
-            xml.writeStartElement("record");
-
-            // Start date
-            xml.writeAttribute("start",
-                Long.toString(record.getStartDate().getTime()));
-
-            // End date
-            if (record.getEndDate() != null)
-                xml.writeAttribute("end",
-                    Long.toString(record.getEndDate().getTime()));
-
-            // Whether connection currently active
-            if (record.isActive())
-                xml.writeAttribute("active", "yes");
-
-            // User involved
-            xml.writeCharacters(record.getUsername());
-
-            xml.writeEndElement();
-        }
-        xml.writeEndElement();
-
-        // End connection
-        xml.writeEndElement();
-        
-    }
-
-    /**
-     * Writes the XML for the given directory of connection groups.
-     * 
-     * @param self The user whose permissions dictate the availability of the
-     *             data written.
-     * @param xml The XMLStremWriter to use when writing the data.
-     * @param directory The directory whose XML representation will be
-     *                  written.
-     * @throws GuacamoleException If an error occurs while reading the
-     *                            requested data.
-     * @throws XMLStreamException If an error occurs while writing the XML.
-     */
-    private void writeConnectionGroups(User self, XMLStreamWriter xml,
-            Directory<String, ConnectionGroup> directory)
-            throws GuacamoleException, XMLStreamException {
-
-        // If no connections, write nothing
-        Set<String> identifiers = directory.getIdentifiers();
-        if (identifiers.isEmpty())
-            return;
-        
-        // Begin connections
-        xml.writeStartElement("groups");
-
-        // For each entry, write corresponding connection element
-        for (String identifier : identifiers) {
-
-            // Write each group
-            ConnectionGroup group = directory.get(identifier);
-            writeConnectionGroup(self, xml, group);
-
-        }
-
-        // End connections
-        xml.writeEndElement();
-
-    }
-
-    /**
-     * Writes the XML for the given directory of connections.
-     * 
-     * @param self The user whose permissions dictate the availability of the
-     *             data written.
-     * @param xml The XMLStremWriter to use when writing the data.
-     * @param directory The directory whose XML representation will be
-     *                  written.
-     * @throws GuacamoleException If an error occurs while reading the
-     *                            requested data.
-     * @throws XMLStreamException If an error occurs while writing the XML.
-     */
-    private void writeConnections(User self, XMLStreamWriter xml,
-            Directory<String, Connection> directory)
-            throws GuacamoleException, XMLStreamException {
-
-        // If no connections, write nothing
-        Set<String> identifiers = directory.getIdentifiers();
-        if (identifiers.isEmpty())
-            return;
-        
-        // Begin connections
-        xml.writeStartElement("connections");
-
-        // For each entry, write corresponding connection element
-        for (String identifier : identifiers) {
-
-            // Write each connection
-            Connection connection = directory.get(identifier);
-            writeConnection(self, xml, connection);
-
-        }
-
-        // End connections
-        xml.writeEndElement();
-
-    }
-
-    @Override
-    protected void authenticatedService(
-            UserContext context,
-            HttpServletRequest request, HttpServletResponse response)
-    throws GuacamoleException {
-
-        // Do not cache
-        response.setHeader("Cache-Control", "no-cache");
-
-        // Write XML content type
-        response.setHeader("Content-Type", "text/xml");
-        
-        // Set encoding
-        response.setCharacterEncoding("UTF-8");
-
-        // Get root group
-        ConnectionGroup root = context.getRootConnectionGroup();
-
-        // Write actual XML
-        try {
-
-            // Get self
-            User self = context.self();
-
-            XMLOutputFactory outputFactory = XMLOutputFactory.newInstance();
-            XMLStreamWriter xml = outputFactory.createXMLStreamWriter(response.getWriter());
-
-            // Write content of root group
-            xml.writeStartDocument();
-            writeConnectionGroup(self, xml, root);
-            xml.writeEndDocument();
-
-        }
-        catch (XMLStreamException e) {
-            throw new GuacamoleServerException(
-                    "Unable to write configuration list XML.", e);
-        }
-        catch (IOException e) {
-            throw new GuacamoleServerException(
-                    "I/O error writing configuration list XML.", e);
-        }
-
-    }
-
-}
-
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/crud/connections/Move.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/crud/connections/Move.java
deleted file mode 100644
index 6db6ec8..0000000
--- a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/crud/connections/Move.java
+++ /dev/null
@@ -1,62 +0,0 @@
-package org.glyptodon.guacamole.net.basic.crud.connections;
-
-/*
- *  Guacamole - Clientless Remote Desktop
- *  Copyright (C) 2010  Michael Jumper
- *
- *  This program is free software: you can redistribute it and/or modify
- *  it under the terms of the GNU Affero General Public License as published by
- *  the Free Software Foundation, either version 3 of the License, or
- *  (at your option) any later version.
- *
- *  This program is distributed in the hope that it will be useful,
- *  but WITHOUT ANY WARRANTY; without even the implied warranty of
- *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- *  GNU Affero General Public License for more details.
- *
- *  You should have received a copy of the GNU Affero General Public License
- *  along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-import org.glyptodon.guacamole.GuacamoleException;
-import org.glyptodon.guacamole.net.auth.Connection;
-import org.glyptodon.guacamole.net.auth.Directory;
-import org.glyptodon.guacamole.net.auth.UserContext;
-import org.glyptodon.guacamole.net.basic.AuthenticatingHttpServlet;
-
-/**
- * Simple HttpServlet which handles moving connections.
- *
- * @author Michael Jumper
- */
-public class Move extends AuthenticatingHttpServlet {
-
-    @Override
-    protected void authenticatedService(
-            UserContext context,
-            HttpServletRequest request, HttpServletResponse response)
-    throws GuacamoleException {
-
-        // Get ID
-        String identifier = request.getParameter("id");
-        
-        // Get the identifier of the new parent connection group
-        String parentID   = request.getParameter("parentID");
-
-        // Attempt to get the new parent connection directory
-        Directory<String, Connection> newParentDirectory =
-                ConnectionUtility.findConnectionDirectory(context, parentID);
-
-        // Attempt to get root connection directory
-        Directory<String, Connection> directory =
-                context.getRootConnectionGroup().getConnectionDirectory();
-
-        // Move connection
-        directory.move(identifier, newParentDirectory);
-
-    }
-
-}
-
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/crud/connections/Update.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/crud/connections/Update.java
deleted file mode 100644
index 631e20a..0000000
--- a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/crud/connections/Update.java
+++ /dev/null
@@ -1,88 +0,0 @@
-package org.glyptodon.guacamole.net.basic.crud.connections;
-
-/*
- *  Guacamole - Clientless Remote Desktop
- *  Copyright (C) 2010  Michael Jumper
- *
- *  This program is free software: you can redistribute it and/or modify
- *  it under the terms of the GNU Affero General Public License as published by
- *  the Free Software Foundation, either version 3 of the License, or
- *  (at your option) any later version.
- *
- *  This program is distributed in the hope that it will be useful,
- *  but WITHOUT ANY WARRANTY; without even the implied warranty of
- *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- *  GNU Affero General Public License for more details.
- *
- *  You should have received a copy of the GNU Affero General Public License
- *  along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-import java.util.Enumeration;
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-import org.glyptodon.guacamole.GuacamoleException;
-import org.glyptodon.guacamole.net.auth.Connection;
-import org.glyptodon.guacamole.net.auth.Directory;
-import org.glyptodon.guacamole.net.auth.UserContext;
-import org.glyptodon.guacamole.net.basic.AuthenticatingHttpServlet;
-import org.glyptodon.guacamole.protocol.GuacamoleConfiguration;
-
-/**
- * Simple HttpServlet which handles connection update.
- *
- * @author Michael Jumper
- */
-public class Update extends AuthenticatingHttpServlet {
-
-    /**
-     * Prefix given to a parameter name when that parameter is a protocol-
-     * specific parameter meant for the configuration.
-     */
-    public static final String PARAMETER_PREFIX = "_";
-
-    @Override
-    protected void authenticatedService(
-            UserContext context,
-            HttpServletRequest request, HttpServletResponse response)
-    throws GuacamoleException {
-
-        // Get ID, name, and protocol
-        String identifier = request.getParameter("id");
-        String name       = request.getParameter("name");
-        String protocol   = request.getParameter("protocol");
-
-        // Attempt to get connection directory
-        Directory<String, Connection> directory =
-                context.getRootConnectionGroup().getConnectionDirectory();
-
-        // Create config
-        GuacamoleConfiguration config = new GuacamoleConfiguration();
-        config.setProtocol(protocol);
-
-        // Load parameters into config
-        Enumeration<String> params = request.getParameterNames();
-        while (params.hasMoreElements()) {
-
-            // If parameter starts with prefix, load corresponding parameter
-            // value into config
-            String param = params.nextElement();
-            if (param.startsWith(PARAMETER_PREFIX))
-                config.setParameter(
-                    param.substring(PARAMETER_PREFIX.length()),
-                    request.getParameter(param));
-
-        }
-
-        // Create connection skeleton
-        Connection connection = directory.get(identifier);
-        connection.setName(name);
-        connection.setConfiguration(config);
-
-        // Update connection
-        directory.update(connection);
-
-    }
-
-}
-
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/crud/connections/package-info.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/crud/connections/package-info.java
deleted file mode 100644
index 1c67b01..0000000
--- a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/crud/connections/package-info.java
+++ /dev/null
@@ -1,6 +0,0 @@
-
-/**
- * Servlets dedicated to CRUD operations related to Connections.
- */
-package org.glyptodon.guacamole.net.basic.crud.connections;
-
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/crud/permissions/List.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/crud/permissions/List.java
deleted file mode 100644
index a8aaeae..0000000
--- a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/crud/permissions/List.java
+++ /dev/null
@@ -1,220 +0,0 @@
-package org.glyptodon.guacamole.net.basic.crud.permissions;
-
-/*
- *  Guacamole - Clientless Remote Desktop
- *  Copyright (C) 2010  Michael Jumper
- *
- *  This program is free software: you can redistribute it and/or modify
- *  it under the terms of the GNU Affero General Public License as published by
- *  the Free Software Foundation, either version 3 of the License, or
- *  (at your option) any later version.
- *
- *  This program is distributed in the hope that it will be useful,
- *  but WITHOUT ANY WARRANTY; without even the implied warranty of
- *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- *  GNU Affero General Public License for more details.
- *
- *  You should have received a copy of the GNU Affero General Public License
- *  along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-import java.io.IOException;
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-import javax.xml.stream.XMLOutputFactory;
-import javax.xml.stream.XMLStreamException;
-import javax.xml.stream.XMLStreamWriter;
-import org.glyptodon.guacamole.GuacamoleClientException;
-import org.glyptodon.guacamole.GuacamoleException;
-import org.glyptodon.guacamole.GuacamoleSecurityException;
-import org.glyptodon.guacamole.GuacamoleServerException;
-import org.glyptodon.guacamole.net.auth.Directory;
-import org.glyptodon.guacamole.net.auth.User;
-import org.glyptodon.guacamole.net.auth.UserContext;
-import org.glyptodon.guacamole.net.auth.permission.ConnectionGroupPermission;
-import org.glyptodon.guacamole.net.auth.permission.ConnectionPermission;
-import org.glyptodon.guacamole.net.auth.permission.ObjectPermission;
-import org.glyptodon.guacamole.net.auth.permission.Permission;
-import org.glyptodon.guacamole.net.auth.permission.SystemPermission;
-import org.glyptodon.guacamole.net.auth.permission.UserPermission;
-import org.glyptodon.guacamole.net.basic.AuthenticatingHttpServlet;
-
-/**
- * Simple HttpServlet which outputs XML containing a list of all visible
- * permissions of a given user.
- *
- * @author Michael Jumper
- */
-public class List extends AuthenticatingHttpServlet {
-
-    /**
-     * Returns the XML attribute value representation of the given
-     * SystemPermission.Type.
-     *
-     * @param type The SystemPermission.Type to translate into a String.
-     * @return The XML attribute value representation of the given
-     *         SystemPermission.Type.
-     *
-     * @throws GuacamoleException If the type given is not implemented.
-     */
-    private String toString(SystemPermission.Type type)
-        throws GuacamoleException {
-
-        switch (type) {
-            case CREATE_USER:             return "create-user";
-            case CREATE_CONNECTION:       return "create-connection";
-            case CREATE_CONNECTION_GROUP: return "create-connection-group";
-            case ADMINISTER:              return "admin";
-        }
-
-        throw new GuacamoleException("Unknown permission type: " + type);
-
-    }
-
-    /**
-     * Returns the XML attribute value representation of the given
-     * ObjectPermission.Type.
-     *
-     * @param type The ObjectPermission.Type to translate into a String.
-     * @return The XML attribute value representation of the given
-     *         ObjectPermission.Type.
-     *
-     * @throws GuacamoleException If the type given is not implemented.
-     */
-    private String toString(ObjectPermission.Type type)
-        throws GuacamoleException {
-
-        switch (type) {
-            case READ:       return "read";
-            case UPDATE:     return "update";
-            case DELETE:     return "delete";
-            case ADMINISTER: return "admin";
-        }
-
-        throw new GuacamoleException("Unknown permission type: " + type);
-
-    }
-
-    @Override
-    protected void authenticatedService(
-            UserContext context,
-            HttpServletRequest request, HttpServletResponse response)
-    throws GuacamoleException {
-
-        // Do not cache
-        response.setHeader("Cache-Control", "no-cache");
-        
-        // Set encoding
-        response.setCharacterEncoding("UTF-8");
-
-        // Write actual XML
-        try {
-
-            User user;
-
-            // Get username
-            String username = request.getParameter("user");
-            if (username != null) {
-
-                // Get user directory
-                Directory<String, User> users = context.getUserDirectory();
-
-                // Get specific user
-                user = users.get(username);
-            }
-            else
-                user = context.self();
-            
-            if (user == null)
-                throw new GuacamoleSecurityException("No such user.");
-
-            // Write XML content type
-            response.setHeader("Content-Type", "text/xml");
-
-            XMLOutputFactory outputFactory = XMLOutputFactory.newInstance();
-            XMLStreamWriter xml = outputFactory.createXMLStreamWriter(response.getWriter());
-
-            // Begin document
-            xml.writeStartDocument();
-            xml.writeStartElement("permissions");
-            xml.writeAttribute("user", user.getUsername());
-
-            // For each entry, write corresponding user element
-            for (Permission permission : user.getPermissions()) {
-
-                // System permission
-                if (permission instanceof SystemPermission) {
-
-                    // Get permission
-                    SystemPermission sp = (SystemPermission) permission;
-
-                    // Write permission
-                    xml.writeEmptyElement("system");
-                    xml.writeAttribute("type", toString(sp.getType()));
-
-                }
-
-                // Config permission
-                else if (permission instanceof ConnectionPermission) {
-
-                    // Get permission
-                    ConnectionPermission cp =
-                            (ConnectionPermission) permission;
-
-                    // Write permission
-                    xml.writeEmptyElement("connection");
-                    xml.writeAttribute("type", toString(cp.getType()));
-                    xml.writeAttribute("name", cp.getObjectIdentifier());
-
-                }
-
-                // Connection group permission
-                else if (permission instanceof ConnectionGroupPermission) {
-
-                    // Get permission
-                    ConnectionGroupPermission cgp =
-                            (ConnectionGroupPermission) permission;
-
-                    // Write permission
-                    xml.writeEmptyElement("connection-group");
-                    xml.writeAttribute("type", toString(cgp.getType()));
-                    xml.writeAttribute("name", cgp.getObjectIdentifier());
-
-                }
-
-                // User permission
-                else if (permission instanceof UserPermission) {
-
-                    // Get permission
-                    UserPermission up = (UserPermission) permission;
-
-                    // Write permission
-                    xml.writeEmptyElement("user");
-                    xml.writeAttribute("type", toString(up.getType()));
-                    xml.writeAttribute("name", up.getObjectIdentifier());
-
-                }
-
-                else
-                    throw new GuacamoleClientException(
-                            "Unsupported permission type.");
-
-            }
-
-            // End document
-            xml.writeEndElement();
-            xml.writeEndDocument();
-
-        }
-        catch (XMLStreamException e) {
-            throw new GuacamoleServerException(
-                    "Unable to write permission list XML.", e);
-        }
-        catch (IOException e) {
-            throw new GuacamoleServerException(
-                    "I/O error writing permission list XML.", e);
-        }
-
-    }
-
-}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/crud/permissions/package-info.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/crud/permissions/package-info.java
deleted file mode 100644
index 39b9c68..0000000
--- a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/crud/permissions/package-info.java
+++ /dev/null
@@ -1,6 +0,0 @@
-
-/**
- * Servlets dedicated to CRUD operations related to Permissions.
- */
-package org.glyptodon.guacamole.net.basic.crud.permissions;
-
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/crud/protocols/List.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/crud/protocols/List.java
deleted file mode 100644
index f219cb4..0000000
--- a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/crud/protocols/List.java
+++ /dev/null
@@ -1,300 +0,0 @@
-package org.glyptodon.guacamole.net.basic.crud.protocols;
-
-/*
- *  Guacamole - Clientless Remote Desktop
- *  Copyright (C) 2010  Michael Jumper
- *
- *  This program is free software: you can redistribute it and/or modify
- *  it under the terms of the GNU Affero General Public License as published by
- *  the Free Software Foundation, either version 3 of the License, or
- *  (at your option) any later version.
- *
- *  This program is distributed in the hope that it will be useful,
- *  but WITHOUT ANY WARRANTY; without even the implied warranty of
- *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- *  GNU Affero General Public License for more details.
- *
- *  You should have received a copy of the GNU Affero General Public License
- *  along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-import java.io.BufferedInputStream;
-import java.io.File;
-import java.io.FileInputStream;
-import java.io.FilenameFilter;
-import java.io.IOException;
-import java.io.InputStream;
-import java.util.HashMap;
-import java.util.Map;
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-import javax.xml.stream.XMLOutputFactory;
-import javax.xml.stream.XMLStreamException;
-import javax.xml.stream.XMLStreamWriter;
-import org.glyptodon.guacamole.GuacamoleException;
-import org.glyptodon.guacamole.GuacamoleServerException;
-import org.glyptodon.guacamole.net.auth.UserContext;
-import org.glyptodon.guacamole.net.basic.AuthenticatingHttpServlet;
-import org.glyptodon.guacamole.net.basic.ProtocolInfo;
-import org.glyptodon.guacamole.net.basic.ProtocolParameter;
-import org.glyptodon.guacamole.net.basic.ProtocolParameterOption;
-import org.glyptodon.guacamole.net.basic.xml.DocumentHandler;
-import org.glyptodon.guacamole.net.basic.xml.protocol.ProtocolTagHandler;
-import org.glyptodon.guacamole.properties.GuacamoleHome;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-import org.xml.sax.InputSource;
-import org.xml.sax.SAXException;
-import org.xml.sax.XMLReader;
-import org.xml.sax.helpers.XMLReaderFactory;
-
-/**
- * Simple HttpServlet which outputs XML containing a list of all visible
- * protocols.
- *
- * @author Michael Jumper
- */
-public class List extends AuthenticatingHttpServlet {
-
-    /**
-     * Logger for this class.
-     */
-    private Logger logger = LoggerFactory.getLogger(List.class);
-
-    /**
-     * Array of all known protocol names.
-     */
-    private static final String[] KNOWN_PROTOCOLS = new String[]{
-        "vnc", "rdp", "ssh"};
-
-    /**
-     * Parses the given XML file, returning the parsed ProtocolInfo.
-     *
-     * @param input An input stream containing XML describing the parameters
-     *              associated with a protocol supported by Guacamole.
-     * @return A new ProtocolInfo object which contains the parameters described
-     *         by the XML file parsed.
-     * @throws GuacamoleException If an error occurs while parsing the XML file.
-     */
-    private ProtocolInfo getProtocol(InputStream input)
-            throws GuacamoleException {
-
-        // Parse document
-        try {
-
-            // Get handler for root element
-            ProtocolTagHandler protocolTagHandler =
-                    new ProtocolTagHandler();
-
-            // Set up document handler
-            DocumentHandler contentHandler = new DocumentHandler(
-                    "protocol", protocolTagHandler);
-
-            // Set up XML parser
-            XMLReader parser = XMLReaderFactory.createXMLReader();
-            parser.setContentHandler(contentHandler);
-
-            // Read and parse file
-            InputStream xml = new BufferedInputStream(input);
-            parser.parse(new InputSource(xml));
-            xml.close();
-
-            // Return parsed protocol
-            return protocolTagHandler.asProtocolInfo();
-
-        }
-        catch (IOException e) {
-            throw new GuacamoleException("Error reading basic user mapping file.", e);
-        }
-        catch (SAXException e) {
-            throw new GuacamoleException("Error parsing basic user mapping XML.", e);
-        }
-
-    }
-
-    /**
-     * Given an XML stream and a fully-populated ProtocolInfo object, writes
-     * out the corresponding protocol XML describing all available parameters.
-     *
-     * @param xml The XMLStreamWriter to use to write the XML.
-     * @param protocol The ProtocolInfo object to read parameters and protocol
-     *                 information from.
-     * @throws XMLStreamException If an error occurs while writing the XML.
-     */
-    private void writeProtocol(XMLStreamWriter xml, ProtocolInfo protocol)
-            throws XMLStreamException {
-
-        // Write protocol
-        xml.writeStartElement("protocol");
-        xml.writeAttribute("name", protocol.getName());
-        xml.writeAttribute("title", protocol.getTitle());
-
-        // Write parameters
-        for (ProtocolParameter param : protocol.getParameters()) {
-
-            // Write param tag
-            xml.writeStartElement("param");
-            xml.writeAttribute("name", param.getName());
-            xml.writeAttribute("title", param.getTitle());
-
-            // Write type
-            switch (param.getType()) {
-
-                // Text parameter
-                case TEXT:
-                    xml.writeAttribute("type", "text");
-                    break;
-
-                // Password parameter
-                case PASSWORD:
-                    xml.writeAttribute("type", "password");
-                    break;
-
-                // Numeric parameter
-                case NUMERIC:
-                    xml.writeAttribute("type", "numeric");
-                    break;
-
-                // Boolean parameter
-                case BOOLEAN:
-                    xml.writeAttribute("type", "boolean");
-                    xml.writeAttribute("value", param.getValue());
-                    break;
-
-                // Enumerated parameter
-                case ENUM:
-                    xml.writeAttribute("type", "enum");
-                    break;
-
-                // If unknown, fail explicitly
-                default:
-                    throw new UnsupportedOperationException(
-                        "Parameter type not supported: " + param.getType());
-
-            }
-
-            // Write options
-            for (ProtocolParameterOption option : param.getOptions()) {
-                xml.writeStartElement("option");
-                xml.writeAttribute("value", option.getValue());
-                xml.writeCharacters(option.getTitle());
-                xml.writeEndElement();
-            }
-
-            // End parameter
-            xml.writeEndElement();
-
-        }
-
-        // End protocol
-        xml.writeEndElement();
-
-    }
-
-    @Override
-    protected void authenticatedService(
-            UserContext context,
-            HttpServletRequest request, HttpServletResponse response)
-    throws GuacamoleException {
-
-        // Do not cache
-        response.setHeader("Cache-Control", "no-cache");
-        
-        // Set encoding
-        response.setCharacterEncoding("UTF-8");
-
-        // Map of all available protocols
-        Map<String, ProtocolInfo> protocols = new HashMap<String, ProtocolInfo>();
-
-        // Get protcols directory
-        File protocol_directory = new File(GuacamoleHome.getDirectory(),
-                "protocols");
-
-        // Read protocols from directory if it exists
-        if (protocol_directory.isDirectory()) {
-
-            // Get all XML files
-            File[] files = protocol_directory.listFiles(
-                new FilenameFilter() {
-
-                    @Override
-                    public boolean accept(File file, String string) {
-                        return string.endsWith(".xml");
-                    }
-
-                }
-            );
-
-            // Load each protocol from each file
-            for (File file : files) {
-
-                try {
-
-                    // Parse protocol
-                    FileInputStream stream = new FileInputStream(file);
-                    ProtocolInfo protocol = getProtocol(stream);
-                    stream.close();
-
-                    // Store protocol
-                    protocols.put(protocol.getName(), protocol);
-
-                }
-                catch (IOException e) {
-                    logger.error("Unable to read protocol XML.", e);
-                }
-
-            }
-
-        }
-
-        // If known protocols are not already defined, read from classpath
-        for (String protocol : KNOWN_PROTOCOLS) {
-
-            // If protocol not defined yet, attempt to load from classpath
-            if (!protocols.containsKey(protocol)) {
-
-                InputStream stream = List.class.getResourceAsStream(
-                        "/net/sourceforge/guacamole/net/protocols/"
-                        + protocol + ".xml");
-
-                // Parse XML if available
-                if (stream != null)
-                    protocols.put(protocol, getProtocol(stream));
-
-            }
-
-        }
-
-        // Write actual XML
-        try {
-            // Write XML content type
-            response.setHeader("Content-Type", "text/xml");
-
-            XMLOutputFactory outputFactory = XMLOutputFactory.newInstance();
-            XMLStreamWriter xml = outputFactory.createXMLStreamWriter(response.getWriter());
-
-            // Begin document
-            xml.writeStartDocument();
-            xml.writeStartElement("protocols");
-
-            // Write all protocols
-            for (ProtocolInfo protocol : protocols.values())
-                writeProtocol(xml, protocol);
-
-            // End document
-            xml.writeEndElement();
-            xml.writeEndDocument();
-
-        }
-        catch (XMLStreamException e) {
-            throw new GuacamoleServerException(
-                    "Unable to write protocol list XML.", e);
-        }
-        catch (IOException e) {
-            throw new GuacamoleServerException(
-                    "I/O error writing protocol list XML.", e);
-        }
-
-    }
-
-}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/crud/protocols/package-info.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/crud/protocols/package-info.java
deleted file mode 100644
index 1d81e17..0000000
--- a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/crud/protocols/package-info.java
+++ /dev/null
@@ -1,6 +0,0 @@
-
-/**
- * Servlets dedicated to CRUD operations related to protocols.
- */
-package org.glyptodon.guacamole.net.basic.crud.protocols;
-
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/crud/users/Create.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/crud/users/Create.java
deleted file mode 100644
index e7859c9..0000000
--- a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/crud/users/Create.java
+++ /dev/null
@@ -1,61 +0,0 @@
-package org.glyptodon.guacamole.net.basic.crud.users;
-
-/*
- *  Guacamole - Clientless Remote Desktop
- *  Copyright (C) 2010  Michael Jumper
- *
- *  This program is free software: you can redistribute it and/or modify
- *  it under the terms of the GNU Affero General Public License as published by
- *  the Free Software Foundation, either version 3 of the License, or
- *  (at your option) any later version.
- *
- *  This program is distributed in the hope that it will be useful,
- *  but WITHOUT ANY WARRANTY; without even the implied warranty of
- *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- *  GNU Affero General Public License for more details.
- *
- *  You should have received a copy of the GNU Affero General Public License
- *  along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-import java.util.UUID;
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-import org.glyptodon.guacamole.GuacamoleException;
-import org.glyptodon.guacamole.net.auth.Directory;
-import org.glyptodon.guacamole.net.auth.User;
-import org.glyptodon.guacamole.net.auth.UserContext;
-import org.glyptodon.guacamole.net.basic.AuthenticatingHttpServlet;
-
-/**
- * Simple HttpServlet which handles user creation.
- *
- * @author Michael Jumper
- */
-public class Create extends AuthenticatingHttpServlet {
-
-    @Override
-    protected void authenticatedService(
-            UserContext context,
-            HttpServletRequest request, HttpServletResponse response)
-    throws GuacamoleException {
-
-        // Create user as specified
-        String username = request.getParameter("name");
-
-        // Attempt to get user directory
-        Directory<String, User> directory =
-                context.getUserDirectory();
-
-        // Create user skeleton
-        User user = new DummyUser();
-        user.setUsername(username);
-        user.setPassword(UUID.randomUUID().toString());
-
-        // Add user
-        directory.add(user);
-
-    }
-
-}
-
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/crud/users/Delete.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/crud/users/Delete.java
deleted file mode 100644
index bb42740..0000000
--- a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/crud/users/Delete.java
+++ /dev/null
@@ -1,54 +0,0 @@
-package org.glyptodon.guacamole.net.basic.crud.users;
-
-/*
- *  Guacamole - Clientless Remote Desktop
- *  Copyright (C) 2010  Michael Jumper
- *
- *  This program is free software: you can redistribute it and/or modify
- *  it under the terms of the GNU Affero General Public License as published by
- *  the Free Software Foundation, either version 3 of the License, or
- *  (at your option) any later version.
- *
- *  This program is distributed in the hope that it will be useful,
- *  but WITHOUT ANY WARRANTY; without even the implied warranty of
- *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- *  GNU Affero General Public License for more details.
- *
- *  You should have received a copy of the GNU Affero General Public License
- *  along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-import org.glyptodon.guacamole.GuacamoleException;
-import org.glyptodon.guacamole.net.auth.Directory;
-import org.glyptodon.guacamole.net.auth.User;
-import org.glyptodon.guacamole.net.auth.UserContext;
-import org.glyptodon.guacamole.net.basic.AuthenticatingHttpServlet;
-
-/**
- * Simple HttpServlet which handles user deletion.
- *
- * @author Michael Jumper
- */
-public class Delete extends AuthenticatingHttpServlet {
-
-    @Override
-    protected void authenticatedService(
-            UserContext context,
-            HttpServletRequest request, HttpServletResponse response)
-    throws GuacamoleException {
-
-        // Get username
-        String username = request.getParameter("name");
-
-        // Attempt to get user directory
-        Directory<String, User> directory = context.getUserDirectory();
-
-        // Remove user
-        directory.remove(username);
-
-    }
-
-}
-
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/crud/users/DummyUser.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/crud/users/DummyUser.java
deleted file mode 100644
index 8e6bbc5..0000000
--- a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/crud/users/DummyUser.java
+++ /dev/null
@@ -1,46 +0,0 @@
-
-package org.glyptodon.guacamole.net.basic.crud.users;
-
-import java.util.HashSet;
-import java.util.Set;
-import org.glyptodon.guacamole.GuacamoleException;
-import org.glyptodon.guacamole.net.auth.AbstractUser;
-import org.glyptodon.guacamole.net.auth.permission.Permission;
-
-/**
- * Basic User skeleton, providing a means of storing User data prior to CRUD
- * operations. This User does not promote any of the semantics that would
- * otherwise be present because of the authentication provider. It is up to the
- * authentication provider to create a new User based on the information
- * contained herein.
- *
- * @author Michael Jumper
- */
-public class DummyUser extends AbstractUser {
-
-    /**
-     * Set of all available permissions.
-     */
-    private Set<Permission> permissions = new HashSet<Permission>();
-
-    @Override
-    public Set<Permission> getPermissions() throws GuacamoleException {
-        return permissions;
-    }
-
-    @Override
-    public boolean hasPermission(Permission permission) throws GuacamoleException {
-        return permissions.contains(permission);
-    }
-
-    @Override
-    public void addPermission(Permission permission) throws GuacamoleException {
-        permissions.add(permission);
-    }
-
-    @Override
-    public void removePermission(Permission permission) throws GuacamoleException {
-        permissions.remove(permission);
-    }
-
-}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/crud/users/List.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/crud/users/List.java
deleted file mode 100644
index 18a7eb1..0000000
--- a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/crud/users/List.java
+++ /dev/null
@@ -1,102 +0,0 @@
-package org.glyptodon.guacamole.net.basic.crud.users;
-
-/*
- *  Guacamole - Clientless Remote Desktop
- *  Copyright (C) 2010  Michael Jumper
- *
- *  This program is free software: you can redistribute it and/or modify
- *  it under the terms of the GNU Affero General Public License as published by
- *  the Free Software Foundation, either version 3 of the License, or
- *  (at your option) any later version.
- *
- *  This program is distributed in the hope that it will be useful,
- *  but WITHOUT ANY WARRANTY; without even the implied warranty of
- *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- *  GNU Affero General Public License for more details.
- *
- *  You should have received a copy of the GNU Affero General Public License
- *  along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-import java.io.IOException;
-import java.util.Set;
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-import javax.xml.stream.XMLOutputFactory;
-import javax.xml.stream.XMLStreamException;
-import javax.xml.stream.XMLStreamWriter;
-import org.glyptodon.guacamole.GuacamoleException;
-import org.glyptodon.guacamole.GuacamoleServerException;
-import org.glyptodon.guacamole.net.auth.Directory;
-import org.glyptodon.guacamole.net.auth.User;
-import org.glyptodon.guacamole.net.auth.UserContext;
-import org.glyptodon.guacamole.net.basic.AuthenticatingHttpServlet;
-
-/**
- * Simple HttpServlet which outputs XML containing a list of all visible users.
- *
- * @author Michael Jumper
- */
-public class List extends AuthenticatingHttpServlet {
-
-    @Override
-    protected void authenticatedService(
-            UserContext context,
-            HttpServletRequest request, HttpServletResponse response)
-    throws GuacamoleException {
-
-        // Do not cache
-        response.setHeader("Cache-Control", "no-cache");
-
-        // Write XML content type
-        response.setHeader("Content-Type", "text/xml");
-        
-        // Set encoding
-        response.setCharacterEncoding("UTF-8");
-
-        // Write actual XML
-        try {
-
-            // Get user directory
-            Directory<String, User> directory = context.getUserDirectory();
-
-            // Get users
-            Set<String> users = directory.getIdentifiers();
-
-            XMLOutputFactory outputFactory = XMLOutputFactory.newInstance();
-            XMLStreamWriter xml = outputFactory.createXMLStreamWriter(response.getWriter());
-
-            // Begin document
-            xml.writeStartDocument();
-            xml.writeStartElement("users");
-
-            // For each entry, write corresponding user element
-            for (String username : users) {
-
-                // Get user
-                User user = directory.get(username);
-
-                // Write user
-                xml.writeEmptyElement("user");
-                xml.writeAttribute("name", user.getUsername());
-
-            }
-
-            // End document
-            xml.writeEndElement();
-            xml.writeEndDocument();
-
-        }
-        catch (XMLStreamException e) {
-            throw new GuacamoleServerException(
-                    "Unable to write configuration list XML.", e);
-        }
-        catch (IOException e) {
-            throw new GuacamoleServerException(
-                    "I/O error writing configuration list XML.", e);
-        }
-
-    }
-
-}
-
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/crud/users/Update.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/crud/users/Update.java
deleted file mode 100644
index 10868b3..0000000
--- a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/crud/users/Update.java
+++ /dev/null
@@ -1,307 +0,0 @@
-package org.glyptodon.guacamole.net.basic.crud.users;
-
-/*
- *  Guacamole - Clientless Remote Desktop
- *  Copyright (C) 2010  Michael Jumper
- *
- *  This program is free software: you can redistribute it and/or modify
- *  it under the terms of the GNU Affero General Public License as published by
- *  the Free Software Foundation, either version 3 of the License, or
- *  (at your option) any later version.
- *
- *  This program is distributed in the hope that it will be useful,
- *  but WITHOUT ANY WARRANTY; without even the implied warranty of
- *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- *  GNU Affero General Public License for more details.
- *
- *  You should have received a copy of the GNU Affero General Public License
- *  along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-import org.glyptodon.guacamole.GuacamoleClientException;
-import org.glyptodon.guacamole.GuacamoleException;
-import org.glyptodon.guacamole.net.auth.Directory;
-import org.glyptodon.guacamole.net.auth.User;
-import org.glyptodon.guacamole.net.auth.UserContext;
-import org.glyptodon.guacamole.net.auth.permission.ConnectionGroupPermission;
-import org.glyptodon.guacamole.net.auth.permission.ConnectionPermission;
-import org.glyptodon.guacamole.net.auth.permission.ObjectPermission;
-import org.glyptodon.guacamole.net.auth.permission.Permission;
-import org.glyptodon.guacamole.net.auth.permission.SystemPermission;
-import org.glyptodon.guacamole.net.auth.permission.UserPermission;
-import org.glyptodon.guacamole.net.basic.AuthenticatingHttpServlet;
-
-/**
- * Simple HttpServlet which handles user update.
- *
- * @author Michael Jumper
- */
-public class Update extends AuthenticatingHttpServlet {
-
-    /**
-     * String given for user creation permission.
-     */
-    private static final String CREATE_USER_PERMISSION = "create-user";
-
-    /**
-     * String given for connection creation permission.
-     */
-    private static final String CREATE_CONNECTION_PERMISSION = "create-connection";
-
-    /**
-     * String given for connection group creation permission.
-     */
-    private static final String CREATE_CONNECTION_GROUP_PERMISSION = "create-connection-group";
-
-    /**
-     * String given for system administration permission.
-     */
-    private static final String ADMIN_PERMISSION = "admin";
-
-    /**
-     * Prefix given before an object identifier for read permission.
-     */
-    private static final String READ_PREFIX   = "read:";
-
-    /**
-     * Prefix given before an object identifier for delete permission.
-     */
-    private static final String DELETE_PREFIX = "delete:";
-
-    /**
-     * Prefix given before an object identifier for update permission.
-     */
-    private static final String UPDATE_PREFIX = "update:";
-
-    /**
-     * Prefix given before an object identifier for administration permission.
-     */
-    private static final String ADMIN_PREFIX  = "admin:";
-
-    /**
-     * Given a permission string, returns the corresponding system permission.
-     *
-     * @param str The permission string to parse.
-     * @return The parsed system permission.
-     * @throws GuacamoleException If the given string could not be parsed.
-     */
-    private Permission parseSystemPermission(String str)
-            throws GuacamoleException {
-
-        // Create user
-        if (str.equals(CREATE_USER_PERMISSION))
-            return new SystemPermission(SystemPermission.Type.CREATE_USER);
-
-        // Create connection
-        if (str.equals(CREATE_CONNECTION_PERMISSION))
-            return new SystemPermission(SystemPermission.Type.CREATE_CONNECTION);
-
-        // Create connection group
-        if (str.equals(CREATE_CONNECTION_GROUP_PERMISSION))
-            return new SystemPermission(SystemPermission.Type.CREATE_CONNECTION_GROUP);
-
-        // Administration
-        if (str.equals(ADMIN_PERMISSION))
-            return new SystemPermission(SystemPermission.Type.ADMINISTER);
-
-        throw new GuacamoleException("Invalid permission string.");
-
-    }
-
-    /**
-     * Given a permission string, returns the corresponding user permission.
-     *
-     * @param str The permission string to parse.
-     * @return The parsed user permission.
-     * @throws GuacamoleException If the given string could not be parsed.
-     */
-    private Permission parseUserPermission(String str)
-            throws GuacamoleException {
-
-        // Read
-        if (str.startsWith(READ_PREFIX))
-            return new UserPermission(ObjectPermission.Type.READ,
-                    str.substring(READ_PREFIX.length()));
-
-        // Update
-        if (str.startsWith(UPDATE_PREFIX))
-            return new UserPermission(ObjectPermission.Type.UPDATE,
-                    str.substring(UPDATE_PREFIX.length()));
-
-        // Delete
-        if (str.startsWith(DELETE_PREFIX))
-            return new UserPermission(ObjectPermission.Type.DELETE,
-                    str.substring(DELETE_PREFIX.length()));
-
-        // Administration
-        if (str.startsWith(ADMIN_PREFIX))
-            return new UserPermission(ObjectPermission.Type.ADMINISTER,
-                    str.substring(ADMIN_PREFIX.length()));
-
-        throw new GuacamoleException("Invalid permission string.");
-
-    }
-
-    /**
-     * Given a permission string, returns the corresponding connection
-     * permission.
-     *
-     * @param str The permission string to parse.
-     * @return The parsed connection permission.
-     * @throws GuacamoleException If the given string could not be parsed.
-     */
-    private Permission parseConnectionPermission(String str)
-            throws GuacamoleException {
-
-        // Read
-        if (str.startsWith(READ_PREFIX))
-            return new ConnectionPermission(ObjectPermission.Type.READ,
-                    str.substring(READ_PREFIX.length()));
-
-        // Update
-        if (str.startsWith(UPDATE_PREFIX))
-            return new ConnectionPermission(ObjectPermission.Type.UPDATE,
-                    str.substring(UPDATE_PREFIX.length()));
-
-        // Delete
-        if (str.startsWith(DELETE_PREFIX))
-            return new ConnectionPermission(ObjectPermission.Type.DELETE,
-                    str.substring(DELETE_PREFIX.length()));
-
-        // Administration
-        if (str.startsWith(ADMIN_PREFIX))
-            return new ConnectionPermission(ObjectPermission.Type.ADMINISTER,
-                    str.substring(ADMIN_PREFIX.length()));
-
-        throw new GuacamoleClientException("Invalid permission string.");
-
-    }
-
-    /**
-     * Given a permission string, returns the corresponding connection group
-     * permission.
-     *
-     * @param str The permission string to parse.
-     * @return The parsed connection group permission.
-     * @throws GuacamoleException If the given string could not be parsed.
-     */
-    private Permission parseConnectionGroupPermission(String str)
-            throws GuacamoleException {
-
-        // Read
-        if (str.startsWith(READ_PREFIX))
-            return new ConnectionGroupPermission(ObjectPermission.Type.READ,
-                    str.substring(READ_PREFIX.length()));
-
-        // Update
-        if (str.startsWith(UPDATE_PREFIX))
-            return new ConnectionGroupPermission(ObjectPermission.Type.UPDATE,
-                    str.substring(UPDATE_PREFIX.length()));
-
-        // Delete
-        if (str.startsWith(DELETE_PREFIX))
-            return new ConnectionGroupPermission(ObjectPermission.Type.DELETE,
-                    str.substring(DELETE_PREFIX.length()));
-
-        // Administration
-        if (str.startsWith(ADMIN_PREFIX))
-            return new ConnectionGroupPermission(ObjectPermission.Type.ADMINISTER,
-                    str.substring(ADMIN_PREFIX.length()));
-
-        throw new GuacamoleClientException("Invalid permission string.");
-
-    }
-
-    @Override
-    protected void authenticatedService(
-            UserContext context,
-            HttpServletRequest request, HttpServletResponse response)
-    throws GuacamoleException {
-
-        // Create user as specified
-        String username = request.getParameter("name");
-        String password = request.getParameter("password");
-
-        // Attempt to get user directory
-        Directory<String, User> directory =
-                context.getUserDirectory();
-
-        // Get user data, setting password if given
-        User user = directory.get(username);
-        user.setUsername(username);
-        if (password != null)
-            user.setPassword(password);
-
-        /*
-         * NEW PERMISSIONS
-         */
-
-        // Set added system permissions
-        String[] add_sys_permission = request.getParameterValues("+sys");
-        if (add_sys_permission != null) {
-            for (String str : add_sys_permission)
-                user.addPermission(parseSystemPermission(str));
-        }
-
-        // Set added user permissions
-        String[] add_user_permission = request.getParameterValues("+user");
-        if (add_user_permission != null) {
-            for (String str : add_user_permission)
-                user.addPermission(parseUserPermission(str));
-        }
-
-        // Set added connection permissions
-        String[] add_connection_permission = request.getParameterValues("+connection");
-        if (add_connection_permission != null) {
-            for (String str : add_connection_permission)
-                user.addPermission(parseConnectionPermission(str));
-        }
-
-        // Set added connection group permissions
-        String[] add_connection_group_permission = request.getParameterValues("+connection-group");
-        if (add_connection_group_permission != null) {
-            for (String str : add_connection_group_permission)
-                user.addPermission(parseConnectionGroupPermission(str));
-        }
-
-        /*
-         * REMOVED PERMISSIONS
-         */
-
-        // Unset removed system permissions
-        String[] remove_sys_permission = request.getParameterValues("-sys");
-        if (remove_sys_permission != null) {
-            for (String str : remove_sys_permission)
-                user.removePermission(parseSystemPermission(str));
-        }
-
-        // Unset removed user permissions
-        String[] remove_user_permission = request.getParameterValues("-user");
-        if (remove_user_permission != null) {
-            for (String str : remove_user_permission)
-                user.removePermission(parseUserPermission(str));
-        }
-
-        // Unset removed connection permissions
-        String[] remove_connection_permission = request.getParameterValues("-connection");
-        if (remove_connection_permission != null) {
-            for (String str : remove_connection_permission)
-                user.removePermission(parseConnectionPermission(str));
-        }
-
-        // Unset removed connection group permissions
-        String[] remove_connection_group_permission = request.getParameterValues("-connection-group");
-        if (remove_connection_group_permission != null) {
-            for (String str : remove_connection_group_permission)
-                user.removePermission(parseConnectionGroupPermission(str));
-        }
-
-        // Update user
-        directory.update(user);
-
-    }
-
-}
-
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/crud/users/package-info.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/crud/users/package-info.java
deleted file mode 100644
index 12fdcd6..0000000
--- a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/crud/users/package-info.java
+++ /dev/null
@@ -1,6 +0,0 @@
-
-/**
- * Servlets dedicated to CRUD operations related to Users.
- */
-package org.glyptodon.guacamole.net.basic.crud.users;
-
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/event/SessionListenerCollection.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/event/SessionListenerCollection.java
deleted file mode 100644
index f354acf..0000000
--- a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/event/SessionListenerCollection.java
+++ /dev/null
@@ -1,132 +0,0 @@
-package org.glyptodon.guacamole.net.basic.event;
-
-/*
- *  Guacamole - Clientless Remote Desktop
- *  Copyright (C) 2010  Michael Jumper
- *
- *  This program is free software: you can redistribute it and/or modify
- *  it under the terms of the GNU Affero General Public License as published by
- *  the Free Software Foundation, either version 3 of the License, or
- *  (at your option) any later version.
- *
- *  This program is distributed in the hope that it will be useful,
- *  but WITHOUT ANY WARRANTY; without even the implied warranty of
- *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- *  GNU Affero General Public License for more details.
- *
- *  You should have received a copy of the GNU Affero General Public License
- *  along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-import java.lang.reflect.InvocationTargetException;
-import java.util.AbstractCollection;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.Iterator;
-import javax.servlet.http.HttpSession;
-import org.glyptodon.guacamole.GuacamoleException;
-import org.glyptodon.guacamole.net.basic.properties.BasicGuacamoleProperties;
-import org.glyptodon.guacamole.properties.GuacamoleProperties;
-
-/**
- * A collection which iterates over instances of all listeners defined in
- * guacamole.properties. For each listener defined in guacamole.properties, a
- * new instance is created and stored in this collection. The contents of this
- * collection is stored within the HttpSession, and will be reused if available.
- * Each listener is instantiated once per session. Listeners are singleton
- * classes within the session, but not globally.
- *
- * @author Michael Jumper
- */
-public class SessionListenerCollection extends AbstractCollection {
-
-    /**
-     * The name of the session attribute which will contain the listener
-     * collection.
-     */
-    private static final String SESSION_ATTRIBUTE = "GUAC_LISTENERS";
-
-    /**
-     * The wrapped collection of listeners, possibly retrieved from the
-     * session.
-     */
-    private Collection listeners;
-
-    /**
-     * Creates a new SessionListenerCollection which stores all listeners
-     * defined in guacamole.properties in the provided session. If listeners
-     * are already stored in the provided session, those listeners are used
-     * instead.
-     *
-     * @param session The HttpSession to store listeners within.
-     * @throws GuacamoleException If an error occurs while instantiating new
-     *                            listeners.
-     */
-    public SessionListenerCollection(HttpSession session) throws GuacamoleException {
-
-        // Pull cached listeners from session
-        listeners = (Collection) session.getAttribute(SESSION_ATTRIBUTE);
-
-        // If no listeners stored, listeners must be loaded first
-        if (listeners == null) {
-
-            // Load listeners from guacamole.properties
-            listeners = new ArrayList();
-            try {
-
-                // Get all listener classes from properties
-                Collection<Class> listenerClasses =
-                        GuacamoleProperties.getProperty(BasicGuacamoleProperties.EVENT_LISTENERS);
-
-                // Add an instance of each class to the list
-                if (listenerClasses != null) {
-                    for (Class listenerClass : listenerClasses) {
-
-                        // Instantiate listener
-                        Object listener = listenerClass.getConstructor().newInstance();
-
-                        // Add listener to collection of listeners
-                        listeners.add(listener);
-
-                    }
-                }
-
-            }
-            catch (InstantiationException e) {
-                throw new GuacamoleException("Listener class is abstract.", e);
-            }
-            catch (IllegalAccessException e) {
-                throw new GuacamoleException("No access to listener constructor.", e);
-            }
-            catch (IllegalArgumentException e) {
-                // This should not happen, given there ARE no arguments
-                throw new GuacamoleException("Illegal arguments to listener constructor.", e);
-            }
-            catch (InvocationTargetException e) {
-                throw new GuacamoleException("Error while instantiating listener.", e);
-            }
-            catch (NoSuchMethodException e) {
-                throw new GuacamoleException("Listener has no default constructor.", e);
-            }
-            catch (SecurityException e) {
-                throw new GuacamoleException("Security restrictions prevent instantiation of listener.", e);
-            }
-
-            // Store listeners for next time
-            session.setAttribute(SESSION_ATTRIBUTE, listeners);
-
-        }
-
-    }
-
-    @Override
-    public Iterator iterator() {
-        return listeners.iterator();
-    }
-
-    @Override
-    public int size() {
-        return listeners.size();
-    }
-
-}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/event/package-info.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/event/package-info.java
deleted file mode 100644
index ec5fc92..0000000
--- a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/event/package-info.java
+++ /dev/null
@@ -1,6 +0,0 @@
-
-/**
- * Classes used by the Guacamole web application to broadcast events.
- */
-package org.glyptodon.guacamole.net.basic.event;
-
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/extension/AuthenticationProviderFacade.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/extension/AuthenticationProviderFacade.java
new file mode 100644
index 0000000..227d430
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/extension/AuthenticationProviderFacade.java
@@ -0,0 +1,203 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.basic.extension;
+
+import java.lang.reflect.InvocationTargetException;
+import java.util.UUID;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.net.auth.AuthenticatedUser;
+import org.glyptodon.guacamole.net.auth.AuthenticationProvider;
+import org.glyptodon.guacamole.net.auth.Credentials;
+import org.glyptodon.guacamole.net.auth.UserContext;
+import org.glyptodon.guacamole.net.auth.credentials.CredentialsInfo;
+import org.glyptodon.guacamole.net.auth.credentials.GuacamoleInvalidCredentialsException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Provides a safe wrapper around an AuthenticationProvider subclass, such that
+ * authentication attempts can cleanly fail, and errors can be properly logged,
+ * even if the AuthenticationProvider cannot be instantiated.
+ *
+ * @author Michael Jumper
+ */
+public class AuthenticationProviderFacade implements AuthenticationProvider {
+
+    /**
+     * Logger for this class.
+     */
+    private Logger logger = LoggerFactory.getLogger(AuthenticationProviderFacade.class);
+
+    /**
+     * The underlying authentication provider, or null if the authentication
+     * provider could not be instantiated.
+     */
+    private final AuthenticationProvider authProvider;
+
+    /**
+     * The identifier to provide for the underlying authentication provider if
+     * the authentication provider could not be loaded.
+     */
+    private final String facadeIdentifier = UUID.randomUUID().toString();
+
+    /**
+     * Creates a new AuthenticationProviderFacade which delegates all function
+     * calls to an instance of the given AuthenticationProvider subclass. If
+     * an instance of the given class cannot be created, creation of this
+     * facade will still succeed, but its use will result in errors being
+     * logged, and all authentication attempts will fail.
+     *
+     * @param authProviderClass
+     *     The AuthenticationProvider subclass to instantiate.
+     */
+    public AuthenticationProviderFacade(Class<? extends AuthenticationProvider> authProviderClass) {
+
+        AuthenticationProvider instance = null;
+        
+        try {
+            // Attempt to instantiate the authentication provider
+            instance = authProviderClass.getConstructor().newInstance();
+        }
+        catch (NoSuchMethodException e) {
+            logger.error("The authentication extension in use is not properly defined. "
+                       + "Please contact the developers of the extension or, if you "
+                       + "are the developer, turn on debug-level logging.");
+            logger.debug("AuthenticationProvider is missing a default constructor.", e);
+        }
+        catch (SecurityException e) {
+            logger.error("The Java security mananager is preventing authentication extensions "
+                       + "from being loaded. Please check the configuration of Java or your "
+                       + "servlet container.");
+            logger.debug("Creation of AuthenticationProvider disallowed by security manager.", e);
+        }
+        catch (InstantiationException e) {
+            logger.error("The authentication extension in use is not properly defined. "
+                       + "Please contact the developers of the extension or, if you "
+                       + "are the developer, turn on debug-level logging.");
+            logger.debug("AuthenticationProvider cannot be instantiated.", e);
+        }
+        catch (IllegalAccessException e) {
+            logger.error("The authentication extension in use is not properly defined. "
+                       + "Please contact the developers of the extension or, if you "
+                       + "are the developer, turn on debug-level logging.");
+            logger.debug("Default constructor of AuthenticationProvider is not public.", e);
+        }
+        catch (IllegalArgumentException e) {
+            logger.error("The authentication extension in use is not properly defined. "
+                       + "Please contact the developers of the extension or, if you "
+                       + "are the developer, turn on debug-level logging.");
+            logger.debug("Default constructor of AuthenticationProvider cannot accept zero arguments.", e);
+        } 
+        catch (InvocationTargetException e) {
+
+            // Obtain causing error - create relatively-informative stub error if cause is unknown
+            Throwable cause = e.getCause();
+            if (cause == null)
+                cause = new GuacamoleException("Error encountered during initialization.");
+            
+            logger.error("Authentication extension failed to start: {}", cause.getMessage());
+            logger.debug("AuthenticationProvider instantiation failed.", e);
+
+        }
+       
+        // Associate instance, if any
+        authProvider = instance;
+
+    }
+
+    @Override
+    public String getIdentifier() {
+
+        // Ignore auth attempts if no auth provider could be loaded
+        if (authProvider == null) {
+            logger.warn("The authentication system could not be loaded. Please check for errors earlier in the logs.");
+            return facadeIdentifier;
+        }
+
+        // Delegate to underlying auth provider
+        return authProvider.getIdentifier();
+
+    }
+
+    @Override
+    public AuthenticatedUser authenticateUser(Credentials credentials)
+            throws GuacamoleException {
+
+        // Ignore auth attempts if no auth provider could be loaded
+        if (authProvider == null) {
+            logger.warn("Authentication attempt denied because the authentication system could not be loaded. Please check for errors earlier in the logs.");
+            throw new GuacamoleInvalidCredentialsException("Permission denied.", CredentialsInfo.USERNAME_PASSWORD);
+        }
+
+        // Delegate to underlying auth provider
+        return authProvider.authenticateUser(credentials);
+
+    }
+
+    @Override
+    public AuthenticatedUser updateAuthenticatedUser(AuthenticatedUser authenticatedUser,
+            Credentials credentials) throws GuacamoleException {
+
+        // Ignore auth attempts if no auth provider could be loaded
+        if (authProvider == null) {
+            logger.warn("Reauthentication attempt denied because the authentication system could not be loaded. Please check for errors earlier in the logs.");
+            throw new GuacamoleInvalidCredentialsException("Permission denied.", CredentialsInfo.USERNAME_PASSWORD);
+        }
+
+        // Delegate to underlying auth provider
+        return authProvider.updateAuthenticatedUser(authenticatedUser, credentials);
+
+    }
+
+    @Override
+    public UserContext getUserContext(AuthenticatedUser authenticatedUser)
+            throws GuacamoleException {
+
+        // Ignore auth attempts if no auth provider could be loaded
+        if (authProvider == null) {
+            logger.warn("User data retrieval attempt denied because the authentication system could not be loaded. Please check for errors earlier in the logs.");
+            return null;
+        }
+
+        // Delegate to underlying auth provider
+        return authProvider.getUserContext(authenticatedUser);
+        
+    }
+
+    @Override
+    public UserContext updateUserContext(UserContext context,
+            AuthenticatedUser authenticatedUser)
+            throws GuacamoleException {
+
+        // Ignore auth attempts if no auth provider could be loaded
+        if (authProvider == null) {
+            logger.warn("User data refresh attempt denied because the authentication system could not be loaded. Please check for errors earlier in the logs.");
+            return null;
+        }
+
+        // Delegate to underlying auth provider
+        return authProvider.updateUserContext(context, authenticatedUser);
+        
+    }
+
+}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/extension/DirectoryClassLoader.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/extension/DirectoryClassLoader.java
new file mode 100644
index 0000000..4a4a8f2
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/extension/DirectoryClassLoader.java
@@ -0,0 +1,154 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.basic.extension;
+
+import java.io.File;
+import java.io.FilenameFilter;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.net.URLClassLoader;
+import java.security.AccessController;
+import java.security.PrivilegedActionException;
+import java.security.PrivilegedExceptionAction;
+import java.util.ArrayList;
+import java.util.Collection;
+import org.glyptodon.guacamole.GuacamoleException;
+
+/**
+ * A ClassLoader implementation which finds classes within .jar files within a
+ * given directory.
+ *
+ * @author Michael Jumper
+ */
+public class DirectoryClassLoader extends URLClassLoader {
+
+    /**
+     * Returns an instance of DirectoryClassLoader configured to load .jar
+     * files from the given directory. Calling this function multiple times
+     * will not affect previously-returned instances of DirectoryClassLoader.
+     *
+     * @param dir
+     *     The directory from which .jar files should be read.
+     *
+     * @return
+     *     A DirectoryClassLoader instance which loads classes from the .jar
+     *     files in the given directory.
+     *
+     * @throws GuacamoleException
+     *     If the given file is not a directory, or the contents of the given
+     *     directory cannot be read.
+     */
+    public static DirectoryClassLoader getInstance(final File dir)
+            throws GuacamoleException {
+
+        try {
+            // Attempt to create singleton classloader which loads classes from
+            // all .jar's in the lib directory defined in guacamole.properties
+            return AccessController.doPrivileged(new PrivilegedExceptionAction<DirectoryClassLoader>() {
+
+                @Override
+                public DirectoryClassLoader run() throws GuacamoleException {
+                    return new DirectoryClassLoader(dir);
+                }
+
+            });
+        }
+
+        catch (PrivilegedActionException e) {
+            throw (GuacamoleException) e.getException();
+        }
+
+    }
+
+    /**
+     * Returns all .jar files within the given directory as an array of URLs.
+     *
+     * @param dir
+     *     The directory to retrieve all .jar files from.
+     *
+     * @return
+     *     An array of the URLs of all .jar files within the given directory.
+     *
+     * @throws GuacamoleException
+     *     If the given file is not a directory, or the contents of the given
+     *     directory cannot be read.
+     */
+    private static URL[] getJarURLs(File dir) throws GuacamoleException {
+
+        // Validate directory is indeed a directory
+        if (!dir.isDirectory())
+            throw new GuacamoleException(dir + " is not a directory.");
+
+        // Get list of URLs for all .jar's in the lib directory
+        Collection<URL> jarURLs = new ArrayList<URL>();
+        File[] files = dir.listFiles(new FilenameFilter() {
+
+            @Override
+            public boolean accept(File dir, String name) {
+
+                // If it ends with .jar, accept the file
+                return name.endsWith(".jar");
+
+            }
+
+        });
+
+        // Verify directory was successfully read
+        if (files == null)
+            throw new GuacamoleException("Unable to read contents of directory " + dir);
+
+        // Add the URL for each .jar to the jar URL list
+        for (File file : files) {
+
+            try {
+                jarURLs.add(file.toURI().toURL());
+            }
+            catch (MalformedURLException e) {
+                throw new GuacamoleException(e);
+            }
+
+        }
+
+        // Set delegate classloader to new URLClassLoader which loads from the .jars found above.
+        URL[] urls = new URL[jarURLs.size()];
+        return jarURLs.toArray(urls);
+
+    }
+
+    /**
+     * Creates a new DirectoryClassLoader configured to load .jar files from
+     * the given directory.
+     *
+     * @param dir
+     *     The directory from which .jar files should be read.
+     *
+     * @throws GuacamoleException
+     *     If the given file is not a directory, or the contents of the given
+     *     directory cannot be read.
+     */
+
+    private DirectoryClassLoader(File dir) throws GuacamoleException {
+        super(getJarURLs(dir), DirectoryClassLoader.class.getClassLoader());
+    }
+
+}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/extension/Extension.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/extension/Extension.java
new file mode 100644
index 0000000..ea99035
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/extension/Extension.java
@@ -0,0 +1,495 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.basic.extension;
+
+import java.io.File;
+import java.io.IOException;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.net.URLClassLoader;
+import java.security.AccessController;
+import java.security.PrivilegedActionException;
+import java.security.PrivilegedExceptionAction;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipException;
+import java.util.zip.ZipFile;
+import org.codehaus.jackson.JsonParseException;
+import org.codehaus.jackson.map.ObjectMapper;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.GuacamoleServerException;
+import org.glyptodon.guacamole.net.auth.AuthenticationProvider;
+import org.glyptodon.guacamole.net.basic.resource.ClassPathResource;
+import org.glyptodon.guacamole.net.basic.resource.Resource;
+
+/**
+ * A Guacamole extension, which may provide custom authentication, static
+ * files, theming/branding, etc.
+ *
+ * @author Michael Jumper
+ */
+public class Extension {
+
+    /**
+     * The Jackson parser for parsing the language JSON files.
+     */
+    private static final ObjectMapper mapper = new ObjectMapper();
+
+    /**
+     * The name of the manifest file that describes the contents of a
+     * Guacamole extension.
+     */
+    private static final String MANIFEST_NAME = "guac-manifest.json";
+
+    /**
+     * The parsed manifest file of this extension, describing the location of
+     * resources within the extension.
+     */
+    private final ExtensionManifest manifest;
+
+    /**
+     * The classloader to use when reading resources from this extension,
+     * including classes and static files.
+     */
+    private final ClassLoader classLoader;
+
+    /**
+     * Map of all JavaScript resources defined within the extension, where each
+     * key is the path to that resource within the extension.
+     */
+    private final Map<String, Resource> javaScriptResources;
+
+    /**
+     * Map of all CSS resources defined within the extension, where each key is
+     * the path to that resource within the extension.
+     */
+    private final Map<String, Resource> cssResources;
+
+    /**
+     * Map of all translation resources defined within the extension, where
+     * each key is the path to that resource within the extension.
+     */
+    private final Map<String, Resource> translationResources;
+
+    /**
+     * Map of all resources defined within the extension which are not already
+     * associated as JavaScript, CSS, or translation resources, where each key
+     * is the path to that resource within the extension.
+     */
+    private final Map<String, Resource> staticResources;
+
+    /**
+     * The collection of all AuthenticationProvider classes defined within the
+     * extension.
+     */
+    private final Collection<Class<AuthenticationProvider>> authenticationProviderClasses;
+
+    /**
+     * The resource for the small favicon for the extension. If provided, this
+     * will replace the default Guacamole icon.
+     */
+    private final Resource smallIcon;
+
+    /**
+     * The resource foe the large favicon for the extension. If provided, this 
+     * will replace the default Guacamole icon.
+     */
+    private final Resource largeIcon;
+
+    /**
+     * Returns a new map of all resources corresponding to the collection of
+     * paths provided. Each resource will be associated with the given
+     * mimetype, and stored in the map using its path as the key.
+     *
+     * @param mimetype
+     *     The mimetype to associate with each resource.
+     *
+     * @param paths
+     *     The paths corresponding to the resources desired.
+     *
+     * @return
+     *     A new, unmodifiable map of resources corresponding to the
+     *     collection of paths provided, where the key of each entry in the
+     *     map is the path for the resource stored in that entry.
+     */
+    private Map<String, Resource> getClassPathResources(String mimetype, Collection<String> paths) {
+
+        // If no paths are provided, just return an empty map 
+        if (paths == null)
+            return Collections.<String, Resource>emptyMap();
+
+        // Add classpath resource for each path provided
+        Map<String, Resource> resources = new HashMap<String, Resource>(paths.size());
+        for (String path : paths)
+            resources.put(path, new ClassPathResource(classLoader, mimetype, path));
+
+        // Callers should not rely on modifying the result
+        return Collections.unmodifiableMap(resources);
+
+    }
+
+    /**
+     * Returns a new map of all resources corresponding to the map of resource
+     * paths provided. Each resource will be associated with the mimetype 
+     * stored in the given map using its path as the key.
+     *
+     * @param resourceTypes 
+     *     A map of all paths to their corresponding mimetypes.
+     *
+     * @return
+     *     A new, unmodifiable map of resources corresponding to the
+     *     collection of paths provided, where the key of each entry in the
+     *     map is the path for the resource stored in that entry.
+     */
+    private Map<String, Resource> getClassPathResources(Map<String, String> resourceTypes) {
+
+        // If no paths are provided, just return an empty map 
+        if (resourceTypes == null)
+            return Collections.<String, Resource>emptyMap();
+
+        // Add classpath resource for each path/mimetype pair provided
+        Map<String, Resource> resources = new HashMap<String, Resource>(resourceTypes.size());
+        for (Map.Entry<String, String> resource : resourceTypes.entrySet()) {
+
+            // Get path and mimetype from entry
+            String path = resource.getKey();
+            String mimetype = resource.getValue();
+
+            // Store as path/resource pair
+            resources.put(path, new ClassPathResource(classLoader, mimetype, path));
+
+        }
+
+        // Callers should not rely on modifying the result
+        return Collections.unmodifiableMap(resources);
+
+    }
+
+    /**
+     * Retrieve the AuthenticationProvider subclass having the given name. If
+     * the class having the given name does not exist or isn't actually a
+     * subclass of AuthenticationProvider, an exception will be thrown.
+     *
+     * @param name
+     *     The name of the AuthenticationProvider class to retrieve.
+     *
+     * @return
+     *     The subclass of AuthenticationProvider having the given name.
+     *
+     * @throws GuacamoleException
+     *     If no such class exists, or if the class with the given name is not
+     *     a subclass of AuthenticationProvider.
+     */
+    @SuppressWarnings("unchecked") // We check this ourselves with isAssignableFrom()
+    private Class<AuthenticationProvider> getAuthenticationProviderClass(String name)
+            throws GuacamoleException {
+
+        try {
+
+            // Get authentication provider class
+            Class<?> authenticationProviderClass = classLoader.loadClass(name);
+
+            // Verify the located class is actually a subclass of AuthenticationProvider
+            if (!AuthenticationProvider.class.isAssignableFrom(authenticationProviderClass))
+                throw new GuacamoleServerException("Authentication providers MUST extend the AuthenticationProvider class.");
+
+            // Return located class
+            return (Class<AuthenticationProvider>) authenticationProviderClass;
+
+        }
+        catch (ClassNotFoundException e) {
+            throw new GuacamoleException("Authentication provider class not found.", e);
+        }
+
+    }
+
+    /**
+     * Returns a new collection of all AuthenticationProvider subclasses having
+     * the given names. If any class does not exist or isn't actually a
+     * subclass of AuthenticationProvider, an exception will be thrown, and
+     * no further AuthenticationProvider classes will be loaded.
+     *
+     * @param names
+     *     The names of the AuthenticationProvider classes to retrieve.
+     *
+     * @return
+     *     A new collection of all AuthenticationProvider subclasses having the
+     *     given names.
+     *
+     * @throws GuacamoleException
+     *     If any given class does not exist, or if any given class is not a
+     *     subclass of AuthenticationProvider.
+     */
+    private Collection<Class<AuthenticationProvider>> getAuthenticationProviderClasses(Collection<String> names)
+            throws GuacamoleException {
+
+        // If no classnames are provided, just return an empty list
+        if (names == null)
+            return Collections.<Class<AuthenticationProvider>>emptyList();
+
+        // Define all auth provider classes
+        Collection<Class<AuthenticationProvider>> classes = new ArrayList<Class<AuthenticationProvider>>(names.size());
+        for (String name : names)
+            classes.add(getAuthenticationProviderClass(name));
+
+        // Callers should not rely on modifying the result
+        return Collections.unmodifiableCollection(classes);
+
+    }
+
+    /**
+     * Loads the given file as an extension, which must be a .jar containing
+     * a guac-manifest.json file describing its contents.
+     *
+     * @param parent
+     *     The classloader to use as the parent for the isolated classloader of
+     *     this extension.
+     *
+     * @param file
+     *     The file to load as an extension.
+     *
+     * @throws GuacamoleException
+     *     If the provided file is not a .jar file, does not contain the
+     *     guac-manifest.json, or if guac-manifest.json is invalid and cannot
+     *     be parsed.
+     */
+    public Extension(final ClassLoader parent, final File file) throws GuacamoleException {
+
+        try {
+
+            // Open extension
+            ZipFile extension = new ZipFile(file);
+
+            try {
+
+                // Retrieve extension manifest
+                ZipEntry manifestEntry = extension.getEntry(MANIFEST_NAME);
+                if (manifestEntry == null)
+                    throw new GuacamoleServerException("Extension " + file.getName() + " is missing " + MANIFEST_NAME);
+
+                // Parse manifest
+                manifest = mapper.readValue(extension.getInputStream(manifestEntry), ExtensionManifest.class);
+                if (manifest == null)
+                    throw new GuacamoleServerException("Contents of " + MANIFEST_NAME + " must be a valid JSON object.");
+
+            }
+
+            // Always close zip file, if possible
+            finally {
+                extension.close();
+            }
+
+            try {
+
+                // Create isolated classloader for this extension
+                classLoader = AccessController.doPrivileged(new PrivilegedExceptionAction<ClassLoader>() {
+
+                    @Override
+                    public ClassLoader run() throws GuacamoleException {
+
+                        try {
+
+                            // Classloader must contain only the extension itself
+                            return new URLClassLoader(new URL[]{file.toURI().toURL()}, parent);
+
+                        }
+                        catch (MalformedURLException e) {
+                            throw new GuacamoleException(e);
+                        }
+
+                    }
+
+                });
+
+            }
+
+            // Rethrow any GuacamoleException
+            catch (PrivilegedActionException e) {
+                throw (GuacamoleException) e.getException();
+            }
+
+        }
+
+        // Abort load if not a valid zip file
+        catch (ZipException e) {
+            throw new GuacamoleServerException("Extension is not a valid zip file: " + file.getName(), e);
+        }
+
+        // Abort if manifest cannot be parsed (invalid JSON)
+        catch (JsonParseException e) {
+            throw new GuacamoleServerException(MANIFEST_NAME + " is not valid JSON: " + file.getName(), e);
+        }
+
+        // Abort if zip file cannot be read at all due to I/O errors
+        catch (IOException e) {
+            throw new GuacamoleServerException("Unable to read extension: " + file.getName(), e);
+        }
+
+        // Define static resources
+        cssResources = getClassPathResources("text/css", manifest.getCSSPaths());
+        javaScriptResources = getClassPathResources("text/javascript", manifest.getJavaScriptPaths());
+        translationResources = getClassPathResources("application/json", manifest.getTranslationPaths());
+        staticResources = getClassPathResources(manifest.getResourceTypes());
+
+        // Define authentication providers
+        authenticationProviderClasses = getAuthenticationProviderClasses(manifest.getAuthProviders());
+
+        // Get small icon resource if provided
+        if (manifest.getSmallIcon() != null)
+            smallIcon = new ClassPathResource(classLoader, "image/png", manifest.getSmallIcon());
+        else
+            smallIcon = null;
+
+        // Get large icon resource if provided
+        if (manifest.getLargeIcon() != null)
+            largeIcon = new ClassPathResource(classLoader, "image/png", manifest.getLargeIcon());
+        else
+            largeIcon = null;
+    }
+
+    /**
+     * Returns the version of the Guacamole web application for which this
+     * extension was built.
+     *
+     * @return
+     *     The version of the Guacamole web application for which this
+     *     extension was built.
+     */
+    public String getGuacamoleVersion() {
+        return manifest.getGuacamoleVersion();
+    }
+
+    /**
+     * Returns the name of this extension, as declared in the extension's
+     * manifest.
+     *
+     * @return
+     *     The name of this extension.
+     */
+    public String getName() {
+        return manifest.getName();
+    }
+
+    /**
+     * Returns the namespace of this extension, as declared in the extension's
+     * manifest.
+     *
+     * @return
+     *     The namespace of this extension.
+     */
+    public String getNamespace() {
+        return manifest.getNamespace();
+    }
+
+    /**
+     * Returns a map of all declared JavaScript resources associated with this
+     * extension, where the key of each entry in the map is the path to that
+     * resource within the extension .jar. JavaScript resources are declared
+     * within the extension manifest.
+     *
+     * @return
+     *     All declared JavaScript resources associated with this extension.
+     */
+    public Map<String, Resource> getJavaScriptResources() {
+        return javaScriptResources;
+    }
+
+    /**
+     * Returns a map of all declared CSS resources associated with this
+     * extension, where the key of each entry in the map is the path to that
+     * resource within the extension .jar. CSS resources are declared within
+     * the extension manifest.
+     *
+     * @return
+     *     All declared CSS resources associated with this extension.
+     */
+    public Map<String, Resource> getCSSResources() {
+        return cssResources;
+    }
+
+    /**
+     * Returns a map of all declared translation resources associated with this
+     * extension, where the key of each entry in the map is the path to that
+     * resource within the extension .jar. Translation resources are declared
+     * within the extension manifest.
+     *
+     * @return
+     *     All declared translation resources associated with this extension.
+     */
+    public Map<String, Resource> getTranslationResources() {
+        return translationResources;
+    }
+
+    /**
+     * Returns a map of all declared resources associated with this extension,
+     * where these resources are not already associated as JavaScript, CSS, or
+     * translation resources. The key of each entry in the map is the path to
+     * that resource within the extension .jar. Static resources are declared
+     * within the extension manifest.
+     *
+     * @return
+     *     All declared static resources associated with this extension.
+     */
+    public Map<String, Resource> getStaticResources() {
+        return staticResources;
+    }
+
+    /**
+     * Returns all declared authentication providers classes associated with
+     * this extension. Authentication providers are declared within the
+     * extension manifest.
+     *
+     * @return
+     *     All declared authentication provider classes with this extension.
+     */
+    public Collection<Class<AuthenticationProvider>> getAuthenticationProviderClasses() {
+        return authenticationProviderClasses;
+    }
+
+    /**
+     * Returns the resource for the small favicon for the extension. If
+     * provided, this will replace the default Guacamole icon.
+     * 
+     * @return 
+     *     The resource for the small favicon.
+     */
+    public Resource getSmallIcon() {
+        return smallIcon;
+    }
+
+    /**
+     * Returns the resource for the large favicon for the extension. If
+     * provided, this will replace the default Guacamole icon.
+     * 
+     * @return 
+     *     The resource for the large favicon.
+     */
+    public Resource getLargeIcon() {
+        return largeIcon;
+    }
+
+}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/extension/ExtensionManifest.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/extension/ExtensionManifest.java
new file mode 100644
index 0000000..6d121b7
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/extension/ExtensionManifest.java
@@ -0,0 +1,371 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.basic.extension;
+
+import java.util.Collection;
+import java.util.Map;
+import org.codehaus.jackson.annotate.JsonProperty;
+
+/**
+ * Java representation of the JSON manifest contained within every Guacamole
+ * extension, identifying an extension and describing its contents.
+ *
+ * @author Michael Jumper
+ */
+public class ExtensionManifest {
+
+    /**
+     * The version of Guacamole for which this extension was built.
+     * Compatibility rules built into the web application will guard against
+     * incompatible extensions being loaded.
+     */
+    private String guacamoleVersion;
+
+    /**
+     * The name of the extension associated with this manifest. The extension
+     * name is human-readable, and used for display purposes only.
+     */
+    private String name;
+
+    /**
+     * The namespace of the extension associated with this manifest. The
+     * extension namespace is required for internal use, and is used wherever
+     * extension-specific files or resources need to be isolated from those of
+     * other extensions.
+     */
+    private String namespace;
+
+    /**
+     * The paths of all JavaScript resources within the .jar of the extension
+     * associated with this manifest.
+     */
+    private Collection<String> javaScriptPaths;
+
+    /**
+     * The paths of all CSS resources within the .jar of the extension
+     * associated with this manifest.
+     */
+    private Collection<String> cssPaths;
+
+    /**
+     * The paths of all translation JSON files within this extension, if any.
+     */
+    private Collection<String> translationPaths;
+
+    /**
+     * The mimetypes of all resources within this extension which are not
+     * already declared as JavaScript, CSS, or translation resources, if any.
+     * The key of each entry is the resource path, while the value is the
+     * corresponding mimetype.
+     */
+    private Map<String, String> resourceTypes;
+
+    /**
+     * The names of all authentication provider classes within this extension,
+     * if any.
+     */
+    private Collection<String> authProviders;
+
+    /**
+     * The path to the small favicon. If provided, this will replace the default
+     * Guacamole icon.
+     */
+    private String smallIcon;
+
+    /**
+     * The path to the large favicon. If provided, this will replace the default
+     * Guacamole icon.
+     */
+    private String largeIcon;
+
+    /**
+     * Returns the version of the Guacamole web application for which the
+     * extension was built, such as "0.9.7".
+     *
+     * @return
+     *     The version of the Guacamole web application for which the extension
+     *     was built.
+     */
+    public String getGuacamoleVersion() {
+        return guacamoleVersion;
+    }
+
+    /**
+     * Sets the version of the Guacamole web application for which the
+     * extension was built, such as "0.9.7".
+     *
+     * @param guacamoleVersion
+     *     The version of the Guacamole web application for which the extension
+     *     was built.
+     */
+    public void setGuacamoleVersion(String guacamoleVersion) {
+        this.guacamoleVersion = guacamoleVersion;
+    }
+
+    /**
+     * Returns the name of the extension associated with this manifest. The
+     * name is human-readable, for display purposes only, and is defined within
+     * the manifest by the "name" property.
+     *
+     * @return
+     *     The name of the extension associated with this manifest.
+     */
+    public String getName() {
+        return name;
+    }
+
+    /**
+     * Sets the name of the extension associated with this manifest. The name
+     * is human-readable, for display purposes only, and is defined within the
+     * manifest by the "name" property.
+     *
+     * @param name
+     *     The name of the extension associated with this manifest.
+     */
+    public void setName(String name) {
+        this.name = name;
+    }
+
+    /**
+     * Returns the namespace of the extension associated with this manifest.
+     * The namespace is required for internal use, and is used wherever
+     * extension-specific files or resources need to be isolated from those of
+     * other extensions. It is defined within the manifest by the "namespace"
+     * property.
+     *
+     * @return
+     *     The namespace of the extension associated with this manifest.
+     */
+    public String getNamespace() {
+        return namespace;
+    }
+
+    /**
+     * Sets the namespace of the extension associated with this manifest. The
+     * namespace is required for internal use, and is used wherever extension-
+     * specific files or resources need to be isolated from those of other
+     * extensions. It is defined within the manifest by the "namespace"
+     * property.
+     *
+     * @param namespace
+     *     The namespace of the extension associated with this manifest.
+     */
+    public void setNamespace(String namespace) {
+        this.namespace = namespace;
+    }
+
+    /**
+     * Returns the paths to all JavaScript resources within the extension.
+     * These paths are defined within the manifest by the "js" property as an
+     * array of strings, where each string is a path relative to the root of
+     * the extension .jar.
+     *
+     * @return
+     *     A collection of paths to all JavaScript resources within the
+     *     extension.
+     */
+    @JsonProperty("js")
+    public Collection<String> getJavaScriptPaths() {
+        return javaScriptPaths;
+    }
+
+    /**
+     * Sets the paths to all JavaScript resources within the extension. These
+     * paths are defined within the manifest by the "js" property as an array
+     * of strings, where each string is a path relative to the root of the
+     * extension .jar.
+     *
+     * @param javaScriptPaths
+     *     A collection of paths to all JavaScript resources within the
+     *     extension.
+     */
+    @JsonProperty("js")
+    public void setJavaScriptPaths(Collection<String> javaScriptPaths) {
+        this.javaScriptPaths = javaScriptPaths;
+    }
+
+    /**
+     * Returns the paths to all CSS resources within the extension. These paths
+     * are defined within the manifest by the "js" property as an array of
+     * strings, where each string is a path relative to the root of the
+     * extension .jar.
+     *
+     * @return
+     *     A collection of paths to all CSS resources within the extension.
+     */
+    @JsonProperty("css")
+    public Collection<String> getCSSPaths() {
+        return cssPaths;
+    }
+
+    /**
+     * Sets the paths to all CSS resources within the extension. These paths
+     * are defined within the manifest by the "js" property as an array of
+     * strings, where each string is a path relative to the root of the
+     * extension .jar.
+     *
+     * @param cssPaths
+     *     A collection of paths to all CSS resources within the extension.
+     */
+    @JsonProperty("css")
+    public void setCSSPaths(Collection<String> cssPaths) {
+        this.cssPaths = cssPaths;
+    }
+
+    /**
+     * Returns the paths to all translation resources within the extension.
+     * These paths are defined within the manifest by the "translations"
+     * property as an array of strings, where each string is a path relative to
+     * the root of the extension .jar.
+     *
+     * @return
+     *     A collection of paths to all translation resources within the
+     *     extension.
+     */
+    @JsonProperty("translations")
+    public Collection<String> getTranslationPaths() {
+        return translationPaths;
+    }
+
+    /**
+     * Sets the paths to all translation resources within the extension. These
+     * paths are defined within the manifest by the "translations" property as
+     * an array of strings, where each string is a path relative to the root of
+     * the extension .jar.
+     *
+     * @param translationPaths
+     *     A collection of paths to all translation resources within the
+     *     extension.
+     */
+    @JsonProperty("translations")
+    public void setTranslationPaths(Collection<String> translationPaths) {
+        this.translationPaths = translationPaths;
+    }
+
+    /**
+     * Returns a map of all resources to their corresponding mimetypes, for all
+     * resources not already declared as JavaScript, CSS, or translation
+     * resources. These paths and corresponding types are defined within the
+     * manifest by the "resources" property as an object, where each property
+     * name is a path relative to the root of the extension .jar, and each
+     * value is a mimetype.
+     *
+     * @return
+     *     A map of all resources within the extension to their corresponding
+     *     mimetypes.
+     */
+    @JsonProperty("resources")
+    public Map<String, String> getResourceTypes() {
+        return resourceTypes;
+    }
+
+    /**
+     * Sets the map of all resources to their corresponding mimetypes, for all
+     * resources not already declared as JavaScript, CSS, or translation
+     * resources. These paths and corresponding types are defined within the
+     * manifest by the "resources" property as an object, where each property
+     * name is a path relative to the root of the extension .jar, and each
+     * value is a mimetype.
+     *
+     * @param resourceTypes
+     *     A map of all resources within the extension to their corresponding
+     *     mimetypes.
+     */
+    @JsonProperty("resources")
+    public void setResourceTypes(Map<String, String> resourceTypes) {
+        this.resourceTypes = resourceTypes;
+    }
+
+    /**
+     * Returns the classnames of all authentication provider classes within the
+     * extension. These classnames are defined within the manifest by the
+     * "authProviders" property as an array of strings, where each string is an
+     * authentication provider classname.
+     *
+     * @return
+     *     A collection of classnames of all authentication providers within
+     *     the extension.
+     */
+    public Collection<String> getAuthProviders() {
+        return authProviders;
+    }
+
+    /**
+     * Sets the classnames of all authentication provider classes within the
+     * extension. These classnames are defined within the manifest by the
+     * "authProviders" property as an array of strings, where each string is an
+     * authentication provider classname.
+     *
+     * @param authProviders
+     *     A collection of classnames of all authentication providers within
+     *     the extension.
+     */
+    public void setAuthProviders(Collection<String> authProviders) {
+        this.authProviders = authProviders;
+    }
+
+    /**
+     * Returns the path to the small favicon, relative to the root of the
+     * extension.
+     *
+     * @return 
+     *     The path to the small favicon.
+     */
+    public String getSmallIcon() {
+        return smallIcon;
+    }
+
+    /**
+     * Sets the path to the small favicon. This will replace the default
+     * Guacamole icon.
+     *
+     * @param smallIcon 
+     *     The path to the small favicon.
+     */
+    public void setSmallIcon(String smallIcon) {
+        this.smallIcon = smallIcon;
+    }
+
+    /**
+     * Returns the path to the large favicon, relative to the root of the
+     * extension.
+     *
+     * @return
+     *     The path to the large favicon.
+     */
+    public String getLargeIcon() {
+        return largeIcon;
+    }
+
+    /**
+     * Sets the path to the large favicon. This will replace the default
+     * Guacamole icon.
+     *
+     * @param largeIcon
+     *     The path to the large favicon.
+     */
+    public void setLargeIcon(String largeIcon) {
+        this.largeIcon = largeIcon;
+    }
+
+}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/extension/ExtensionModule.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/extension/ExtensionModule.java
new file mode 100644
index 0000000..6296ea5
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/extension/ExtensionModule.java
@@ -0,0 +1,440 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.basic.extension;
+
+import com.google.inject.Provides;
+import com.google.inject.servlet.ServletModule;
+import java.io.File;
+import java.io.FileFilter;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import net.sourceforge.guacamole.net.basic.BasicFileAuthenticationProvider;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.GuacamoleServerException;
+import org.glyptodon.guacamole.environment.Environment;
+import org.glyptodon.guacamole.net.auth.AuthenticationProvider;
+import org.glyptodon.guacamole.net.basic.properties.BasicGuacamoleProperties;
+import org.glyptodon.guacamole.net.basic.resource.Resource;
+import org.glyptodon.guacamole.net.basic.resource.ResourceServlet;
+import org.glyptodon.guacamole.net.basic.resource.SequenceResource;
+import org.glyptodon.guacamole.net.basic.resource.WebApplicationResource;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * A Guice Module which loads all extensions within the
+ * GUACAMOLE_HOME/extensions directory, if any.
+ *
+ * @author Michael Jumper
+ */
+public class ExtensionModule extends ServletModule {
+
+    /**
+     * Logger for this class.
+     */
+    private final Logger logger = LoggerFactory.getLogger(ExtensionModule.class);
+
+    /**
+     * The version strings of all Guacamole versions whose extensions are
+     * compatible with this release.
+     */
+    private static final List<String> ALLOWED_GUACAMOLE_VERSIONS =
+        Collections.unmodifiableList(Arrays.asList(
+            "*",
+            "0.9.9"
+        ));
+
+    /**
+     * The name of the directory within GUACAMOLE_HOME containing any .jars
+     * which should be included in the classpath of all extensions.
+     */
+    private static final String LIB_DIRECTORY = "lib";
+
+    /**
+     * The name of the directory within GUACAMOLE_HOME containing all
+     * extensions.
+     */
+    private static final String EXTENSIONS_DIRECTORY = "extensions";
+
+    /**
+     * The string that the filenames of all extensions must end with to be
+     * recognized as extensions.
+     */
+    private static final String EXTENSION_SUFFIX = ".jar";
+
+    /**
+     * The Guacamole server environment.
+     */
+    private final Environment environment;
+
+    /**
+     * All currently-bound authentication providers, if any.
+     */
+    private final List<AuthenticationProvider> boundAuthenticationProviders =
+            new ArrayList<AuthenticationProvider>();
+
+    /**
+     * Service for adding and retrieving language resources.
+     */
+    private final LanguageResourceService languageResourceService;
+    
+    /**
+     * Returns the classloader that should be used as the parent classloader
+     * for all extensions. If the GUACAMOLE_HOME/lib directory exists, this
+     * will be a classloader that loads classes from within the .jar files in
+     * that directory. Lacking the GUACAMOLE_HOME/lib directory, this will
+     * simply be the classloader associated with the ExtensionModule class.
+     *
+     * @return
+     *     The classloader that should be used as the parent classloader for
+     *     all extensions.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while retrieving the classloader.
+     */
+    private ClassLoader getParentClassLoader() throws GuacamoleException {
+
+        // Retrieve lib directory
+        File libDir = new File(environment.getGuacamoleHome(), LIB_DIRECTORY);
+
+        // If lib directory does not exist, use default class loader
+        if (!libDir.isDirectory())
+            return ExtensionModule.class.getClassLoader();
+
+        // Return classloader which loads classes from all .jars within the lib directory
+        return DirectoryClassLoader.getInstance(libDir);
+
+    }
+
+    /**
+     * Creates a module which loads all extensions within the
+     * GUACAMOLE_HOME/extensions directory.
+     *
+     * @param environment
+     *     The environment to use when configuring authentication.
+     */
+    public ExtensionModule(Environment environment) {
+        this.environment = environment;
+        this.languageResourceService = new LanguageResourceService(environment);
+    }
+
+    /**
+     * Reads the value of the now-deprecated "auth-provider" property from
+     * guacamole.properties, returning the corresponding AuthenticationProvider
+     * class. If no authentication provider could be read, or the property is
+     * not present, null is returned.
+     *
+     * As this property is deprecated, this function will also log warning
+     * messages if the property is actually specified.
+     *
+     * @return
+     *     The value of the deprecated "auth-provider" property, or null if the
+     *     property is not present.
+     */
+    @SuppressWarnings("deprecation") // We must continue to use this property until it is truly no longer supported
+    private Class<AuthenticationProvider> getAuthProviderProperty() {
+
+        // Get and bind auth provider instance, if defined via property
+        try {
+
+            // Use "auth-provider" property if present, but warn about deprecation
+            Class<AuthenticationProvider> authenticationProvider = environment.getProperty(BasicGuacamoleProperties.AUTH_PROVIDER);
+            if (authenticationProvider != null)
+                logger.warn("The \"auth-provider\" and \"lib-directory\" properties are now deprecated. Please use the \"extensions\" and \"lib\" directories within GUACAMOLE_HOME instead.");
+
+            return authenticationProvider;
+
+        }
+        catch (GuacamoleException e) {
+            logger.warn("Value of deprecated \"auth-provider\" property within guacamole.properties is not valid: {}", e.getMessage());
+            logger.debug("Error reading authentication provider from guacamole.properties.", e);
+        }
+
+        return null;
+
+    }
+
+    /**
+     * Binds the given AuthenticationProvider class such that any service
+     * requiring access to the AuthenticationProvider can obtain it via
+     * injection, along with any other bound AuthenticationProviders.
+     *
+     * @param authenticationProvider
+     *     The AuthenticationProvider class to bind.
+     */
+    private void bindAuthenticationProvider(Class<? extends AuthenticationProvider> authenticationProvider) {
+
+        // Bind authentication provider
+        logger.debug("[{}] Binding AuthenticationProvider \"{}\".",
+                boundAuthenticationProviders.size(), authenticationProvider.getName());
+        boundAuthenticationProviders.add(new AuthenticationProviderFacade(authenticationProvider));
+
+    }
+
+    /**
+     * Binds each of the the given AuthenticationProvider classes such that any
+     * service requiring access to the AuthenticationProvider can obtain it via
+     * injection.
+     *
+     * @param authProviders
+     *     The AuthenticationProvider classes to bind.
+     */
+    private void bindAuthenticationProviders(Collection<Class<AuthenticationProvider>> authProviders) {
+
+        // Bind each authentication provider within extension
+        for (Class<AuthenticationProvider> authenticationProvider : authProviders)
+            bindAuthenticationProvider(authenticationProvider);
+
+    }
+
+    /**
+     * Returns a list of all currently-bound AuthenticationProvider instances.
+     *
+     * @return
+     *     A List of all currently-bound AuthenticationProvider. The List is
+     *     not modifiable.
+     */
+    @Provides
+    public List<AuthenticationProvider> getAuthenticationProviders() {
+        return Collections.unmodifiableList(boundAuthenticationProviders);
+    }
+
+    /**
+     * Serves each of the given resources as a language resource. Language
+     * resources are served from within the "/translations" directory as JSON
+     * files, where the name of each JSON file is the language key.
+     *
+     * @param resources
+     *     A map of all language resources to serve, where the key of each
+     *     entry in the language key from which the name of the JSON file will
+     *     be derived.
+     */
+    private void serveLanguageResources(Map<String, Resource> resources) {
+
+        // Add all resources to language resource service
+        for (Map.Entry<String, Resource> translationResource : resources.entrySet()) {
+
+            // Get path and resource from path/resource pair
+            String path = translationResource.getKey();
+            Resource resource = translationResource.getValue();
+
+            // Derive key from path
+            String languageKey = languageResourceService.getLanguageKey(path);
+            if (languageKey == null) {
+                logger.warn("Invalid language file name: \"{}\"", path);
+                continue;
+            }
+
+            // Add language resource
+            languageResourceService.addLanguageResource(languageKey, resource);
+
+        }
+
+    }
+
+    /**
+     * Serves each of the given resources under the given prefix. The path of
+     * each resource relative to the prefix is the key of its entry within the
+     * map.
+     *
+     * @param prefix
+     *     The prefix under which each resource should be served.
+     *
+     * @param resources
+     *     A map of all resources to serve, where the key of each entry in the
+     *     map is the desired path of that resource relative to the prefix.
+     */
+    private void serveStaticResources(String prefix, Map<String, Resource> resources) {
+
+        // Add all resources under given prefix
+        for (Map.Entry<String, Resource> staticResource : resources.entrySet()) {
+
+            // Get path and resource from path/resource pair
+            String path = staticResource.getKey();
+            Resource resource = staticResource.getValue();
+
+            // Serve within namespace-derived path
+            serve(prefix + path).with(new ResourceServlet(resource));
+
+        }
+
+    }
+
+    /**
+     * Returns whether the given version of Guacamole is compatible with this
+     * version of Guacamole as far as extensions are concerned.
+     *
+     * @param guacamoleVersion
+     *     The version of Guacamole the extension was built for.
+     *
+     * @return
+     *     true if the given version of Guacamole is compatible with this
+     *     version of Guacamole, false otherwise.
+     */
+    private boolean isCompatible(String guacamoleVersion) {
+        return ALLOWED_GUACAMOLE_VERSIONS.contains(guacamoleVersion);
+    }
+
+    /**
+     * Loads all extensions within the GUACAMOLE_HOME/extensions directory, if
+     * any, adding their static resource to the given resoure collections.
+     *
+     * @param javaScriptResources
+     *     A modifiable collection of static JavaScript resources which may
+     *     receive new JavaScript resources from extensions.
+     *
+     * @param cssResources 
+     *     A modifiable collection of static CSS resources which may receive
+     *     new CSS resources from extensions.
+     */
+    private void loadExtensions(Collection<Resource> javaScriptResources,
+            Collection<Resource> cssResources) {
+
+        // Retrieve and validate extensions directory
+        File extensionsDir = new File(environment.getGuacamoleHome(), EXTENSIONS_DIRECTORY);
+        if (!extensionsDir.isDirectory())
+            return;
+
+        // Retrieve list of all extension files within extensions directory
+        File[] extensionFiles = extensionsDir.listFiles(new FileFilter() {
+
+            @Override
+            public boolean accept(File file) {
+                return file.isFile() && file.getName().endsWith(EXTENSION_SUFFIX);
+            }
+
+        });
+
+        // Verify contents are accessible
+        if (extensionFiles == null) {
+            logger.warn("Although GUACAMOLE_HOME/" + EXTENSIONS_DIRECTORY + " exists, its contents cannot be read.");
+            return;
+        }
+
+        // Sort files lexicographically
+        Arrays.sort(extensionFiles);
+
+        // Load each extension within the extension directory
+        for (File extensionFile : extensionFiles) {
+
+            logger.debug("Loading extension: \"{}\"", extensionFile.getName());
+
+            try {
+
+                // Load extension from file
+                Extension extension = new Extension(getParentClassLoader(), extensionFile);
+
+                // Validate Guacamole version of extension
+                if (!isCompatible(extension.getGuacamoleVersion())) {
+                    logger.debug("Declared Guacamole version \"{}\" of extension \"{}\" is not compatible with this version of Guacamole.",
+                            extension.getGuacamoleVersion(), extensionFile.getName());
+                    throw new GuacamoleServerException("Extension \"" + extension.getName() + "\" is not "
+                            + "compatible with this version of Guacamole.");
+                }
+
+                // Add any JavaScript / CSS resources
+                javaScriptResources.addAll(extension.getJavaScriptResources().values());
+                cssResources.addAll(extension.getCSSResources().values());
+
+                // Attempt to load all authentication providers
+                bindAuthenticationProviders(extension.getAuthenticationProviderClasses());
+
+                // Add any translation resources
+                serveLanguageResources(extension.getTranslationResources());
+
+                // Add all static resources under namespace-derived prefix
+                String staticResourcePrefix = "/app/ext/" + extension.getNamespace() + "/";
+                serveStaticResources(staticResourcePrefix, extension.getStaticResources());
+
+                // Serve up the small favicon if provided
+                if(extension.getSmallIcon() != null)
+                    serve("/images/logo-64.png").with(new ResourceServlet(extension.getSmallIcon()));
+
+                // Serve up the large favicon if provided
+                if(extension.getLargeIcon()!= null)
+                    serve("/images/logo-144.png").with(new ResourceServlet(extension.getLargeIcon()));
+
+                // Log successful loading of extension by name
+                logger.info("Extension \"{}\" loaded.", extension.getName());
+
+            }
+            catch (GuacamoleException e) {
+                logger.error("Extension \"{}\" could not be loaded: {}", extensionFile.getName(), e.getMessage());
+                logger.debug("Unable to load extension.", e);
+            }
+
+        }
+
+    }
+    
+    @Override
+    protected void configureServlets() {
+
+        // Bind language resource service
+        bind(LanguageResourceService.class).toInstance(languageResourceService);
+
+        // Load initial language resources from servlet context
+        languageResourceService.addLanguageResources(getServletContext());
+        
+        // Load authentication provider from guacamole.properties for sake of backwards compatibility
+        Class<AuthenticationProvider> authProviderProperty = getAuthProviderProperty();
+        if (authProviderProperty != null)
+            bindAuthenticationProvider(authProviderProperty);
+
+        // Init JavaScript resources with base guacamole.min.js
+        Collection<Resource> javaScriptResources = new ArrayList<Resource>();
+        javaScriptResources.add(new WebApplicationResource(getServletContext(), "/guacamole.min.js"));
+
+        // Init CSS resources with base guacamole.min.css
+        Collection<Resource> cssResources = new ArrayList<Resource>();
+        cssResources.add(new WebApplicationResource(getServletContext(), "/guacamole.min.css"));
+
+        // Load all extensions
+        loadExtensions(javaScriptResources, cssResources);
+
+        // Always bind basic auth last
+        bindAuthenticationProvider(BasicFileAuthenticationProvider.class);
+
+        // Dynamically generate app.js and app.css from extensions
+        serve("/app.js").with(new ResourceServlet(new SequenceResource(javaScriptResources)));
+        serve("/app.css").with(new ResourceServlet(new SequenceResource(cssResources)));
+
+        // Dynamically serve all language resources
+        for (Map.Entry<String, Resource> entry : languageResourceService.getLanguageResources().entrySet()) {
+
+            // Get language key/resource pair
+            String languageKey = entry.getKey();
+            Resource resource = entry.getValue();
+
+            // Serve resource within /translations
+            serve("/translations/" + languageKey + ".json").with(new ResourceServlet(resource));
+            
+        }
+        
+    }
+
+}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/extension/LanguageResourceService.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/extension/LanguageResourceService.java
new file mode 100644
index 0000000..dece975
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/extension/LanguageResourceService.java
@@ -0,0 +1,442 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.basic.extension;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.Map;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import javax.servlet.ServletContext;
+import org.codehaus.jackson.JsonNode;
+import org.codehaus.jackson.map.ObjectMapper;
+import org.codehaus.jackson.node.JsonNodeFactory;
+import org.codehaus.jackson.node.ObjectNode;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.environment.Environment;
+import org.glyptodon.guacamole.net.basic.properties.BasicGuacamoleProperties;
+import org.glyptodon.guacamole.net.basic.resource.ByteArrayResource;
+import org.glyptodon.guacamole.net.basic.resource.Resource;
+import org.glyptodon.guacamole.net.basic.resource.WebApplicationResource;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Service which provides access to all built-in languages as resources, and
+ * allows other resources to be added or overlaid against existing resources.
+ *
+ * @author Michael Jumper
+ */
+public class LanguageResourceService {
+
+    /**
+     * Logger for this class.
+     */
+    private final Logger logger = LoggerFactory.getLogger(LanguageResourceService.class);
+    
+    /**
+     * The path to the translation folder within the webapp.
+     */
+    private static final String TRANSLATION_PATH = "/translations";
+    
+    /**
+     * The JSON property for the human readable display name.
+     */
+    private static final String LANGUAGE_DISPLAY_NAME_KEY = "NAME";
+    
+    /**
+     * The Jackson parser for parsing the language JSON files.
+     */
+    private static final ObjectMapper mapper = new ObjectMapper();
+    
+    /**
+     * The regular expression to use for parsing the language key from the
+     * filename.
+     */
+    private static final Pattern LANGUAGE_KEY_PATTERN = Pattern.compile(".*/([a-z]+(_[A-Z]+)?)\\.json");
+
+    /**
+     * The set of all language keys which are explicitly listed as allowed
+     * within guacamole.properties, or null if all defined languages should be
+     * allowed.
+     */
+    private final Set<String> allowedLanguages;
+
+    /**
+     * Map of all language resources by language key. Language keys are
+     * language and country code pairs, separated by an underscore, like
+     * "en_US". The country code and underscore SHOULD be omitted in the case
+     * that only one dialect of that language is defined, or in the case of the
+     * most universal or well-supported of all supported dialects of that
+     * language.
+     */
+    private final Map<String, Resource> resources = new HashMap<String, Resource>();
+
+    /**
+     * Creates a new service for tracking and parsing available translations
+     * which reads its configuration from the given environment.
+     *
+     * @param environment
+     *     The environment from which the configuration properties of this
+     *     service should be read.
+     */
+    public LanguageResourceService(Environment environment) {
+
+        Set<String> parsedAllowedLanguages;
+
+        // Parse list of available languages from properties
+        try {
+            parsedAllowedLanguages = environment.getProperty(BasicGuacamoleProperties.ALLOWED_LANGUAGES);
+            logger.debug("Available languages will be restricted to: {}", parsedAllowedLanguages);
+        }
+
+        // Warn of failure to parse
+        catch (GuacamoleException e) {
+            parsedAllowedLanguages = null;
+            logger.error("Unable to parse list of allowed languages: {}", e.getMessage());
+            logger.debug("Error parsing list of allowed languages.", e);
+        }
+
+        this.allowedLanguages = parsedAllowedLanguages;
+
+    }
+
+    /**
+     * Derives a language key from the filename within the given path, if
+     * possible. If the filename is not a valid language key, null is returned.
+     *
+     * @param path
+     *     The path containing the filename to derive the language key from.
+     *
+     * @return
+     *     The derived language key, or null if the filename is not a valid
+     *     language key.
+     */
+    public String getLanguageKey(String path) {
+
+        // Parse language key from filename
+        Matcher languageKeyMatcher = LANGUAGE_KEY_PATTERN.matcher(path);
+        if (!languageKeyMatcher.matches())
+            return null;
+
+        // Return parsed key
+        return languageKeyMatcher.group(1);
+
+    }
+
+    /**
+     * Merges the given JSON objects. Any leaf node in overlay will overwrite
+     * the corresponding path in original.
+     *
+     * @param original
+     *     The original JSON object to which changes should be applied.
+     *
+     * @param overlay
+     *     The JSON object containing changes that should be applied.
+     *
+     * @return
+     *     The newly constructed JSON object that is the result of merging
+     *     original and overlay.
+     */
+    private JsonNode mergeTranslations(JsonNode original, JsonNode overlay) {
+
+        // If we are at a leaf node, the result of merging is simply the overlay
+        if (!overlay.isObject() || original == null)
+            return overlay;
+
+        // Create mutable copy of original
+        ObjectNode newNode = JsonNodeFactory.instance.objectNode();
+        Iterator<String> fieldNames = original.getFieldNames();
+        while (fieldNames.hasNext()) {
+            String fieldName = fieldNames.next();
+            newNode.put(fieldName, original.get(fieldName));
+        }
+
+        // Merge each field
+        fieldNames = overlay.getFieldNames();
+        while (fieldNames.hasNext()) {
+            String fieldName = fieldNames.next();
+            newNode.put(fieldName, mergeTranslations(original.get(fieldName), overlay.get(fieldName)));
+        }
+
+        return newNode;
+
+    }
+
+    /**
+     * Parses the given language resource, returning the resulting JsonNode.
+     * If the resource cannot be read because it does not exist, null is
+     * returned.
+     *
+     * @param resource
+     *     The language resource to parse. Language resources must have the
+     *     mimetype "application/json".
+     *
+     * @return
+     *     A JsonNode representing the root of the parsed JSON tree, or null if
+     *     the given resource does not exist.
+     *
+     * @throws IOException
+     *     If an error occurs while parsing the resource as JSON.
+     */
+    private JsonNode parseLanguageResource(Resource resource) throws IOException {
+
+        // Get resource stream
+        InputStream stream = resource.asStream();
+        if (stream == null)
+            return null;
+
+        // Parse JSON tree
+        try {
+            JsonNode tree = mapper.readTree(stream);
+            return tree;
+        }
+
+        // Ensure stream is always closed
+        finally {
+            stream.close();
+        }
+
+    }
+
+    /**
+     * Returns whether a language having the given key should be allowed to be
+     * loaded. If language availability restrictions are imposed through
+     * guacamole.properties, this may return false in some cases. By default,
+     * this function will always return true. Note that just because a language
+     * key is allowed to be loaded does not imply that the language key is
+     * valid.
+     *
+     * @param languageKey
+     *     The language key of the language to test.
+     *
+     * @return
+     *     true if the given language key should be allowed to be loaded, false
+     *     otherwise.
+     */
+    private boolean isLanguageAllowed(String languageKey) {
+
+        // If no list is provided, all languages are implicitly available
+        if (allowedLanguages == null)
+            return true;
+
+        return allowedLanguages.contains(languageKey);
+
+    }
+
+    /**
+     * Adds or overlays the given language resource, which need not exist in
+     * the ServletContext. If a language resource is already defined for the
+     * given language key, the strings from the given resource will be overlaid
+     * on top of the existing strings, augmenting or overriding the available
+     * strings for that language.
+     *
+     * @param key
+     *     The language key of the resource being added. Language keys are
+     *     pairs consisting of a language code followed by an underscore and
+     *     country code, such as "en_US".
+     *
+     * @param resource
+     *     The language resource to add. This resource must have the mimetype
+     *     "application/json".
+     */
+    public void addLanguageResource(String key, Resource resource) {
+
+        // Skip loading of language if not allowed
+        if (!isLanguageAllowed(key)) {
+            logger.debug("OMITTING language: \"{}\"", key);
+            return;
+        }
+
+        // Merge language resources if already defined
+        Resource existing = resources.get(key);
+        if (existing != null) {
+
+            try {
+
+                // Read the original language resource
+                JsonNode existingTree = parseLanguageResource(existing);
+                if (existingTree == null) {
+                    logger.warn("Base language resource \"{}\" does not exist.", key);
+                    return;
+                }
+
+                // Read new language resource
+                JsonNode resourceTree = parseLanguageResource(resource);
+                if (resourceTree == null) {
+                    logger.warn("Overlay language resource \"{}\" does not exist.", key);
+                    return;
+                }
+
+                // Merge the language resources
+                JsonNode mergedTree = mergeTranslations(existingTree, resourceTree);
+                resources.put(key, new ByteArrayResource("application/json", mapper.writeValueAsBytes(mergedTree)));
+
+                logger.debug("Merged strings with existing language: \"{}\"", key);
+
+            }
+            catch (IOException e) {
+                logger.error("Unable to merge language resource \"{}\": {}", key, e.getMessage());
+                logger.debug("Error merging language resource.", e);
+            }
+
+        }
+
+        // Otherwise, add new language resource
+        else {
+            resources.put(key, resource);
+            logger.debug("Added language: \"{}\"", key);
+        }
+
+    }
+
+    /**
+     * Adds or overlays all languages defined within the /translations
+     * directory of the given ServletContext. If no such language files exist,
+     * nothing is done. If a language is already defined, the strings from the
+     * will be overlaid on top of the existing strings, augmenting or
+     * overriding the available strings for that language. The language key
+     * for each language file is derived from the filename.
+     *
+     * @param context
+     *     The ServletContext from which language files should be loaded.
+     */
+    public void addLanguageResources(ServletContext context) {
+
+        // Get the paths of all the translation files
+        Set<?> resourcePaths = context.getResourcePaths(TRANSLATION_PATH);
+        
+        // If no translation files found, nothing to add
+        if (resourcePaths == null)
+            return;
+        
+        // Iterate through all the found language files and add them to the map
+        for (Object resourcePathObject : resourcePaths) {
+
+            // Each resource path is guaranteed to be a string
+            String resourcePath = (String) resourcePathObject;
+
+            // Parse language key from path
+            String languageKey = getLanguageKey(resourcePath);
+            if (languageKey == null) {
+                logger.warn("Invalid language file name: \"{}\"", resourcePath);
+                continue;
+            }
+
+            // Add/overlay new resource
+            addLanguageResource(
+                languageKey,
+                new WebApplicationResource(context, "application/json", resourcePath)
+            );
+
+        }
+
+    }
+
+    /**
+     * Returns a set of all unique language keys currently associated with
+     * language resources stored in this service. The returned set cannot be
+     * modified.
+     *
+     * @return
+     *     A set of all unique language keys currently associated with this
+     *     service.
+     */
+    public Set<String> getLanguageKeys() {
+        return Collections.unmodifiableSet(resources.keySet());
+    }
+
+    /**
+     * Returns a map of all languages currently associated with this service,
+     * where the key of each map entry is the language key. The returned map
+     * cannot be modified.
+     *
+     * @return
+     *     A map of all languages currently associated with this service.
+     */
+    public Map<String, Resource> getLanguageResources() {
+        return Collections.unmodifiableMap(resources);
+    }
+
+    /**
+     * Returns a mapping of all language keys to their corresponding human-
+     * readable language names. If an error occurs while parsing a language
+     * resource, its key/name pair will simply be omitted. The returned map
+     * cannot be modified.
+     *
+     * @return
+     *     A map of all language keys and their corresponding human-readable
+     *     names.
+     */
+    public Map<String, String> getLanguageNames() {
+
+        Map<String, String> languageNames = new HashMap<String, String>();
+
+        // For each language key/resource pair
+        for (Map.Entry<String, Resource> entry : resources.entrySet()) {
+
+            // Get language key and resource
+            String languageKey = entry.getKey();
+            Resource resource = entry.getValue();
+
+            // Get stream for resource
+            InputStream resourceStream = resource.asStream();
+            if (resourceStream == null) {
+                logger.warn("Expected language resource does not exist: \"{}\".", languageKey);
+                continue;
+            }
+            
+            // Get name node of language
+            try {
+                JsonNode tree = mapper.readTree(resourceStream);
+                JsonNode nameNode = tree.get(LANGUAGE_DISPLAY_NAME_KEY);
+                
+                // Attempt to read language name from node
+                String languageName;
+                if (nameNode == null || (languageName = nameNode.getTextValue()) == null) {
+                    logger.warn("Root-level \"" + LANGUAGE_DISPLAY_NAME_KEY + "\" string missing or invalid in language \"{}\"", languageKey);
+                    languageName = languageKey;
+                }
+                
+                // Add language key/name pair to map
+                languageNames.put(languageKey, languageName);
+
+            }
+
+            // Continue with next language if unable to read
+            catch (IOException e) {
+                logger.warn("Unable to read language resource \"{}\".", languageKey);
+                logger.debug("Error reading language resource.", e);
+            }
+
+        }
+        
+        return Collections.unmodifiableMap(languageNames);
+        
+    }
+    
+}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/extension/package-info.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/extension/package-info.java
new file mode 100644
index 0000000..198e4c4
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/extension/package-info.java
@@ -0,0 +1,27 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Classes which represent and facilitate the loading of extensions to the
+ * Guacamole web application.
+ */
+package org.glyptodon.guacamole.net.basic.extension;
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/log/LogModule.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/log/LogModule.java
new file mode 100644
index 0000000..94589b8
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/log/LogModule.java
@@ -0,0 +1,99 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.basic.log;
+
+import ch.qos.logback.classic.LoggerContext;
+import ch.qos.logback.classic.joran.JoranConfigurator;
+import ch.qos.logback.core.joran.spi.JoranException;
+import ch.qos.logback.core.util.StatusPrinter;
+import com.google.inject.AbstractModule;
+import java.io.File;
+import org.glyptodon.guacamole.environment.Environment;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Initializes the logging subsystem.
+ *
+ * @author Michael Jumper
+ */
+public class LogModule extends AbstractModule {
+
+    /**
+     * Logger for this class.
+     */
+    private final Logger logger = LoggerFactory.getLogger(LogModule.class);
+
+    /**
+     * The Guacamole server environment.
+     */
+    private final Environment environment;
+
+    /**
+     * Creates a new LogModule which uses the given environment to determine
+     * the logging configuration.
+     *
+     * @param environment
+     *     The environment to use when configuring logging.
+     */
+    public LogModule(Environment environment) {
+        this.environment = environment;
+    }
+    
+    @Override
+    protected void configure() {
+
+        // Only load logback configuration if GUACAMOLE_HOME exists
+        File guacamoleHome = environment.getGuacamoleHome();
+        if (!guacamoleHome.isDirectory())
+            return;
+
+        // Check for custom logback.xml
+        File logbackConfiguration = new File(guacamoleHome, "logback.xml");
+        if (!logbackConfiguration.exists())
+            return;
+
+        logger.info("Loading logback configuration from \"{}\".", logbackConfiguration);
+
+        LoggerContext context = (LoggerContext) LoggerFactory.getILoggerFactory();
+        context.reset();
+
+        try {
+
+            // Initialize logback
+            JoranConfigurator configurator = new JoranConfigurator();
+            configurator.setContext(context);
+            configurator.doConfigure(logbackConfiguration);
+
+            // Dump any errors that occur during logback init
+            StatusPrinter.printInCaseOfErrorsOrWarnings(context);
+
+        }
+        catch (JoranException e) {
+            logger.error("Initialization of logback failed: {}", e.getMessage());
+            logger.debug("Unable to load logback configuration..", e);
+        }
+
+    }
+
+}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/package-info.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/package-info.java
index 69ccee9..9da7ed5 100644
--- a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/package-info.java
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/package-info.java
@@ -1,3 +1,24 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
 
 /**
  * Classes specific to the general-purpose web application implemented by
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/properties/AuthenticationProviderProperty.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/properties/AuthenticationProviderProperty.java
index 8711baa..c2dd97d 100644
--- a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/properties/AuthenticationProviderProperty.java
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/properties/AuthenticationProviderProperty.java
@@ -1,39 +1,45 @@
-package org.glyptodon.guacamole.net.basic.properties;
-
 /*
- *  Guacamole - Clientless Remote Desktop
- *  Copyright (C) 2010  Michael Jumper
+ * Copyright (C) 2015 Glyptodon LLC
  *
- *  This program is free software: you can redistribute it and/or modify
- *  it under the terms of the GNU Affero General Public License as published by
- *  the Free Software Foundation, either version 3 of the License, or
- *  (at your option) any later version.
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
  *
- *  This program is distributed in the hope that it will be useful,
- *  but WITHOUT ANY WARRANTY; without even the implied warranty of
- *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- *  GNU Affero General Public License for more details.
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
  *
- *  You should have received a copy of the GNU Affero General Public License
- *  along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
  */
 
-import java.lang.reflect.InvocationTargetException;
+package org.glyptodon.guacamole.net.basic.properties;
+
 import org.glyptodon.guacamole.GuacamoleException;
 import org.glyptodon.guacamole.net.auth.AuthenticationProvider;
-import org.glyptodon.guacamole.net.basic.GuacamoleClassLoader;
 import org.glyptodon.guacamole.properties.GuacamoleProperty;
 
 /**
  * A GuacamoleProperty whose value is the name of a class to use to
- * authenticate users. This class must implement AuthenticationProvider.
+ * authenticate users. This class must implement AuthenticationProvider. Use
+ * of this property type is deprecated in favor of the
+ * GUACAMOLE_HOME/extensions directory.
  *
  * @author Michael Jumper
  */
-public abstract class AuthenticationProviderProperty implements GuacamoleProperty<AuthenticationProvider> {
+ at Deprecated
+public abstract class AuthenticationProviderProperty implements GuacamoleProperty<Class<AuthenticationProvider>> {
 
     @Override
-    public AuthenticationProvider parseValue(String authProviderClassName) throws GuacamoleException {
+    @SuppressWarnings("unchecked") // Explicitly checked within by isAssignableFrom()
+    public Class<AuthenticationProvider> parseValue(String authProviderClassName) throws GuacamoleException {
 
         // If no property provided, return null.
         if (authProviderClassName == null)
@@ -42,35 +48,21 @@ public abstract class AuthenticationProviderProperty implements GuacamolePropert
         // Get auth provider instance
         try {
 
-            Object obj = GuacamoleClassLoader.getInstance().loadClass(authProviderClassName)
-                            .getConstructor().newInstance();
+            // Get authentication provider class
+            Class<?> authProviderClass = org.glyptodon.guacamole.net.basic.GuacamoleClassLoader.getInstance().loadClass(authProviderClassName);
 
-            if (!(obj instanceof AuthenticationProvider))
+            // Verify the located class is actually a subclass of AuthenticationProvider
+            if (!AuthenticationProvider.class.isAssignableFrom(authProviderClass))
                 throw new GuacamoleException("Specified authentication provider class is not a AuthenticationProvider.");
 
-            return (AuthenticationProvider) obj;
+            // Return located class
+            return (Class<AuthenticationProvider>) authProviderClass;
 
         }
         catch (ClassNotFoundException e) {
             throw new GuacamoleException("Authentication provider class not found", e);
         }
-        catch (NoSuchMethodException e) {
-            throw new GuacamoleException("Default constructor for authentication provider not present", e);
-        }
-        catch (SecurityException e) {
-            throw new GuacamoleException("Creation of authentication provider disallowed; check your security settings", e);
-        }
-        catch (InstantiationException e) {
-            throw new GuacamoleException("Unable to instantiate authentication provider", e);
-        }
-        catch (IllegalAccessException e) {
-            throw new GuacamoleException("Unable to access default constructor of authentication provider", e);
-        }
-        catch (InvocationTargetException e) {
-            throw new GuacamoleException("Internal error in constructor of authentication provider", e.getTargetException());
-        }
 
     }
 
 }
-
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/properties/BasicGuacamoleProperties.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/properties/BasicGuacamoleProperties.java
index 91e1d38..2bccfdb 100644
--- a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/properties/BasicGuacamoleProperties.java
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/properties/BasicGuacamoleProperties.java
@@ -1,25 +1,30 @@
-
-package org.glyptodon.guacamole.net.basic.properties;
-
 /*
- *  Guacamole - Clientless Remote Desktop
- *  Copyright (C) 2010  Michael Jumper
+ * Copyright (C) 2013 Glyptodon LLC
  *
- *  This program is free software: you can redistribute it and/or modify
- *  it under the terms of the GNU Affero General Public License as published by
- *  the Free Software Foundation, either version 3 of the License, or
- *  (at your option) any later version.
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
  *
- *  This program is distributed in the hope that it will be useful,
- *  but WITHOUT ANY WARRANTY; without even the implied warranty of
- *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- *  GNU Affero General Public License for more details.
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
  *
- *  You should have received a copy of the GNU Affero General Public License
- *  along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
  */
 
+package org.glyptodon.guacamole.net.basic.properties;
+
 import org.glyptodon.guacamole.properties.FileGuacamoleProperty;
+import org.glyptodon.guacamole.properties.IntegerGuacamoleProperty;
+import org.glyptodon.guacamole.properties.StringGuacamoleProperty;
 
 /**
  * Properties used by the default Guacamole web application.
@@ -35,8 +40,10 @@ public class BasicGuacamoleProperties {
 
     /**
      * The authentication provider to user when retrieving the authorized
-     * configurations of a user.
+     * configurations of a user. This property is currently supported, but
+     * deprecated in favor of the GUACAMOLE_HOME/extensions directory.
      */
+    @Deprecated
     public static final AuthenticationProviderProperty AUTH_PROVIDER = new AuthenticationProviderProperty() {
 
         @Override
@@ -45,8 +52,11 @@ public class BasicGuacamoleProperties {
     };
 
     /**
-     * The directory to search for authentication provider classes.
+     * The directory to search for authentication provider classes. This
+     * property is currently supported, but deprecated in favor of the
+     * GUACAMOLE_HOME/lib directory.
      */
+    @Deprecated
     public static final FileGuacamoleProperty LIB_DIRECTORY = new FileGuacamoleProperty() {
 
         @Override
@@ -55,12 +65,25 @@ public class BasicGuacamoleProperties {
     };
 
     /**
-     * The comma-separated list of all classes to use as event listeners.
+     * The session timeout for the API, in minutes.
+     */
+    public static final IntegerGuacamoleProperty API_SESSION_TIMEOUT = new IntegerGuacamoleProperty() {
+
+        @Override
+        public String getName() { return "api-session-timeout"; }
+
+    };
+
+    /**
+     * Comma-separated list of all allowed languages, where each language is
+     * represented by a language key, such as "en" or "en_US". If specified,
+     * only languages within this list will be listed as available by the REST
+     * service.
      */
-    public static final EventListenersProperty EVENT_LISTENERS = new EventListenersProperty() {
+    public static final StringSetProperty ALLOWED_LANGUAGES = new StringSetProperty() {
 
         @Override
-        public String getName() { return "event-listeners"; }
+        public String getName() { return "allowed-languages"; }
 
     };
 
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/properties/EventListenersProperty.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/properties/EventListenersProperty.java
deleted file mode 100644
index 5a987ee..0000000
--- a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/properties/EventListenersProperty.java
+++ /dev/null
@@ -1,68 +0,0 @@
-package org.glyptodon.guacamole.net.basic.properties;
-
-/*
- *  Guacamole - Clientless Remote Desktop
- *  Copyright (C) 2010  Michael Jumper
- *
- *  This program is free software: you can redistribute it and/or modify
- *  it under the terms of the GNU Affero General Public License as published by
- *  the Free Software Foundation, either version 3 of the License, or
- *  (at your option) any later version.
- *
- *  This program is distributed in the hope that it will be useful,
- *  but WITHOUT ANY WARRANTY; without even the implied warranty of
- *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- *  GNU Affero General Public License for more details.
- *
- *  You should have received a copy of the GNU Affero General Public License
- *  along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-import java.util.ArrayList;
-import java.util.Collection;
-import org.glyptodon.guacamole.GuacamoleException;
-import org.glyptodon.guacamole.net.basic.GuacamoleClassLoader;
-import org.glyptodon.guacamole.properties.GuacamoleProperty;
-
-/**
- * A GuacamoleProperty whose value is a comma-separated list of class names,
- * where each class will be used as a listener for events.
- *
- * @author Michael Jumper
- */
-public abstract class EventListenersProperty implements GuacamoleProperty<Collection<Class>> {
-
-    @Override
-    public Collection<Class> parseValue(String classNameList) throws GuacamoleException {
-
-        // If no property provided, return null.
-        if (classNameList == null)
-            return null;
-
-        // Parse list
-        String[] classNames = classNameList.split(",[\\s]*");
-
-        // Fill list of classes
-        Collection<Class> listeners = new ArrayList<Class>();
-        try {
-
-            // Load all classes in list
-            for (String className : classNames) {
-                Class clazz = GuacamoleClassLoader.getInstance().loadClass(className);
-                listeners.add(clazz);
-            }
-
-        }
-        catch (ClassNotFoundException e) {
-            throw new GuacamoleException("Listener class not found.", e);
-        }
-        catch (SecurityException e) {
-            throw new GuacamoleException("Security settings prevent loading of listener class.", e);
-        }
-
-        return listeners;
-
-    }
-
-}
-
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/properties/StringSetProperty.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/properties/StringSetProperty.java
new file mode 100644
index 0000000..037427f
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/properties/StringSetProperty.java
@@ -0,0 +1,66 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.basic.properties;
+
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.regex.Pattern;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.properties.GuacamoleProperty;
+
+/**
+ * A GuacamoleProperty whose value is a Set of unique Strings. The string value
+ * parsed to produce this set is a comma-delimited list. Duplicate values are
+ * ignored, as is any whitespace following delimiters. To maintain
+ * compatibility with the behavior of Java properties in general, only
+ * whitespace at the beginning of each value is ignored; trailing whitespace
+ * becomes part of the value.
+ *
+ * @author Michael Jumper
+ */
+public abstract class StringSetProperty implements GuacamoleProperty<Set<String>> {
+
+    /**
+     * A pattern which matches against the delimiters between values. This is
+     * currently simply a comma and any following whitespace. Parts of the
+     * input string which match this pattern will not be included in the parsed
+     * result.
+     */
+    private static final Pattern DELIMITER_PATTERN = Pattern.compile(",\\s*");
+
+    @Override
+    public Set<String> parseValue(String values) throws GuacamoleException {
+
+        // If no property provided, return null.
+        if (values == null)
+            return null;
+
+        // Split string into a set of individual values
+        List<String> valueList = Arrays.asList(DELIMITER_PATTERN.split(values));
+        return new HashSet<String>(valueList);
+
+    }
+
+}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/properties/package-info.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/properties/package-info.java
index 7324280..9302914 100644
--- a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/properties/package-info.java
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/properties/package-info.java
@@ -1,3 +1,24 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
 
 /**
  * Classes related to the properties which the Guacamole web application
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/resource/AbstractResource.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/resource/AbstractResource.java
new file mode 100644
index 0000000..0e0e9f1
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/resource/AbstractResource.java
@@ -0,0 +1,82 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.basic.resource;
+
+/**
+ * Base abstract resource implementation which provides an associated mimetype,
+ * and modification time. Classes which extend AbstractResource must provide
+ * their own InputStream, however.
+ *
+ * @author Michael Jumper
+ */
+public abstract class AbstractResource implements Resource {
+
+    /**
+     * The mimetype of this resource.
+     */
+    private final String mimetype;
+
+    /**
+     * The time this resource was last modified, in milliseconds since midnight
+     * of January 1, 1970 UTC.
+     */
+    private final long lastModified;
+
+    /**
+     * Initializes this AbstractResource with the given mimetype and
+     * modification time.
+     *
+     * @param mimetype
+     *     The mimetype of this resource.
+     *
+     * @param lastModified
+     *     The time this resource was last modified, in milliseconds since
+     *     midnight of January 1, 1970 UTC.
+     */
+    public AbstractResource(String mimetype, long lastModified) {
+        this.mimetype = mimetype;
+        this.lastModified = lastModified;
+    }
+
+    /**
+     * Initializes this AbstractResource with the given mimetype. The
+     * modification time of the resource is set to the current system time.
+     *
+     * @param mimetype
+     *     The mimetype of this resource.
+     */
+    public AbstractResource(String mimetype) {
+        this(mimetype, System.currentTimeMillis());
+    }
+
+    @Override
+    public long getLastModified() {
+        return lastModified;
+    }
+
+    @Override
+    public String getMimeType() {
+        return mimetype;
+    }
+
+}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/resource/ByteArrayResource.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/resource/ByteArrayResource.java
new file mode 100644
index 0000000..86bfb56
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/resource/ByteArrayResource.java
@@ -0,0 +1,62 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.basic.resource;
+
+import java.io.ByteArrayInputStream;
+import java.io.InputStream;
+
+/**
+ * A resource which contains a defined byte array.
+ *
+ * @author Michael Jumper
+ */
+public class ByteArrayResource extends AbstractResource {
+
+    /**
+     * The bytes contained by this resource.
+     */
+    private final byte[] bytes;
+
+    /**
+     * Creates a new ByteArrayResource which provides access to the given byte
+     * array. Changes to the given byte array will affect this resource even
+     * after the resource is created. Changing the byte array while an input
+     * stream from this resource is in use has undefined behavior.
+     *
+     * @param mimetype
+     *     The mimetype of the resource.
+     *
+     * @param bytes
+     *     The bytes that this resource should contain.
+     */
+    public ByteArrayResource(String mimetype, byte[] bytes) {
+        super(mimetype);
+        this.bytes = bytes;
+    }
+
+    @Override
+    public InputStream asStream() {
+        return new ByteArrayInputStream(bytes);
+    }
+
+}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/resource/ClassPathResource.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/resource/ClassPathResource.java
new file mode 100644
index 0000000..1f75378
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/resource/ClassPathResource.java
@@ -0,0 +1,85 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.basic.resource;
+
+import java.io.InputStream;
+
+/**
+ * A resource which is located within the classpath of an arbitrary
+ * ClassLoader.
+ *
+ * @author Michael Jumper
+ */
+public class ClassPathResource extends AbstractResource {
+
+    /**
+     * The classloader to use when reading this resource.
+     */
+    private final ClassLoader classLoader;
+
+    /**
+     * The path of this resource relative to the classloader.
+     */
+    private final String path;
+
+    /**
+     * Creates a new ClassPathResource which uses the given ClassLoader to
+     * read the resource having the given path.
+     *
+     * @param classLoader
+     *     The ClassLoader to use when reading the resource.
+     *
+     * @param mimetype
+     *     The mimetype of the resource.
+     *
+     * @param path
+     *     The path of the resource relative to the given ClassLoader.
+     */
+    public ClassPathResource(ClassLoader classLoader, String mimetype, String path) {
+        super(mimetype);
+        this.classLoader = classLoader;
+        this.path = path;
+    }
+
+    /**
+     * Creates a new ClassPathResource which uses the ClassLoader associated
+     * with the ClassPathResource class to read the resource having the given
+     * path.
+     *
+     * @param mimetype
+     *     The mimetype of the resource.
+     *
+     * @param path
+     *     The path of the resource relative to the ClassLoader associated
+     *     with the ClassPathResource class.
+     */
+    public ClassPathResource(String mimetype, String path) {
+        this(ClassPathResource.class.getClassLoader(), mimetype, path);
+    }
+
+    @Override
+    public InputStream asStream() {
+        return classLoader.getResourceAsStream(path);
+    }
+
+}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/resource/Resource.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/resource/Resource.java
new file mode 100644
index 0000000..2a50b75
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/resource/Resource.java
@@ -0,0 +1,67 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.basic.resource;
+
+import java.io.InputStream;
+
+/**
+ * An arbitrary resource that can be served to a user via HTTP. Resources are
+ * anonymous but have a defined mimetype and corresponding input stream.
+ *
+ * @author Michael Jumper
+ */
+public interface Resource {
+
+    /**
+     * Returns the mimetype of this resource. This function MUST always return
+     * a value. If the type is unknown, return "application/octet-stream".
+     *
+     * @return
+     *     The mimetype of this resource.
+     */
+    String getMimeType();
+
+    /**
+     * Returns the time the resource was last modified in milliseconds since
+     * midnight of January 1, 1970 UTC.
+     *
+     * @return
+     *      The time the resource was last modified, in milliseconds.
+     */
+    long getLastModified();
+
+    /**
+     * Returns an InputStream which reads the contents of this resource,
+     * starting with the first byte. Reading from the returned InputStream will
+     * not affect reads from other InputStreams returned by other calls to
+     * asStream(). The returned InputStream must be manually closed when no
+     * longer needed. If the resource is unexpectedly unavailable, this will
+     * return null.
+     *
+     * @return
+     *     An InputStream which reads the contents of this resource, starting
+     *     with the first byte, or null if the resource is unavailable.
+     */
+    InputStream asStream();
+
+}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/resource/ResourceServlet.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/resource/ResourceServlet.java
new file mode 100644
index 0000000..494bebe
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/resource/ResourceServlet.java
@@ -0,0 +1,126 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.basic.resource;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Servlet which serves a given resource for all HTTP GET requests. The HEAD
+ * method is correctly supported, and HTTP 304 ("Not Modified") responses will
+ * be properly returned for GET requests depending on the last time the
+ * resource was modified.
+ *
+ * @author Michael Jumper
+ */
+public class ResourceServlet extends HttpServlet {
+
+    /**
+     * Logger for this class.
+     */
+    private static final Logger logger = LoggerFactory.getLogger(ResourceServlet.class);
+
+    /**
+     * The size of the buffer to use when transferring data from the input
+     * stream of a resource to the output stream of a request.
+     */
+    private static final int BUFFER_SIZE = 10240;
+
+    /**
+     * The resource to serve for every GET request.
+     */
+    private final Resource resource;
+
+    /**
+     * Creates a new ResourceServlet which serves the given Resource for all
+     * HTTP GET requests.
+     *
+     * @param resource
+     *     The Resource to serve.
+     */
+    public ResourceServlet(Resource resource) {
+        this.resource = resource;
+    }
+
+    @Override
+    protected void doHead(HttpServletRequest request, HttpServletResponse response)
+            throws ServletException, IOException {
+
+        // Set last modified and content type headers
+        response.addDateHeader("Last-Modified", resource.getLastModified());
+        response.setContentType(resource.getMimeType());
+
+    }
+
+    @Override
+    protected void doGet(HttpServletRequest request, HttpServletResponse response)
+            throws ServletException, IOException {
+
+        // Get input stream from resource
+        InputStream input = resource.asStream();
+
+        // If resource does not exist, return not found
+        if (input == null) {
+            logger.debug("Resource does not exist: \"{}\"", request.getServletPath());
+            response.setStatus(HttpServletResponse.SC_NOT_FOUND);
+            return;
+        }
+
+        try {
+
+            // Write headers
+            doHead(request, response);
+
+            // If not modified since "If-Modified-Since" header, return not modified
+            long ifModifiedSince = request.getDateHeader("If-Modified-Since");
+            if (resource.getLastModified() - ifModifiedSince < 1000) {
+                logger.debug("Resource not modified: \"{}\"", request.getServletPath());
+                response.setStatus(HttpServletResponse.SC_NOT_MODIFIED);
+                return;
+            }
+
+            int length;
+            byte[] buffer = new byte[BUFFER_SIZE];
+
+            // Write resource to response body
+            OutputStream output = response.getOutputStream();
+            while ((length = input.read(buffer)) != -1)
+                output.write(buffer, 0, length);
+
+        }
+
+        // Ensure input stream is always closed
+        finally {
+            input.close();
+        }
+
+    }
+
+}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/resource/SequenceResource.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/resource/SequenceResource.java
new file mode 100644
index 0000000..4f42fe7
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/resource/SequenceResource.java
@@ -0,0 +1,153 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.basic.resource;
+
+import java.io.InputStream;
+import java.io.SequenceInputStream;
+import java.util.Arrays;
+import java.util.Enumeration;
+import java.util.Iterator;
+
+/**
+ * A resource which is the logical concatenation of other resources.
+ *
+ * @author Michael Jumper
+ */
+public class SequenceResource extends AbstractResource {
+
+    /**
+     * The resources to be concatenated.
+     */
+    private final Iterable<Resource> resources;
+
+    /**
+     * Returns the mimetype of the first resource in the given Iterable, or
+     * "application/octet-stream" if no resources are provided.
+     *
+     * @param resources
+     *     The resources from which the mimetype should be retrieved.
+     *
+     * @return
+     *     The mimetype of the first resource, or "application/octet-stream"
+     *     if no resources were provided.
+     */
+    private static String getMimeType(Iterable<Resource> resources) {
+
+        // If no resources, just assume application/octet-stream
+        Iterator<Resource> resourceIterator = resources.iterator();
+        if (!resourceIterator.hasNext())
+            return "application/octet-stream";
+
+        // Return mimetype of first resource
+        return resourceIterator.next().getMimeType();
+
+    }
+
+    /**
+     * Creates a new SequenceResource as the logical concatenation of the
+     * given resources. Each resource is concatenated in iteration order as
+     * needed when reading from the input stream of the SequenceResource.
+     *
+     * @param mimetype
+     *     The mimetype of the resource.
+     *
+     * @param resources
+     *     The resources to concatenate within the InputStream of this
+     *     SequenceResource.
+     */
+    public SequenceResource(String mimetype, Iterable<Resource> resources) {
+        super(mimetype);
+        this.resources = resources;
+    }
+
+    /**
+     * Creates a new SequenceResource as the logical concatenation of the
+     * given resources. Each resource is concatenated in iteration order as
+     * needed when reading from the input stream of the SequenceResource. The
+     * mimetype of the resulting concatenation is derived from the first
+     * resource.
+     *
+     * @param resources
+     *     The resources to concatenate within the InputStream of this
+     *     SequenceResource.
+     */
+    public SequenceResource(Iterable<Resource> resources) {
+        super(getMimeType(resources));
+        this.resources = resources;
+    }
+
+    /**
+     * Creates a new SequenceResource as the logical concatenation of the
+     * given resources. Each resource is concatenated in iteration order as
+     * needed when reading from the input stream of the SequenceResource.
+     *
+     * @param mimetype
+     *     The mimetype of the resource.
+     *
+     * @param resources
+     *     The resources to concatenate within the InputStream of this
+     *     SequenceResource.
+     */
+    public SequenceResource(String mimetype, Resource... resources) {
+        this(mimetype, Arrays.asList(resources));
+    }
+
+    /**
+     * Creates a new SequenceResource as the logical concatenation of the
+     * given resources. Each resource is concatenated in iteration order as
+     * needed when reading from the input stream of the SequenceResource. The
+     * mimetype of the resulting concatenation is derived from the first
+     * resource.
+     *
+     * @param resources
+     *     The resources to concatenate within the InputStream of this
+     *     SequenceResource.
+     */
+    public SequenceResource(Resource... resources) {
+        this(Arrays.asList(resources));
+    }
+
+    @Override
+    public InputStream asStream() {
+        return new SequenceInputStream(new Enumeration<InputStream>() {
+
+            /**
+             * Iterator over all resources associated with this
+             * SequenceResource.
+             */
+            private final Iterator<Resource> resourceIterator = resources.iterator();
+
+            @Override
+            public boolean hasMoreElements() {
+                return resourceIterator.hasNext();
+            }
+
+            @Override
+            public InputStream nextElement() {
+                return resourceIterator.next().asStream();
+            }
+
+        });
+    }
+
+}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/resource/WebApplicationResource.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/resource/WebApplicationResource.java
new file mode 100644
index 0000000..ad99181
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/resource/WebApplicationResource.java
@@ -0,0 +1,116 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.basic.resource;
+
+import java.io.InputStream;
+import javax.servlet.ServletContext;
+
+/**
+ * A resource which is located within the classpath associated with another
+ * class.
+ *
+ * @author Michael Jumper
+ */
+public class WebApplicationResource extends AbstractResource {
+
+    /**
+     * The servlet context to use when reading the resource and, if necessary,
+     * when determining the mimetype of the resource.
+     */
+    private final ServletContext context;
+
+    /**
+     * The path of this resource relative to the ServletContext.
+     */
+    private final String path;
+
+    /**
+     * Derives a mimetype from the filename within the given path using the
+     * given ServletContext, if possible.
+     *
+     * @param context
+     *     The ServletContext to use to derive the mimetype.
+     *
+     * @param path
+     *     The path to derive the mimetype from.
+     *
+     * @return
+     *     An appropriate mimetype based on the name of the file in the path,
+     *     or "application/octet-stream" if no mimetype could be determined.
+     */
+    private static String getMimeType(ServletContext context, String path) {
+
+        // If mimetype is known, use defined mimetype
+        String mimetype = context.getMimeType(path);
+        if (mimetype != null)
+            return mimetype;
+
+        // Otherwise, default to application/octet-stream
+        return "application/octet-stream";
+
+    }
+
+    /**
+     * Creates a new WebApplicationResource which serves the resource at the
+     * given path relative to the given ServletContext. Rather than deriving
+     * the mimetype of the resource from the filename within the path, the
+     * mimetype given is used.
+     *
+     * @param context
+     *     The ServletContext to use when reading the resource.
+     *
+     * @param mimetype
+     *     The mimetype of the resource.
+     *
+     * @param path
+     *     The path of the resource relative to the given ServletContext.
+     */
+    public WebApplicationResource(ServletContext context, String mimetype, String path) {
+        super(mimetype);
+        this.context = context;
+        this.path = path;
+    }
+
+    /**
+     * Creates a new WebApplicationResource which serves the resource at the
+     * given path relative to the given ServletContext. The mimetype of the
+     * resource is automatically determined based on the filename within the
+     * path.
+     *
+     * @param context
+     *     The ServletContext to use when reading the resource and deriving the
+     *     mimetype.
+     *
+     * @param path
+     *     The path of the resource relative to the given ServletContext.
+     */
+    public WebApplicationResource(ServletContext context, String path) {
+        this(context, getMimeType(context, path), path);
+    }
+
+    @Override
+    public InputStream asStream() {
+        return context.getResourceAsStream(path);
+    }
+
+}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/resource/package-info.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/resource/package-info.java
new file mode 100644
index 0000000..ff6b304
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/resource/package-info.java
@@ -0,0 +1,28 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Classes which describe and provide access to arbitrary resources, such as
+ * the contents of the classpath of a classloader, or files within the web
+ * application itself.
+ */
+package org.glyptodon.guacamole.net.basic.resource;
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/APIError.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/APIError.java
new file mode 100644
index 0000000..1f8ed26
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/APIError.java
@@ -0,0 +1,183 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.basic.rest;
+
+import java.util.Collection;
+import javax.ws.rs.core.Response;
+import org.glyptodon.guacamole.form.Field;
+
+/**
+ * Describes an error that occurred within a REST endpoint.
+ *
+ * @author James Muehlner
+ * @author Michael Jumper
+ */
+public class APIError {
+
+    /**
+     * The error message.
+     */
+    private final String message;
+
+    /**
+     * All expected request parameters, if any, as a collection of fields.
+     */
+    private final Collection<Field> expected;
+
+    /**
+     * The type of error that occurred.
+     */
+    private final Type type;
+
+    /**
+     * All possible types of REST API errors.
+     */
+    public enum Type {
+
+        /**
+         * The requested operation could not be performed because the request
+         * itself was malformed.
+         */
+        BAD_REQUEST(Response.Status.BAD_REQUEST),
+
+        /**
+         * The credentials provided were invalid.
+         */
+        INVALID_CREDENTIALS(Response.Status.FORBIDDEN),
+
+        /**
+         * The credentials provided were not necessarily invalid, but were not
+         * sufficient to determine validity.
+         */
+        INSUFFICIENT_CREDENTIALS(Response.Status.FORBIDDEN),
+
+        /**
+         * An internal server error has occurred.
+         */
+        INTERNAL_ERROR(Response.Status.INTERNAL_SERVER_ERROR),
+
+        /**
+         * An object related to the request does not exist.
+         */
+        NOT_FOUND(Response.Status.NOT_FOUND),
+
+        /**
+         * Permission was denied to perform the requested operation.
+         */
+        PERMISSION_DENIED(Response.Status.FORBIDDEN);
+
+        /**
+         * The HTTP status associated with this error type.
+         */
+        private final Response.Status status;
+
+        /**
+         * Defines a new error type associated with the given HTTP status.
+         *
+         * @param status
+         *     The HTTP status to associate with the error type.
+         */
+        Type(Response.Status status) {
+            this.status = status;
+        }
+
+        /**
+         * Returns the HTTP status associated with this error type.
+         *
+         * @return
+         *     The HTTP status associated with this error type.
+         */
+        public Response.Status getStatus() {
+            return status;
+        }
+
+    }
+
+    /**
+     * Create a new APIError with the specified error message.
+     *
+     * @param type
+     *     The type of error that occurred.
+     *
+     * @param message
+     *     The error message.
+     */
+    public APIError(Type type, String message) {
+        this.type     = type;
+        this.message  = message;
+        this.expected = null;
+    }
+
+    /**
+     * Create a new APIError with the specified error message and parameter
+     * information.
+     *
+     * @param type
+     *     The type of error that occurred.
+     *
+     * @param message
+     *     The error message.
+     *
+     * @param expected
+     *     All parameters expected in the original request, or now required as
+     *     a result of the original request, as a collection of fields.
+     */
+    public APIError(Type type, String message, Collection<Field> expected) {
+        this.type     = type;
+        this.message  = message;
+        this.expected = expected;
+    }
+
+    /**
+     * Returns the type of error that occurred.
+     *
+     * @return
+     *     The type of error that occurred.
+     */
+    public Type getType() {
+        return type;
+    }
+
+    /**
+     * Returns a collection of all required parameters, where each parameter is
+     * represented by a field.
+     *
+     * @return
+     *     A collection of all required parameters.
+     */
+    public Collection<Field> getExpected() {
+        return expected;
+    }
+
+    /**
+     * Returns a human-readable error message describing the error that
+     * occurred.
+     *
+     * @return
+     *     A human-readable error message.
+     */
+    public String getMessage() {
+        return message;
+    }
+
+}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/APIException.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/APIException.java
new file mode 100644
index 0000000..4fff36e
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/APIException.java
@@ -0,0 +1,85 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.basic.rest;
+
+import java.util.Collection;
+import javax.ws.rs.WebApplicationException;
+import javax.ws.rs.core.Response;
+import org.glyptodon.guacamole.form.Field;
+
+/**
+ * An exception that will result in the given error error information being
+ * returned from the API layer. All error messages have the same format which
+ * is defined by APIError.
+ *
+ * @author James Muehlner
+ * @author Michael Jumper
+ */
+public class APIException extends WebApplicationException {
+
+    /**
+     * Construct a new APIException with the given error. All information
+     * associated with this new exception will be extracted from the given
+     * APIError.
+     *
+     * @param error
+     *     The error that occurred.
+     */
+    public APIException(APIError error) {
+        super(Response.status(error.getType().getStatus()).entity(error).build());
+    }
+
+    /**
+     * Creates a new APIException with the given type and message. The
+     * corresponding APIError will be created from the provided information.
+     *
+     * @param type
+     *     The type of error that occurred.
+     *
+     * @param message
+     *     A human-readable message describing the error.
+     */
+    public APIException(APIError.Type type, String message) {
+        this(new APIError(type, message));
+    }
+
+    /**
+     * Creates a new APIException with the given type, message, and parameter
+     * information. The corresponding APIError will be created from the
+     * provided information.
+     *
+     * @param type
+     *     The type of error that occurred.
+     *
+     * @param message
+     *     A human-readable message describing the error.
+     *
+     * @param expected
+     *     All parameters expected in the original request, or now required as
+     *     a result of the original request, as a collection of fields.
+     */
+    public APIException(APIError.Type type, String message, Collection<Field> expected) {
+        this(new APIError(type, message, expected));
+    }
+
+}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/APIPatch.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/APIPatch.java
new file mode 100644
index 0000000..c5c2dec
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/APIPatch.java
@@ -0,0 +1,104 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.basic.rest;
+
+/**
+ * An object for representing the body of a HTTP PATCH method.
+ * See https://tools.ietf.org/html/rfc6902
+ * 
+ * @author James Muehlner
+ * @param <T> The type of object being patched.
+ */
+public class APIPatch<T> {
+    
+    /**
+     * The possible operations for a PATCH request.
+     */
+    public enum Operation {
+        add, remove, test, copy, replace, move
+    }
+    
+    /**
+     * The operation to perform for this patch.
+     */
+    private Operation op;
+    
+    /**
+     * The value for this patch.
+     */
+    private T value;
+    
+    /**
+     * The path for this patch.
+     */
+    private String path;
+
+    /**
+     * Returns the operation for this patch.
+     * @return the operation for this patch. 
+     */
+    public Operation getOp() {
+        return op;
+    }
+
+    /**
+     * Set the operation for this patch.
+     * @param op The operation for this patch.
+     */
+    public void setOp(Operation op) {
+        this.op = op;
+    }
+
+    /**
+     * Returns the value of this patch.
+     * @return The value of this patch.
+     */
+    public T getValue() {
+        return value;
+    }
+
+    /**
+     * Sets the value of this patch.
+     * @param value The value of this patch.
+     */
+    public void setValue(T value) {
+        this.value = value;
+    }
+
+    /**
+     * Returns the path for this patch.
+     * @return The path for this patch.
+     */
+    public String getPath() {
+        return path;
+    }
+
+    /**
+     * Set the path for this patch.
+     * @param path The path for this patch.
+     */
+    public void setPath(String path) {
+        this.path = path;
+    }
+
+}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/APIRequest.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/APIRequest.java
new file mode 100644
index 0000000..c1a79cc
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/APIRequest.java
@@ -0,0 +1,107 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.basic.rest;
+
+import java.util.Collections;
+import java.util.Enumeration;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletRequestWrapper;
+import javax.ws.rs.core.MultivaluedMap;
+
+/**
+ * Wrapper for HttpServletRequest which uses a given MultivaluedMap to provide
+ * the values of all request parameters.
+ * 
+ * @author Michael Jumper
+ */
+public class APIRequest extends HttpServletRequestWrapper {
+
+    /**
+     * Map of all request parameter names to their corresponding values.
+     */
+    private final Map<String, String[]> parameters;
+
+    /**
+     * Wraps the given HttpServletRequest, using the given MultivaluedMap to
+     * provide all request parameters. All HttpServletRequest functions which
+     * do not deal with parameter names and values are delegated to the wrapped
+     * request.
+     *
+     * @param request
+     *     The HttpServletRequest to wrap.
+     *
+     * @param parameters
+     *     All request parameters.
+     */
+    public APIRequest(HttpServletRequest request,
+            MultivaluedMap<String, String> parameters) {
+
+        super(request);
+
+        // Copy parameters from given MultivaluedMap 
+        this.parameters = new HashMap<String, String[]>(parameters.size());
+        for (Map.Entry<String, List<String>> entry : parameters.entrySet()) {
+
+            // Get parameter name and all corresponding values
+            String name = entry.getKey();
+            List<String> values = entry.getValue();
+
+            // Add parameters to map
+            this.parameters.put(name, values.toArray(new String[values.size()]));
+            
+        }
+        
+    }
+
+    @Override
+    public String[] getParameterValues(String name) {
+        return parameters.get(name);
+    }
+
+    @Override
+    public Enumeration<String> getParameterNames() {
+        return Collections.enumeration(parameters.keySet());
+    }
+
+    @Override
+    public Map<String, String[]> getParameterMap() {
+        return Collections.unmodifiableMap(parameters);
+    }
+
+    @Override
+    public String getParameter(String name) {
+
+        // If no such parameter exists, just return null
+        String[] values = getParameterValues(name);
+        if (values == null)
+            return null;
+
+        // Otherwise, return first value
+        return values[0];
+
+    }
+
+}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/ObjectRetrievalService.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/ObjectRetrievalService.java
new file mode 100644
index 0000000..a9af8c6
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/ObjectRetrievalService.java
@@ -0,0 +1,280 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.basic.rest;
+
+import java.util.List;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.GuacamoleResourceNotFoundException;
+import org.glyptodon.guacamole.net.auth.AuthenticationProvider;
+import org.glyptodon.guacamole.net.auth.Connection;
+import org.glyptodon.guacamole.net.auth.ConnectionGroup;
+import org.glyptodon.guacamole.net.auth.Directory;
+import org.glyptodon.guacamole.net.auth.User;
+import org.glyptodon.guacamole.net.auth.UserContext;
+import org.glyptodon.guacamole.net.basic.GuacamoleSession;
+import org.glyptodon.guacamole.net.basic.rest.connectiongroup.APIConnectionGroup;
+
+/**
+ * Provides easy access and automatic error handling for retrieval of objects,
+ * such as users, connections, or connection groups. REST API semantics, such
+ * as the special root connection group identifier, are also handled
+ * automatically.
+ */
+public class ObjectRetrievalService {
+
+    /**
+     * Retrieves a single UserContext from the given GuacamoleSession, which
+     * may contain multiple UserContexts.
+     *
+     * @param session
+     *     The GuacamoleSession to retrieve the UserContext from.
+     *
+     * @param authProviderIdentifier
+     *     The unique identifier of the AuthenticationProvider that created the
+     *     UserContext being retrieved. Only one UserContext per User per
+     *     AuthenticationProvider can exist.
+     *
+     * @return
+     *     The UserContext that was created by the AuthenticationProvider
+     *     having the given identifier.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while retrieving the UserContext, or if the
+     *     UserContext does not exist.
+     */
+    public UserContext retrieveUserContext(GuacamoleSession session,
+            String authProviderIdentifier) throws GuacamoleException {
+
+        // Get list of UserContexts
+        List<UserContext> userContexts = session.getUserContexts();
+
+        // Locate and return the UserContext associated with the
+        // AuthenticationProvider having the given identifier, if any
+        for (UserContext userContext : userContexts) {
+
+            // Get AuthenticationProvider associated with current UserContext
+            AuthenticationProvider authProvider = userContext.getAuthenticationProvider();
+
+            // If AuthenticationProvider identifier matches, done
+            if (authProvider.getIdentifier().equals(authProviderIdentifier))
+                return userContext;
+
+        }
+
+        throw new GuacamoleResourceNotFoundException("Session not associated with authentication provider \"" + authProviderIdentifier + "\".");
+
+    }
+
+    /**
+     * Retrieves a single user from the given user context.
+     *
+     * @param userContext
+     *     The user context to retrieve the user from.
+     *
+     * @param identifier
+     *     The identifier of the user to retrieve.
+     *
+     * @return
+     *     The user having the given identifier.
+     *
+     * @throws GuacamoleException 
+     *     If an error occurs while retrieving the user, or if the
+     *     user does not exist.
+     */
+    public User retrieveUser(UserContext userContext,
+            String identifier) throws GuacamoleException {
+
+        // Get user directory
+        Directory<User> directory = userContext.getUserDirectory();
+
+        // Pull specified user
+        User user = directory.get(identifier);
+        if (user == null)
+            throw new GuacamoleResourceNotFoundException("No such user: \"" + identifier + "\"");
+
+        return user;
+
+    }
+
+    /**
+     * Retrieves a single user from the given GuacamoleSession.
+     *
+     * @param session
+     *     The GuacamoleSession to retrieve the user from.
+     *
+     * @param authProviderIdentifier
+     *     The unique identifier of the AuthenticationProvider that created the
+     *     UserContext from which the user should be retrieved. Only one
+     *     UserContext per User per AuthenticationProvider can exist.
+     *
+     * @param identifier
+     *     The identifier of the user to retrieve.
+     *
+     * @return
+     *     The user having the given identifier.
+     *
+     * @throws GuacamoleException 
+     *     If an error occurs while retrieving the user, or if the
+     *     user does not exist.
+     */
+    public User retrieveUser(GuacamoleSession session, String authProviderIdentifier,
+            String identifier) throws GuacamoleException {
+
+        UserContext userContext = retrieveUserContext(session, authProviderIdentifier);
+        return retrieveUser(userContext, identifier);
+
+    }
+
+    /**
+     * Retrieves a single connection from the given user context.
+     *
+     * @param userContext
+     *     The user context to retrieve the connection from.
+     *
+     * @param identifier
+     *     The identifier of the connection to retrieve.
+     *
+     * @return
+     *     The connection having the given identifier.
+     *
+     * @throws GuacamoleException 
+     *     If an error occurs while retrieving the connection, or if the
+     *     connection does not exist.
+     */
+    public Connection retrieveConnection(UserContext userContext,
+            String identifier) throws GuacamoleException {
+
+        // Get connection directory
+        Directory<Connection> directory = userContext.getConnectionDirectory();
+
+        // Pull specified connection
+        Connection connection = directory.get(identifier);
+        if (connection == null)
+            throw new GuacamoleResourceNotFoundException("No such connection: \"" + identifier + "\"");
+
+        return connection;
+
+    }
+
+    /**
+     * Retrieves a single connection from the given GuacamoleSession.
+     *
+     * @param session
+     *     The GuacamoleSession to retrieve the connection from.
+     *
+     * @param authProviderIdentifier
+     *     The unique identifier of the AuthenticationProvider that created the
+     *     UserContext from which the connection should be retrieved. Only one
+     *     UserContext per User per AuthenticationProvider can exist.
+     *
+     * @param identifier
+     *     The identifier of the connection to retrieve.
+     *
+     * @return
+     *     The connection having the given identifier.
+     *
+     * @throws GuacamoleException 
+     *     If an error occurs while retrieving the connection, or if the
+     *     connection does not exist.
+     */
+    public Connection retrieveConnection(GuacamoleSession session,
+            String authProviderIdentifier, String identifier)
+            throws GuacamoleException {
+
+        UserContext userContext = retrieveUserContext(session, authProviderIdentifier);
+        return retrieveConnection(userContext, identifier);
+
+    }
+
+    /**
+     * Retrieves a single connection group from the given user context. If
+     * the given identifier the REST API root identifier, the root connection
+     * group will be returned. The underlying authentication provider may
+     * additionally use a different identifier for root.
+     *
+     * @param userContext
+     *     The user context to retrieve the connection group from.
+     *
+     * @param identifier
+     *     The identifier of the connection group to retrieve.
+     *
+     * @return
+     *     The connection group having the given identifier, or the root
+     *     connection group if the identifier the root identifier.
+     *
+     * @throws GuacamoleException 
+     *     If an error occurs while retrieving the connection group, or if the
+     *     connection group does not exist.
+     */
+    public ConnectionGroup retrieveConnectionGroup(UserContext userContext,
+            String identifier) throws GuacamoleException {
+
+        // Use root group if identifier is the standard root identifier
+        if (identifier != null && identifier.equals(APIConnectionGroup.ROOT_IDENTIFIER))
+            return userContext.getRootConnectionGroup();
+
+        // Pull specified connection group otherwise
+        Directory<ConnectionGroup> directory = userContext.getConnectionGroupDirectory();
+        ConnectionGroup connectionGroup = directory.get(identifier);
+
+        if (connectionGroup == null)
+            throw new GuacamoleResourceNotFoundException("No such connection group: \"" + identifier + "\"");
+
+        return connectionGroup;
+
+    }
+
+    /**
+     * Retrieves a single connection group from the given GuacamoleSession. If
+     * the given identifier is the REST API root identifier, the root
+     * connection group will be returned. The underlying authentication
+     * provider may additionally use a different identifier for root.
+     *
+     * @param session
+     *     The GuacamoleSession to retrieve the connection group from.
+     *
+     * @param authProviderIdentifier
+     *     The unique identifier of the AuthenticationProvider that created the
+     *     UserContext from which the connection group should be retrieved.
+     *     Only one UserContext per User per AuthenticationProvider can exist.
+     *
+     * @param identifier
+     *     The identifier of the connection group to retrieve.
+     *
+     * @return
+     *     The connection group having the given identifier, or the root
+     *     connection group if the identifier is the root identifier.
+     *
+     * @throws GuacamoleException 
+     *     If an error occurs while retrieving the connection group, or if the
+     *     connection group does not exist.
+     */
+    public ConnectionGroup retrieveConnectionGroup(GuacamoleSession session,
+            String authProviderIdentifier, String identifier) throws GuacamoleException {
+
+        UserContext userContext = retrieveUserContext(session, authProviderIdentifier);
+        return retrieveConnectionGroup(userContext, identifier);
+
+    }
+
+}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/PATCH.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/PATCH.java
new file mode 100644
index 0000000..bca1b33
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/PATCH.java
@@ -0,0 +1,40 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+
+package org.glyptodon.guacamole.net.basic.rest;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+import javax.ws.rs.HttpMethod;
+
+/**
+ * An annotation for using the HTTP PATCH method in the REST endpoints.
+ * 
+ * @author James Muehlner
+ */
+ at Target({ElementType.METHOD}) 
+ at Retention(RetentionPolicy.RUNTIME) 
+ at HttpMethod("PATCH") 
+public @interface PATCH {} 
\ No newline at end of file
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/RESTExceptionWrapper.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/RESTExceptionWrapper.java
new file mode 100644
index 0000000..92bb63f
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/RESTExceptionWrapper.java
@@ -0,0 +1,274 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.basic.rest;
+
+import com.google.inject.Inject;
+import java.lang.annotation.Annotation;
+import java.lang.reflect.Method;
+import javax.ws.rs.FormParam;
+import javax.ws.rs.QueryParam;
+import org.aopalliance.intercept.MethodInterceptor;
+import org.aopalliance.intercept.MethodInvocation;
+import org.glyptodon.guacamole.GuacamoleClientException;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.GuacamoleResourceNotFoundException;
+import org.glyptodon.guacamole.GuacamoleSecurityException;
+import org.glyptodon.guacamole.GuacamoleUnauthorizedException;
+import org.glyptodon.guacamole.net.auth.credentials.GuacamoleInsufficientCredentialsException;
+import org.glyptodon.guacamole.net.auth.credentials.GuacamoleInvalidCredentialsException;
+import org.glyptodon.guacamole.net.basic.rest.auth.AuthenticationService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * A method interceptor which wraps custom exception handling around methods
+ * which can throw GuacamoleExceptions and which are exposed through the REST
+ * interface. The various types of GuacamoleExceptions are automatically
+ * translated into appropriate HTTP responses, including JSON describing the
+ * error that occurred.
+ *
+ * @author James Muehlner
+ * @author Michael Jumper
+ */
+public class RESTExceptionWrapper implements MethodInterceptor {
+
+    /**
+     * Logger for this class.
+     */
+    private final Logger logger = LoggerFactory.getLogger(RESTExceptionWrapper.class);
+
+    /**
+     * Service for authenticating users and managing their Guacamole sessions.
+     */
+    @Inject
+    private AuthenticationService authenticationService;
+
+    /**
+     * Determines whether the given set of annotations describes an HTTP
+     * request parameter of the given name. For a parameter to be associated
+     * with an HTTP request parameter, it must be annotated with either the
+     * <code>@QueryParam</code> or <code>@FormParam</code> annotations.
+     *
+     * @param annotations
+     *     The annotations associated with the Java parameter being checked.
+     *
+     * @param name
+     *     The name of the HTTP request parameter.
+     *
+     * @return
+     *     true if the given set of annotations describes an HTTP request
+     *     parameter having the given name, false otherwise.
+     */
+    private boolean isRequestParameter(Annotation[] annotations, String name) {
+
+        // Search annotations for associated HTTP parameters
+        for (Annotation annotation : annotations) {
+
+            // Check if parameter is associated with the HTTP query string
+            if (annotation instanceof QueryParam && name.equals(((QueryParam) annotation).value()))
+                return true;
+
+            // Failing that, check whether the parameter is associated with the
+            // HTTP request body
+            if (annotation instanceof FormParam && name.equals(((FormParam) annotation).value()))
+                return true;
+
+        }
+
+        // No parameter annotations are present
+        return false;
+
+    }
+
+    /**
+     * Returns the authentication token that was passed in the given method
+     * invocation. If the given method invocation is not associated with an
+     * HTTP request (it lacks the appropriate JAX-RS annotations) or there is
+     * no authentication token, null is returned.
+     *
+     * @param invocation
+     *     The method invocation whose corresponding authentication token
+     *     should be determined.
+     *
+     * @return
+     *     The authentication token passed in the given method invocation, or
+     *     null if there is no such token.
+     */
+    private String getAuthenticationToken(MethodInvocation invocation) {
+
+        Method method = invocation.getMethod();
+
+        // Get the types and annotations associated with each parameter
+        Annotation[][] parameterAnnotations = method.getParameterAnnotations();
+        Class<?>[] parameterTypes = method.getParameterTypes();
+
+        // The Java standards require these to be parallel arrays
+        assert(parameterAnnotations.length == parameterTypes.length);
+
+        // Iterate through all parameters, looking for the authentication token
+        for (int i = 0; i < parameterTypes.length; i++) {
+
+            // Only inspect String parameters
+            Class<?> parameterType = parameterTypes[i];
+            if (parameterType != String.class)
+                continue;
+
+            // Parameter must be declared as a REST service parameter
+            Annotation[] annotations = parameterAnnotations[i];
+            if (!isRequestParameter(annotations, "token"))
+                continue;
+
+            // The token parameter has been found - return its value
+            Object[] args = invocation.getArguments();
+            return (String) args[i];
+
+        }
+
+        // No token parameter is defined
+        return null;
+
+    }
+
+    @Override
+    public Object invoke(MethodInvocation invocation) throws Throwable {
+
+        try {
+
+            // Invoke wrapped method
+            try {
+                return invocation.proceed();
+            }
+
+            // Ensure any associated session is invalidated if unauthorized
+            catch (GuacamoleUnauthorizedException e) {
+
+                // Pull authentication token from request
+                String token = getAuthenticationToken(invocation);
+
+                // If there is an associated auth token, invalidate it
+                if (authenticationService.destroyGuacamoleSession(token))
+                    logger.debug("Implicitly invalidated session for token \"{}\".", token);
+
+                // Continue with exception processing
+                throw e;
+
+            }
+
+        }
+
+        // Additional credentials are needed
+        catch (GuacamoleInsufficientCredentialsException e) {
+
+            // Generate default message
+            String message = e.getMessage();
+            if (message == null)
+                message = "Permission denied.";
+
+            throw new APIException(
+                APIError.Type.INSUFFICIENT_CREDENTIALS,
+                message,
+                e.getCredentialsInfo().getFields()
+            );
+        }
+
+        // The provided credentials are wrong
+        catch (GuacamoleInvalidCredentialsException e) {
+
+            // Generate default message
+            String message = e.getMessage();
+            if (message == null)
+                message = "Permission denied.";
+
+            throw new APIException(
+                APIError.Type.INVALID_CREDENTIALS,
+                message,
+                e.getCredentialsInfo().getFields()
+            );
+        }
+
+        // Generic permission denied
+        catch (GuacamoleSecurityException e) {
+
+            // Generate default message
+            String message = e.getMessage();
+            if (message == null)
+                message = "Permission denied.";
+
+            throw new APIException(
+                APIError.Type.PERMISSION_DENIED,
+                message
+            );
+
+        }
+
+        // Arbitrary resource not found
+        catch (GuacamoleResourceNotFoundException e) {
+
+            // Generate default message
+            String message = e.getMessage();
+            if (message == null)
+                message = "Not found.";
+
+            throw new APIException(
+                APIError.Type.NOT_FOUND,
+                message
+            );
+
+        }
+        
+        // Arbitrary bad requests
+        catch (GuacamoleClientException e) {
+
+            // Generate default message
+            String message = e.getMessage();
+            if (message == null)
+                message = "Invalid request.";
+
+            throw new APIException(
+                APIError.Type.BAD_REQUEST,
+                message
+            );
+
+        }
+
+        // All other errors
+        catch (GuacamoleException e) {
+
+            // Generate default message
+            String message = e.getMessage();
+            if (message == null)
+                message = "Unexpected server error.";
+
+            // Ensure internal errors are logged at the debug level
+            logger.debug("Unexpected exception in REST endpoint.", e);
+
+            throw new APIException(
+                APIError.Type.INTERNAL_ERROR,
+                message
+            );
+
+        }
+
+    }
+
+}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/RESTMethodMatcher.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/RESTMethodMatcher.java
new file mode 100644
index 0000000..643addc
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/RESTMethodMatcher.java
@@ -0,0 +1,109 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.basic.rest;
+
+import com.google.inject.matcher.AbstractMatcher;
+import java.lang.annotation.Annotation;
+import java.lang.reflect.Method;
+import javax.ws.rs.HttpMethod;
+import org.glyptodon.guacamole.GuacamoleException;
+
+/**
+ * A Guice Matcher which matches only methods which throw GuacamoleException
+ * (or a subclass thereof) and are explicitly annotated as with an HTTP method
+ * annotation like <code>@GET</code> or <code>@POST</code>. Any method which
+ * throws GuacamoleException and is annotated with an annotation that is
+ * annotated with <code>@HttpMethod</code> will match.
+ *
+ * @author Michael Jumper
+ */
+public class RESTMethodMatcher extends AbstractMatcher<Method> {
+
+    /**
+     * Returns whether the given method throws the specified exception type,
+     * including any subclasses of that type.
+     *
+     * @param method
+     *     The method to test.
+     *
+     * @param exceptionType
+     *     The exception type to test for.
+     *
+     * @return
+     *     true if the given method throws an exception of the specified type,
+     *     false otherwise.
+     */
+    private boolean methodThrowsException(Method method,
+            Class<? extends Exception> exceptionType) {
+
+        // Check whether the method throws an exception of the specified type
+        for (Class<?> thrownType : method.getExceptionTypes()) {
+            if (exceptionType.isAssignableFrom(thrownType))
+                return true;
+        }
+
+        // No such exception is declared to be thrown
+        return false;
+        
+    }
+
+    /**
+     * Returns whether the given method is annotated as a REST method. A REST
+     * method is annotated with an annotation which is annotated with
+     * <code>@HttpMethod</code>.
+     *
+     * @param method
+     *     The method to test.
+     *
+     * @return
+     *     true if the given method is annotated as a REST method, false
+     *     otherwise.
+     */
+    private boolean isRESTMethod(Method method) {
+
+        // Check whether the required REST annotations are present
+        for (Annotation annotation : method.getAnnotations()) {
+
+            // A method is a REST method if it is annotated with @HttpMethod
+            Class<? extends Annotation> annotationType = annotation.annotationType();
+            if (annotationType.isAnnotationPresent(HttpMethod.class))
+                return true;
+
+        }
+
+        // The method is not an HTTP method
+        return false;
+
+    }
+
+    @Override
+    public boolean matches(Method method) {
+
+        // Guacamole REST methods are REST methods which throw
+        // GuacamoleExceptions
+        return isRESTMethod(method)
+            && methodThrowsException(method, GuacamoleException.class);
+
+    }
+
+}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/RESTServiceModule.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/RESTServiceModule.java
new file mode 100644
index 0000000..40ee2d3
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/RESTServiceModule.java
@@ -0,0 +1,105 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.basic.rest;
+
+import com.google.inject.Scopes;
+import com.google.inject.matcher.Matchers;
+import com.google.inject.servlet.ServletModule;
+import com.sun.jersey.guice.spi.container.servlet.GuiceContainer;
+import org.aopalliance.intercept.MethodInterceptor;
+import org.codehaus.jackson.jaxrs.JacksonJsonProvider;
+import org.glyptodon.guacamole.net.basic.rest.auth.TokenRESTService;
+import org.glyptodon.guacamole.net.basic.rest.connection.ConnectionRESTService;
+import org.glyptodon.guacamole.net.basic.rest.connectiongroup.ConnectionGroupRESTService;
+import org.glyptodon.guacamole.net.basic.rest.activeconnection.ActiveConnectionRESTService;
+import org.glyptodon.guacamole.net.basic.rest.auth.AuthTokenGenerator;
+import org.glyptodon.guacamole.net.basic.rest.auth.AuthenticationService;
+import org.glyptodon.guacamole.net.basic.rest.auth.SecureRandomAuthTokenGenerator;
+import org.glyptodon.guacamole.net.basic.rest.auth.TokenSessionMap;
+import org.glyptodon.guacamole.net.basic.rest.history.HistoryRESTService;
+import org.glyptodon.guacamole.net.basic.rest.language.LanguageRESTService;
+import org.glyptodon.guacamole.net.basic.rest.schema.SchemaRESTService;
+import org.glyptodon.guacamole.net.basic.rest.user.UserRESTService;
+
+/**
+ * A Guice Module to set up the servlet mappings and authentication-specific
+ * dependency injection for the Guacamole REST API.
+ *
+ * @author James Muehlner
+ * @author Michael Jumper
+ */
+public class RESTServiceModule extends ServletModule {
+
+    /**
+     * Singleton instance of TokenSessionMap.
+     */
+    private final TokenSessionMap tokenSessionMap;
+
+    /**
+     * Creates a module which handles binding of REST services and related
+     * authentication objects, including the singleton TokenSessionMap.
+     *
+     * @param tokenSessionMap
+     *     An instance of TokenSessionMap to inject as a singleton wherever
+     *     needed.
+     */
+    public RESTServiceModule(TokenSessionMap tokenSessionMap) {
+        this.tokenSessionMap = tokenSessionMap;
+    }
+
+    @Override
+    protected void configureServlets() {
+
+        // Bind session map
+        bind(TokenSessionMap.class).toInstance(tokenSessionMap);
+
+        // Bind low-level services
+        bind(AuthenticationService.class);
+        bind(AuthTokenGenerator.class).to(SecureRandomAuthTokenGenerator.class);
+
+        // Automatically translate GuacamoleExceptions for REST methods
+        MethodInterceptor interceptor = new RESTExceptionWrapper();
+        requestInjection(interceptor);
+        bindInterceptor(Matchers.any(), new RESTMethodMatcher(), interceptor);
+
+        // Bind convenience services used by the REST API
+        bind(ObjectRetrievalService.class);
+
+        // Set up the API endpoints
+        bind(ActiveConnectionRESTService.class);
+        bind(ConnectionGroupRESTService.class);
+        bind(ConnectionRESTService.class);
+        bind(HistoryRESTService.class);
+        bind(LanguageRESTService.class);
+        bind(SchemaRESTService.class);
+        bind(TokenRESTService.class);
+        bind(UserRESTService.class);
+
+        // Set up the servlet and JSON mappings
+        bind(GuiceContainer.class);
+        bind(JacksonJsonProvider.class).in(Scopes.SINGLETON);
+        serve("/api/*").with(GuiceContainer.class);
+
+    }
+
+}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/activeconnection/APIActiveConnection.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/activeconnection/APIActiveConnection.java
new file mode 100644
index 0000000..2065c39
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/activeconnection/APIActiveConnection.java
@@ -0,0 +1,130 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.basic.rest.activeconnection;
+
+import java.util.Date;
+import org.glyptodon.guacamole.net.auth.ActiveConnection;
+
+/**
+ * Information related to active connections which may be exposed through the
+ * REST endpoints.
+ * 
+ * @author Michael Jumper
+ */
+public class APIActiveConnection {
+
+    /**
+     * The identifier of the active connection itself.
+     */
+    private final String identifier;
+
+    /**
+     * The identifier of the connection associated with this
+     * active connection.
+     */
+    private final String connectionIdentifier;
+    
+    /**
+     * The date and time the connection began.
+     */
+    private final Date startDate;
+
+    /**
+     * The host from which the connection originated, if known.
+     */
+    private final String remoteHost;
+    
+    /**
+     * The name of the user who used or is using the connection.
+     */
+    private final String username;
+
+    /**
+     * Creates a new APIActiveConnection, copying the information from the given
+     * active connection.
+     *
+     * @param connection
+     *     The active connection to copy data from.
+     */
+    public APIActiveConnection(ActiveConnection connection) {
+        this.identifier           = connection.getIdentifier();
+        this.connectionIdentifier = connection.getConnectionIdentifier();
+        this.startDate            = connection.getStartDate();
+        this.remoteHost           = connection.getRemoteHost();
+        this.username             = connection.getUsername();
+    }
+
+    /**
+     * Returns the identifier of the connection associated with this tunnel.
+     *
+     * @return
+     *     The identifier of the connection associated with this tunnel.
+     */
+    public String getConnectionIdentifier() {
+        return connectionIdentifier;
+    }
+    
+    /**
+     * Returns the date and time the connection began.
+     *
+     * @return
+     *     The date and time the connection began.
+     */
+    public Date getStartDate() {
+        return startDate;
+    }
+
+    /**
+     * Returns the remote host from which this connection originated.
+     *
+     * @return
+     *     The remote host from which this connection originated.
+     */
+    public String getRemoteHost() {
+        return remoteHost;
+    }
+
+    /**
+     * Returns the name of the user who used or is using the connection at the
+     * times given by this tunnel.
+     *
+     * @return
+     *     The name of the user who used or is using the associated connection.
+     */
+    public String getUsername() {
+        return username;
+    }
+
+    /**
+     * Returns the identifier of the active connection itself. This is
+     * distinct from the connection identifier, and uniquely identifies a
+     * specific use of a connection.
+     *
+     * @return
+     *     The identifier of the active connection.
+     */
+    public String getIdentifier() {
+        return identifier;
+    }
+    
+}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/activeconnection/ActiveConnectionRESTService.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/activeconnection/ActiveConnectionRESTService.java
new file mode 100644
index 0000000..2f2b699
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/activeconnection/ActiveConnectionRESTService.java
@@ -0,0 +1,196 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.basic.rest.activeconnection;
+
+import com.google.inject.Inject;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import javax.ws.rs.Consumes;
+import javax.ws.rs.GET;
+import javax.ws.rs.Path;
+import javax.ws.rs.PathParam;
+import javax.ws.rs.Produces;
+import javax.ws.rs.QueryParam;
+import javax.ws.rs.core.MediaType;
+import org.glyptodon.guacamole.GuacamoleClientException;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.GuacamoleUnsupportedException;
+import org.glyptodon.guacamole.net.auth.ActiveConnection;
+import org.glyptodon.guacamole.net.auth.Directory;
+import org.glyptodon.guacamole.net.auth.User;
+import org.glyptodon.guacamole.net.auth.UserContext;
+import org.glyptodon.guacamole.net.auth.permission.ObjectPermission;
+import org.glyptodon.guacamole.net.auth.permission.ObjectPermissionSet;
+import org.glyptodon.guacamole.net.auth.permission.SystemPermission;
+import org.glyptodon.guacamole.net.auth.permission.SystemPermissionSet;
+import org.glyptodon.guacamole.net.basic.GuacamoleSession;
+import org.glyptodon.guacamole.net.basic.rest.APIPatch;
+import org.glyptodon.guacamole.net.basic.rest.ObjectRetrievalService;
+import org.glyptodon.guacamole.net.basic.rest.PATCH;
+import org.glyptodon.guacamole.net.basic.rest.auth.AuthenticationService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * A REST Service for retrieving and managing the tunnels of active connections.
+ * 
+ * @author Michael Jumper
+ */
+ at Path("/data/{dataSource}/activeConnections")
+ at Produces(MediaType.APPLICATION_JSON)
+ at Consumes(MediaType.APPLICATION_JSON)
+public class ActiveConnectionRESTService {
+
+    /**
+     * Logger for this class.
+     */
+    private static final Logger logger = LoggerFactory.getLogger(ActiveConnectionRESTService.class);
+
+    /**
+     * A service for authenticating users from auth tokens.
+     */
+    @Inject
+    private AuthenticationService authenticationService;
+
+    /**
+     * Service for convenient retrieval of objects.
+     */
+    @Inject
+    private ObjectRetrievalService retrievalService;
+
+    /**
+     * Gets a list of active connections in the system, filtering the returned
+     * list by the given permissions, if specified.
+     * 
+     * @param authToken
+     *     The authentication token that is used to authenticate the user
+     *     performing the operation.
+     *
+     * @param authProviderIdentifier
+     *     The unique identifier of the AuthenticationProvider associated with
+     *     the UserContext containing the active connections to be retrieved.
+     *
+     * @param permissions
+     *     The set of permissions to filter with. A user must have one or more
+     *     of these permissions for a user to appear in the result. 
+     *     If null, no filtering will be performed.
+     * 
+     * @return
+     *     A list of all active connections. If a permission was specified,
+     *     this list will contain only those active connections for which the
+     *     current user has that permission.
+     * 
+     * @throws GuacamoleException
+     *     If an error is encountered while retrieving active connections.
+     */
+    @GET
+    public Map<String, APIActiveConnection> getActiveConnections(@QueryParam("token") String authToken,
+            @PathParam("dataSource") String authProviderIdentifier,
+            @QueryParam("permission") List<ObjectPermission.Type> permissions)
+            throws GuacamoleException {
+
+        GuacamoleSession session = authenticationService.getGuacamoleSession(authToken);
+        UserContext userContext = retrievalService.retrieveUserContext(session, authProviderIdentifier);
+        User self = userContext.self();
+        
+        // Do not filter on permissions if no permissions are specified
+        if (permissions != null && permissions.isEmpty())
+            permissions = null;
+
+        // An admin user has access to any connection
+        SystemPermissionSet systemPermissions = self.getSystemPermissions();
+        boolean isAdmin = systemPermissions.hasPermission(SystemPermission.Type.ADMINISTER);
+
+        // Get the directory
+        Directory<ActiveConnection> activeConnectionDirectory = userContext.getActiveConnectionDirectory();
+
+        // Filter connections, if requested
+        Collection<String> activeConnectionIdentifiers = activeConnectionDirectory.getIdentifiers();
+        if (!isAdmin && permissions != null) {
+            ObjectPermissionSet activeConnectionPermissions = self.getActiveConnectionPermissions();
+            activeConnectionIdentifiers = activeConnectionPermissions.getAccessibleObjects(permissions, activeConnectionIdentifiers);
+        }
+            
+        // Retrieve all active connections , converting to API active connections
+        Map<String, APIActiveConnection> apiActiveConnections = new HashMap<String, APIActiveConnection>();
+        for (ActiveConnection activeConnection : activeConnectionDirectory.getAll(activeConnectionIdentifiers))
+            apiActiveConnections.put(activeConnection.getIdentifier(), new APIActiveConnection(activeConnection));
+
+        return apiActiveConnections;
+
+    }
+
+    /**
+     * Applies the given active connection patches. This operation currently
+     * only supports deletion of active connections through the "remove" patch
+     * operation. Deleting an active connection effectively kills the
+     * connection. The path of each patch operation is of the form "/ID"
+     * where ID is the identifier of the active connection being modified.
+     * 
+     * @param authToken
+     *     The authentication token that is used to authenticate the user
+     *     performing the operation.
+     *
+     * @param authProviderIdentifier
+     *     The unique identifier of the AuthenticationProvider associated with
+     *     the UserContext containing the active connections to be deleted.
+     *
+     * @param patches
+     *     The active connection patches to apply for this request.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while deleting the active connections.
+     */
+    @PATCH
+    public void patchTunnels(@QueryParam("token") String authToken,
+            @PathParam("dataSource") String authProviderIdentifier,
+            List<APIPatch<String>> patches) throws GuacamoleException {
+
+        GuacamoleSession session = authenticationService.getGuacamoleSession(authToken);
+        UserContext userContext = retrievalService.retrieveUserContext(session, authProviderIdentifier);
+
+        // Get the directory
+        Directory<ActiveConnection> activeConnectionDirectory = userContext.getActiveConnectionDirectory();
+
+        // Close each connection listed for removal
+        for (APIPatch<String> patch : patches) {
+
+            // Only remove is supported
+            if (patch.getOp() != APIPatch.Operation.remove)
+                throw new GuacamoleUnsupportedException("Only the \"remove\" operation is supported when patching active connections.");
+
+            // Retrieve and validate path
+            String path = patch.getPath();
+            if (!path.startsWith("/"))
+                throw new GuacamoleClientException("Patch paths must start with \"/\".");
+
+            // Close connection 
+            activeConnectionDirectory.remove(path.substring(1));
+            
+        }
+        
+    }
+    
+}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/auth/APIAuthenticationResponse.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/auth/APIAuthenticationResponse.java
new file mode 100644
index 0000000..6c4126b
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/auth/APIAuthenticationResponse.java
@@ -0,0 +1,105 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.basic.rest.auth;
+
+/**
+ * A simple object to represent an auth token/username pair in the API.
+ * 
+ * @author James Muehlner
+ */
+public class APIAuthenticationResponse {
+    
+    /**
+     * The auth token.
+     */
+    private final String authToken;
+    
+    
+    /**
+     * The username of the user that authenticated.
+     */
+    private final String username;
+
+    /**
+     * The unique identifier of the data source from which this user account
+     * came. Although this user account may exist across several data sources
+     * (AuthenticationProviders), this will be the unique identifier of the
+     * AuthenticationProvider that authenticated this user for the current
+     * session.
+     */
+    private final String dataSource;
+
+    /**
+     * Returns the unique authentication token which identifies the current
+     * session.
+     *
+     * @return
+     *     The user's authentication token.
+     */
+    public String getAuthToken() {
+        return authToken;
+    }
+    
+    /**
+     * Returns the user identified by the authentication token associated with
+     * the current session.
+     *
+     * @return
+     *      The user identified by this authentication token.
+     */
+    public String getUsername() {
+        return username;
+    }
+
+    /**
+     * Returns the unique identifier of the data source associated with the user
+     * account associated with this auth token.
+     * 
+     * @return 
+     *     The unique identifier of the data source associated with the user
+     *     account associated with this auth token.
+     */
+    public String getDataSource() {
+        return dataSource;
+    }
+    
+    /**
+     * Create a new APIAuthToken Object with the given auth token.
+     *
+     * @param dataSource
+     *     The unique identifier of the AuthenticationProvider which
+     *     authenticated the user.
+     *
+     * @param authToken
+     *     The auth token to create the new APIAuthToken with.
+     *
+     * @param username
+     *     The username of the user owning the given token.
+     */
+    public APIAuthenticationResponse(String dataSource, String authToken, String username) {
+        this.dataSource = dataSource;
+        this.authToken = authToken;
+        this.username = username;
+    }
+
+}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/auth/APIAuthenticationResult.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/auth/APIAuthenticationResult.java
new file mode 100644
index 0000000..ab8e50d
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/auth/APIAuthenticationResult.java
@@ -0,0 +1,133 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.basic.rest.auth;
+
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * A simple object which describes the result of an authentication operation,
+ * including the resulting token.
+ *
+ * @author James Muehlner
+ * @author Michael Jumper
+ */
+public class APIAuthenticationResult {
+
+    /**
+     * The unique token generated for the user that authenticated.
+     */
+    private final String authToken;
+
+    /**
+     * The username of the user that authenticated.
+     */
+    private final String username;
+
+    /**
+     * The unique identifier of the data source from which this user account
+     * came. Although this user account may exist across several data sources
+     * (AuthenticationProviders), this will be the unique identifier of the
+     * AuthenticationProvider that authenticated this user for the current
+     * session.
+     */
+    private final String dataSource;
+
+    /**
+     * The identifiers of all data sources available to this user.
+     */
+    private final List<String> availableDataSources;
+
+    /**
+     * Returns the unique authentication token which identifies the current
+     * session.
+     *
+     * @return
+     *     The user's authentication token.
+     */
+    public String getAuthToken() {
+        return authToken;
+    }
+
+    /**
+     * Returns the user identified by the authentication token associated with
+     * the current session.
+     *
+     * @return
+     *      The user identified by this authentication token.
+     */
+    public String getUsername() {
+        return username;
+    }
+
+    /**
+     * Returns the unique identifier of the data source associated with the user
+     * account associated with the current session.
+     *
+     * @return
+     *     The unique identifier of the data source associated with the user
+     *     account associated with the current session.
+     */
+    public String getDataSource() {
+        return dataSource;
+    }
+
+    /**
+     * Returns the identifiers of all data sources available to the user
+     * associated with the current session.
+     *
+     * @return
+     *     The identifiers of all data sources available to the user associated
+     *     with the current session.
+     */
+    public List<String> getAvailableDataSources() {
+        return availableDataSources;
+    }
+
+    /**
+     * Create a new APIAuthenticationResult object containing the given data.
+     *
+     * @param authToken
+     *     The unique token generated for the user that authenticated, to be
+     *     used for the duration of their session.
+     *
+     * @param username
+     *     The username of the user owning the given token.
+     *
+     * @param dataSource
+     *     The unique identifier of the AuthenticationProvider which
+     *     authenticated the user.
+     *
+     * @param availableDataSources
+     *     The unique identifier of all AuthenticationProviders to which the
+     *     user now has access.
+     */
+    public APIAuthenticationResult(String authToken, String username,
+            String dataSource, List<String> availableDataSources) {
+        this.authToken = authToken;
+        this.username = username;
+        this.dataSource = dataSource;
+        this.availableDataSources = Collections.unmodifiableList(availableDataSources);
+    }
+
+}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/auth/AuthTokenGenerator.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/auth/AuthTokenGenerator.java
new file mode 100644
index 0000000..1c35d58
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/auth/AuthTokenGenerator.java
@@ -0,0 +1,39 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.basic.rest.auth;
+
+/**
+ * Generates an auth token for an authenticated user.
+ * 
+ * @author James Muehlner
+ */
+public interface AuthTokenGenerator {
+    
+    /**
+     * Get a new auth token.
+     * 
+     * @return A new auth token.
+     */
+    public String getToken();
+
+}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/auth/AuthenticationService.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/auth/AuthenticationService.java
new file mode 100644
index 0000000..a472768
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/auth/AuthenticationService.java
@@ -0,0 +1,475 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.basic.rest.auth;
+
+import com.google.inject.Inject;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.regex.Pattern;
+import javax.servlet.http.HttpServletRequest;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.GuacamoleSecurityException;
+import org.glyptodon.guacamole.GuacamoleUnauthorizedException;
+import org.glyptodon.guacamole.environment.Environment;
+import org.glyptodon.guacamole.net.auth.AuthenticatedUser;
+import org.glyptodon.guacamole.net.auth.AuthenticationProvider;
+import org.glyptodon.guacamole.net.auth.Credentials;
+import org.glyptodon.guacamole.net.auth.UserContext;
+import org.glyptodon.guacamole.net.auth.credentials.CredentialsInfo;
+import org.glyptodon.guacamole.net.auth.credentials.GuacamoleCredentialsException;
+import org.glyptodon.guacamole.net.auth.credentials.GuacamoleInvalidCredentialsException;
+import org.glyptodon.guacamole.net.basic.GuacamoleSession;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * A service for performing authentication checks in REST endpoints.
+ * 
+ * @author James Muehlner
+ * @author Michael Jumper
+ */
+public class AuthenticationService {
+
+    /**
+     * Logger for this class.
+     */
+    private static final Logger logger = LoggerFactory.getLogger(AuthenticationService.class);
+
+    /**
+     * The Guacamole server environment.
+     */
+    @Inject
+    private Environment environment;
+
+    /**
+     * All configured authentication providers which can be used to
+     * authenticate users or retrieve data associated with authenticated users.
+     */
+    @Inject
+    private List<AuthenticationProvider> authProviders;
+
+    /**
+     * The map of auth tokens to sessions for the REST endpoints.
+     */
+    @Inject
+    private TokenSessionMap tokenSessionMap;
+
+    /**
+     * A generator for creating new auth tokens.
+     */
+    @Inject
+    private AuthTokenGenerator authTokenGenerator;
+
+    /**
+     * Regular expression which matches any IPv4 address.
+     */
+    private static final String IPV4_ADDRESS_REGEX = "([0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3})";
+
+    /**
+     * Regular expression which matches any IPv6 address.
+     */
+    private static final String IPV6_ADDRESS_REGEX = "([0-9a-fA-F]*(:[0-9a-fA-F]*){0,7})";
+
+    /**
+     * Regular expression which matches any IP address, regardless of version.
+     */
+    private static final String IP_ADDRESS_REGEX = "(" + IPV4_ADDRESS_REGEX + "|" + IPV6_ADDRESS_REGEX + ")";
+
+    /**
+     * Pattern which matches valid values of the de-facto standard
+     * "X-Forwarded-For" header.
+     */
+    private static final Pattern X_FORWARDED_FOR = Pattern.compile("^" + IP_ADDRESS_REGEX + "(, " + IP_ADDRESS_REGEX + ")*$");
+
+    /**
+     * Returns a formatted string containing an IP address, or list of IP
+     * addresses, which represent the HTTP client and any involved proxies. As
+     * the headers used to determine proxies can easily be forged, this data is
+     * superficially validated to ensure that it at least looks like a list of
+     * IPs.
+     *
+     * @param request
+     *     The HTTP request to format.
+     *
+     * @return
+     *     A formatted string containing one or more IP addresses.
+     */
+    private String getLoggableAddress(HttpServletRequest request) {
+
+        // Log X-Forwarded-For, if present and valid
+        String header = request.getHeader("X-Forwarded-For");
+        if (header != null && X_FORWARDED_FOR.matcher(header).matches())
+            return "[" + header + ", " + request.getRemoteAddr() + "]";
+
+        // If header absent or invalid, just use source IP
+        return request.getRemoteAddr();
+
+    }
+
+    /**
+     * Attempts authentication against all AuthenticationProviders, in order,
+     * using the provided credentials. The first authentication failure takes
+     * priority, but remaining AuthenticationProviders are attempted. If any
+     * AuthenticationProvider succeeds, the resulting AuthenticatedUser is
+     * returned, and no further AuthenticationProviders are tried.
+     *
+     * @param credentials
+     *     The credentials to use for authentication.
+     *
+     * @return
+     *     The AuthenticatedUser given by the highest-priority
+     *     AuthenticationProvider for which the given credentials are valid.
+     *
+     * @throws GuacamoleException
+     *     If the given credentials are not valid for any
+     *     AuthenticationProvider, or if an error occurs while authenticating
+     *     the user.
+     */
+    private AuthenticatedUser authenticateUser(Credentials credentials)
+        throws GuacamoleException {
+
+        GuacamoleCredentialsException authFailure = null;
+
+        // Attempt authentication against each AuthenticationProvider
+        for (AuthenticationProvider authProvider : authProviders) {
+
+            // Attempt authentication
+            try {
+                AuthenticatedUser authenticatedUser = authProvider.authenticateUser(credentials);
+                if (authenticatedUser != null)
+                    return authenticatedUser;
+            }
+
+            // First failure takes priority for now
+            catch (GuacamoleCredentialsException e) {
+                if (authFailure == null)
+                    authFailure = e;
+            }
+
+        }
+
+        // If a specific failure occured, rethrow that
+        if (authFailure != null)
+            throw authFailure;
+
+        // Otherwise, request standard username/password
+        throw new GuacamoleInvalidCredentialsException(
+            "Permission Denied.",
+            CredentialsInfo.USERNAME_PASSWORD
+        );
+
+    }
+
+    /**
+     * Re-authenticates the given AuthenticatedUser against the
+     * AuthenticationProvider that originally created it, using the given
+     * Credentials.
+     *
+     * @param authenticatedUser
+     *     The AuthenticatedUser to re-authenticate.
+     *
+     * @param credentials
+     *     The Credentials to use to re-authenticate the user.
+     *
+     * @return
+     *     A AuthenticatedUser which may have been updated due to re-
+     *     authentication.
+     *
+     * @throws GuacamoleException
+     *     If an error prevents the user from being re-authenticated.
+     */
+    private AuthenticatedUser updateAuthenticatedUser(AuthenticatedUser authenticatedUser,
+            Credentials credentials) throws GuacamoleException {
+
+        // Get original AuthenticationProvider
+        AuthenticationProvider authProvider = authenticatedUser.getAuthenticationProvider();
+
+        // Re-authenticate the AuthenticatedUser against the original AuthenticationProvider only
+        authenticatedUser = authProvider.updateAuthenticatedUser(authenticatedUser, credentials);
+        if (authenticatedUser == null)
+            throw new GuacamoleSecurityException("User re-authentication failed.");
+
+        return authenticatedUser;
+
+    }
+
+    /**
+     * Returns the AuthenticatedUser associated with the given session and
+     * credentials, performing a fresh authentication and creating a new
+     * AuthenticatedUser if necessary.
+     *
+     * @param existingSession
+     *     The current GuacamoleSession, or null if no session exists yet.
+     *
+     * @param credentials
+     *     The Credentials to use to authenticate the user.
+     *
+     * @return
+     *     The AuthenticatedUser associated with the given session and
+     *     credentials.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while authenticating or re-authenticating the
+     *     user.
+     */
+    private AuthenticatedUser getAuthenticatedUser(GuacamoleSession existingSession,
+            Credentials credentials) throws GuacamoleException {
+
+        try {
+
+            // Re-authenticate user if session exists
+            if (existingSession != null)
+                return updateAuthenticatedUser(existingSession.getAuthenticatedUser(), credentials);
+
+            // Otherwise, attempt authentication as a new user
+            AuthenticatedUser authenticatedUser = AuthenticationService.this.authenticateUser(credentials);
+            if (logger.isInfoEnabled())
+                logger.info("User \"{}\" successfully authenticated from {}.",
+                        authenticatedUser.getIdentifier(),
+                        getLoggableAddress(credentials.getRequest()));
+
+            return authenticatedUser;
+
+        }
+
+        // Log and rethrow any authentication errors
+        catch (GuacamoleException e) {
+
+            // Get request and username for sake of logging
+            HttpServletRequest request = credentials.getRequest();
+            String username = credentials.getUsername();
+
+            // Log authentication failures with associated usernames
+            if (username != null) {
+                if (logger.isWarnEnabled())
+                    logger.warn("Authentication attempt from {} for user \"{}\" failed.",
+                            getLoggableAddress(request), username);
+            }
+
+            // Log anonymous authentication failures
+            else if (logger.isDebugEnabled())
+                logger.debug("Anonymous authentication attempt from {} failed.",
+                        getLoggableAddress(request));
+
+            // Rethrow exception
+            throw e;
+
+        }
+
+    }
+
+    /**
+     * Returns all UserContexts associated with the given AuthenticatedUser,
+     * updating existing UserContexts, if any. If no UserContexts are yet
+     * associated with the given AuthenticatedUser, new UserContexts are
+     * generated by polling each available AuthenticationProvider.
+     *
+     * @param existingSession
+     *     The current GuacamoleSession, or null if no session exists yet.
+     *
+     * @param authenticatedUser
+     *     The AuthenticatedUser that has successfully authenticated or re-
+     *     authenticated.
+     *
+     * @return
+     *     A List of all UserContexts associated with the given
+     *     AuthenticatedUser.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while creating or updating any UserContext.
+     */
+    private List<UserContext> getUserContexts(GuacamoleSession existingSession,
+            AuthenticatedUser authenticatedUser) throws GuacamoleException {
+
+        List<UserContext> userContexts = new ArrayList<UserContext>(authProviders.size());
+
+        // If UserContexts already exist, update them and add to the list
+        if (existingSession != null) {
+
+            // Update all old user contexts
+            List<UserContext> oldUserContexts = existingSession.getUserContexts();
+            for (UserContext oldUserContext : oldUserContexts) {
+
+                // Update existing UserContext
+                AuthenticationProvider authProvider = oldUserContext.getAuthenticationProvider();
+                UserContext userContext = authProvider.updateUserContext(oldUserContext, authenticatedUser);
+
+                // Add to available data, if successful
+                if (userContext != null)
+                    userContexts.add(userContext);
+
+                // If unsuccessful, log that this happened, as it may be a bug
+                else
+                    logger.debug("AuthenticationProvider \"{}\" retroactively destroyed its UserContext.",
+                            authProvider.getClass().getName());
+
+            }
+
+        }
+
+        // Otherwise, create new UserContexts from available AuthenticationProviders
+        else {
+
+            // Get UserContexts from each available AuthenticationProvider
+            for (AuthenticationProvider authProvider : authProviders) {
+
+                // Generate new UserContext
+                UserContext userContext = authProvider.getUserContext(authenticatedUser);
+
+                // Add to available data, if successful
+                if (userContext != null)
+                    userContexts.add(userContext);
+
+            }
+
+        }
+
+        return userContexts;
+
+    }
+
+    /**
+     * Authenticates a user using the given credentials and optional
+     * authentication token, returning the authentication token associated with
+     * the user's Guacamole session, which may be newly generated. If an
+     * existing token is provided, the authentication procedure will attempt to
+     * update or reuse the provided token, but it is possible that a new token
+     * will be returned. Note that this function CANNOT return null.
+     *
+     * @param credentials
+     *     The credentials to use when authenticating the user.
+     *
+     * @param token
+     *     The authentication token to use if attempting to re-authenticate an
+     *     existing session, or null to request a new token.
+     *
+     * @return
+     *     The authentication token associated with the newly created or
+     *     existing session.
+     *
+     * @throws GuacamoleException
+     *     If the authentication or re-authentication attempt fails.
+     */
+    public String authenticate(Credentials credentials, String token)
+        throws GuacamoleException {
+
+        // Pull existing session if token provided
+        GuacamoleSession existingSession;
+        if (token != null)
+            existingSession = tokenSessionMap.get(token);
+        else
+            existingSession = null;
+
+        // Get up-to-date AuthenticatedUser and associated UserContexts
+        AuthenticatedUser authenticatedUser = getAuthenticatedUser(existingSession, credentials);
+        List<UserContext> userContexts = getUserContexts(existingSession, authenticatedUser);
+
+        // Update existing session, if it exists
+        String authToken;
+        if (existingSession != null) {
+            authToken = token;
+            existingSession.setAuthenticatedUser(authenticatedUser);
+            existingSession.setUserContexts(userContexts);
+        }
+
+        // If no existing session, generate a new token/session pair
+        else {
+            authToken = authTokenGenerator.getToken();
+            tokenSessionMap.put(authToken, new GuacamoleSession(environment, authenticatedUser, userContexts));
+            logger.debug("Login was successful for user \"{}\".", authenticatedUser.getIdentifier());
+        }
+
+        return authToken;
+
+    }
+
+    /**
+     * Finds the Guacamole session for a given auth token, if the auth token
+     * represents a currently logged in user. Throws an unauthorized error
+     * otherwise.
+     *
+     * @param authToken The auth token to check against the map of logged in users.
+     * @return The session that corresponds to the provided auth token.
+     * @throws GuacamoleException If the auth token does not correspond to any
+     *                            logged in user.
+     */
+    public GuacamoleSession getGuacamoleSession(String authToken) 
+            throws GuacamoleException {
+        
+        // Try to get the session from the map of logged in users.
+        GuacamoleSession session = tokenSessionMap.get(authToken);
+       
+        // Authentication failed.
+        if (session == null)
+            throw new GuacamoleUnauthorizedException("Permission Denied.");
+        
+        return session;
+
+    }
+
+    /**
+     * Invalidates a specific authentication token and its corresponding
+     * Guacamole session, effectively logging out the associated user. If the
+     * authentication token is not valid, this function has no effect.
+     *
+     * @param authToken
+     *     The token being invalidated.
+     *
+     * @return
+     *     true if the given authentication token was valid and the
+     *     corresponding Guacamole session was destroyed, false if the given
+     *     authentication token was not valid and no action was taken.
+     */
+    public boolean destroyGuacamoleSession(String authToken) {
+
+        // Remove corresponding GuacamoleSession if the token is valid
+        GuacamoleSession session = tokenSessionMap.remove(authToken);
+        if (session == null)
+            return false;
+
+        // Invalidate the removed session
+        session.invalidate();
+        return true;
+
+    }
+
+    /**
+     * Returns all UserContexts associated with a given auth token, if the auth
+     * token represents a currently logged in user. Throws an unauthorized
+     * error otherwise.
+     *
+     * @param authToken
+     *     The auth token to check against the map of logged in users.
+     *
+     * @return
+     *     A List of all UserContexts associated with the provided auth token.
+     *
+     * @throws GuacamoleException
+     *     If the auth token does not correspond to any logged in user.
+     */
+    public List<UserContext> getUserContexts(String authToken)
+            throws GuacamoleException {
+        return getGuacamoleSession(authToken).getUserContexts();
+    }
+
+}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/auth/BasicTokenSessionMap.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/auth/BasicTokenSessionMap.java
new file mode 100644
index 0000000..c92f6a7
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/auth/BasicTokenSessionMap.java
@@ -0,0 +1,189 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.basic.rest.auth;
+
+import java.util.Iterator;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.environment.Environment;
+import org.glyptodon.guacamole.net.basic.GuacamoleSession;
+import org.glyptodon.guacamole.net.basic.properties.BasicGuacamoleProperties;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * A basic, HashMap-based implementation of the TokenSessionMap with support
+ * for session timeouts.
+ * 
+ * @author James Muehlner
+ */
+public class BasicTokenSessionMap implements TokenSessionMap {
+
+    /**
+     * Logger for this class.
+     */
+    private static final Logger logger = LoggerFactory.getLogger(BasicTokenSessionMap.class);
+
+    /**
+     * Executor service which runs the period session eviction task.
+     */
+    private final ScheduledExecutorService executor = Executors.newScheduledThreadPool(1);
+    
+    /**
+     * Keeps track of the authToken to GuacamoleSession mapping.
+     */
+    private final ConcurrentMap<String, GuacamoleSession> sessionMap =
+            new ConcurrentHashMap<String, GuacamoleSession>();
+
+    /**
+     * Create a new BasicTokenGuacamoleSessionMap configured using the given
+     * environment.
+     *
+     * @param environment
+     *     The environment to use when configuring the token session map.
+     */
+    public BasicTokenSessionMap(Environment environment) {
+        
+        int sessionTimeoutValue;
+
+        // Read session timeout from guacamole.properties
+        try {
+            sessionTimeoutValue = environment.getProperty(BasicGuacamoleProperties.API_SESSION_TIMEOUT, 60);
+        }
+        catch (GuacamoleException e) {
+            logger.error("Unable to read guacamole.properties: {}", e.getMessage());
+            logger.debug("Error while reading session timeout value.", e);
+            sessionTimeoutValue = 60;
+        }
+        
+        // Check for expired sessions every minute
+        logger.info("Sessions will expire after {} minutes of inactivity.", sessionTimeoutValue);
+        executor.scheduleAtFixedRate(new SessionEvictionTask(sessionTimeoutValue * 60000l), 1, 1, TimeUnit.MINUTES);
+        
+    }
+
+    /**
+     * Task which iterates through all active sessions, evicting those sessions
+     * which are beyond the session timeout.
+     */
+    private class SessionEvictionTask implements Runnable {
+
+        /**
+         * The maximum allowed age of any session, in milliseconds.
+         */
+        private final long sessionTimeout;
+
+        /**
+         * Creates a new task which automatically evicts sessions which are
+         * older than the specified timeout.
+         * 
+         * @param sessionTimeout The maximum age of any session, in
+         *                       milliseconds.
+         */
+        public SessionEvictionTask(long sessionTimeout) {
+            this.sessionTimeout = sessionTimeout;
+        }
+        
+        @Override
+        public void run() {
+
+            // Get start time of session check time
+            long sessionCheckStart = System.currentTimeMillis();
+
+            logger.debug("Checking for expired sessions...");
+
+            // For each session, remove sesions which have expired
+            Iterator<Map.Entry<String, GuacamoleSession>> entries = sessionMap.entrySet().iterator();
+            while (entries.hasNext()) {
+
+                Map.Entry<String, GuacamoleSession> entry = entries.next();
+                GuacamoleSession session = entry.getValue();
+
+                // Do not expire sessions which are active
+                if (session.hasTunnels())
+                    continue;
+
+                // Get elapsed time since last access
+                long age = sessionCheckStart - session.getLastAccessedTime();
+
+                // If session is too old, evict it and check the next one
+                if (age >= sessionTimeout) {
+                    logger.debug("Session \"{}\" has timed out.", entry.getKey());
+                    entries.remove();
+                    session.invalidate();
+                }
+
+            }
+
+            // Log completion and duration
+            logger.debug("Session check completed in {} ms.",
+                    System.currentTimeMillis() - sessionCheckStart);
+            
+        }
+
+    }
+
+    @Override
+    public GuacamoleSession get(String authToken) {
+        
+        // There are no null auth tokens
+        if (authToken == null)
+            return null;
+
+        // Update the last access time and return the GuacamoleSession
+        GuacamoleSession session = sessionMap.get(authToken);
+        if (session != null)
+            session.access();
+
+        return session;
+
+    }
+
+    @Override
+    public void put(String authToken, GuacamoleSession session) {
+        sessionMap.put(authToken, session);
+    }
+
+    @Override
+    public GuacamoleSession remove(String authToken) {
+
+        // There are no null auth tokens
+        if (authToken == null)
+            return null;
+
+        // Attempt to retrieve only if non-null
+        return sessionMap.remove(authToken);
+
+    }
+
+    @Override
+    public void shutdown() {
+        executor.shutdownNow();
+    }
+
+}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/auth/SecureRandomAuthTokenGenerator.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/auth/SecureRandomAuthTokenGenerator.java
new file mode 100644
index 0000000..7fadd10
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/auth/SecureRandomAuthTokenGenerator.java
@@ -0,0 +1,48 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.basic.rest.auth;
+
+import java.security.SecureRandom;
+import org.apache.commons.codec.binary.Hex;
+
+/**
+ * An implementation of the AuthTokenGenerator based around SecureRandom.
+ * 
+ * @author James Muehlner
+ */
+public class SecureRandomAuthTokenGenerator implements AuthTokenGenerator {
+
+    /**
+     * Instance of SecureRandom for generating the auth token.
+     */
+    private final SecureRandom secureRandom = new SecureRandom();
+
+    @Override
+    public String getToken() {
+        byte[] bytes = new byte[32];
+        secureRandom.nextBytes(bytes);
+        
+        return Hex.encodeHexString(bytes);
+    }
+    
+}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/auth/TokenRESTService.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/auth/TokenRESTService.java
new file mode 100644
index 0000000..da64316
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/auth/TokenRESTService.java
@@ -0,0 +1,230 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.basic.rest.auth;
+
+import com.google.inject.Inject;
+import java.io.UnsupportedEncodingException;
+import java.util.ArrayList;
+import java.util.List;
+import javax.servlet.http.HttpServletRequest;
+import javax.ws.rs.DELETE;
+import javax.ws.rs.FormParam;
+import javax.ws.rs.POST;
+import javax.ws.rs.Path;
+import javax.ws.rs.PathParam;
+import javax.ws.rs.Produces;
+import javax.ws.rs.core.Context;
+import javax.ws.rs.core.MediaType;
+import javax.ws.rs.core.MultivaluedMap;
+import javax.xml.bind.DatatypeConverter;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.GuacamoleResourceNotFoundException;
+import org.glyptodon.guacamole.net.auth.AuthenticatedUser;
+import org.glyptodon.guacamole.net.auth.Credentials;
+import org.glyptodon.guacamole.net.auth.UserContext;
+import org.glyptodon.guacamole.net.basic.GuacamoleSession;
+import org.glyptodon.guacamole.net.basic.rest.APIRequest;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * A service for managing auth tokens via the Guacamole REST API.
+ * 
+ * @author James Muehlner
+ * @author Michael Jumper
+ */
+ at Path("/tokens")
+ at Produces(MediaType.APPLICATION_JSON)
+public class TokenRESTService {
+
+    /**
+     * Logger for this class.
+     */
+    private static final Logger logger = LoggerFactory.getLogger(TokenRESTService.class);
+
+    /**
+     * Service for authenticating users and managing their Guacamole sessions.
+     */
+    @Inject
+    private AuthenticationService authenticationService;
+
+    /**
+     * Returns the credentials associated with the given request, using the
+     * provided username and password.
+     *
+     * @param request
+     *     The request to use to derive the credentials.
+     *
+     * @param username
+     *     The username to associate with the credentials, or null if the
+     *     username should be derived from the request.
+     *
+     * @param password
+     *     The password to associate with the credentials, or null if the
+     *     password should be derived from the request.
+     *
+     * @return
+     *     A new Credentials object whose contents have been derived from the
+     *     given request, along with the provided username and password.
+     */
+    private Credentials getCredentials(HttpServletRequest request,
+            String username, String password) {
+
+        // If no username/password given, try Authorization header
+        if (username == null && password == null) {
+
+            String authorization = request.getHeader("Authorization");
+            if (authorization != null && authorization.startsWith("Basic ")) {
+
+                try {
+
+                    // Decode base64 authorization
+                    String basicBase64 = authorization.substring(6);
+                    String basicCredentials = new String(DatatypeConverter.parseBase64Binary(basicBase64), "UTF-8");
+
+                    // Pull username/password from auth data
+                    int colon = basicCredentials.indexOf(':');
+                    if (colon != -1) {
+                        username = basicCredentials.substring(0, colon);
+                        password = basicCredentials.substring(colon + 1);
+                    }
+                    else
+                        logger.debug("Invalid HTTP Basic \"Authorization\" header received.");
+
+                }
+
+                // UTF-8 support is required by the Java specification
+                catch (UnsupportedEncodingException e) {
+                    throw new UnsupportedOperationException("Unexpected lack of UTF-8 support.", e);
+                }
+
+            }
+
+        } // end Authorization header fallback
+
+        // Build credentials
+        Credentials credentials = new Credentials();
+        credentials.setUsername(username);
+        credentials.setPassword(password);
+        credentials.setRequest(request);
+        credentials.setSession(request.getSession(true));
+
+        return credentials;
+
+    }
+
+    /**
+     * Authenticates a user, generates an auth token, associates that auth token
+     * with the user's UserContext for use by further requests. If an existing
+     * token is provided, the authentication procedure will attempt to update
+     * or reuse the provided token.
+     *
+     * @param username
+     *     The username of the user who is to be authenticated.
+     *
+     * @param password
+     *     The password of the user who is to be authenticated.
+     *
+     * @param token
+     *     An optional existing auth token for the user who is to be
+     *     authenticated.
+     *
+     * @param consumedRequest
+     *     The HttpServletRequest associated with the login attempt. The
+     *     parameters of this request may not be accessible, as the request may
+     *     have been fully consumed by JAX-RS.
+     *
+     * @param parameters
+     *     A MultivaluedMap containing all parameters from the given HTTP
+     *     request. All request parameters must be made available through this
+     *     map, even if those parameters are no longer accessible within the
+     *     now-fully-consumed HTTP request.
+     *
+     * @return
+     *     An authentication response object containing the possible-new auth
+     *     token, as well as other related data.
+     *
+     * @throws GuacamoleException
+     *     If an error prevents successful authentication.
+     */
+    @POST
+    public APIAuthenticationResult createToken(@FormParam("username") String username,
+            @FormParam("password") String password,
+            @FormParam("token") String token,
+            @Context HttpServletRequest consumedRequest,
+            MultivaluedMap<String, String> parameters)
+            throws GuacamoleException {
+
+        // Reconstitute the HTTP request with the map of parameters
+        HttpServletRequest request = new APIRequest(consumedRequest, parameters);
+
+        // Build credentials from request
+        Credentials credentials = getCredentials(request, username, password);
+
+        // Create/update session producing possibly-new token
+        token = authenticationService.authenticate(credentials, token);
+
+        // Pull corresponding session
+        GuacamoleSession session = authenticationService.getGuacamoleSession(token);
+        if (session == null)
+            throw new GuacamoleResourceNotFoundException("No such token.");
+
+        // Build list of all available auth providers
+        List<UserContext> userContexts = session.getUserContexts();
+        List<String> authProviderIdentifiers = new ArrayList<String>(userContexts.size());
+        for (UserContext userContext : userContexts)
+            authProviderIdentifiers.add(userContext.getAuthenticationProvider().getIdentifier());
+
+        // Return possibly-new auth token
+        AuthenticatedUser authenticatedUser = session.getAuthenticatedUser();
+        return new APIAuthenticationResult(
+            token,
+            authenticatedUser.getIdentifier(),
+            authenticatedUser.getAuthenticationProvider().getIdentifier(),
+            authProviderIdentifiers
+        );
+
+    }
+
+    /**
+     * Invalidates a specific auth token, effectively logging out the associated
+     * user.
+     * 
+     * @param authToken
+     *     The token being invalidated.
+     *
+     * @throws GuacamoleException
+     *     If the specified token does not exist.
+     */
+    @DELETE
+    @Path("/{token}")
+    public void invalidateToken(@PathParam("token") String authToken)
+            throws GuacamoleException {
+
+        // Invalidate session, if it exists
+        if (!authenticationService.destroyGuacamoleSession(authToken))
+            throw new GuacamoleResourceNotFoundException("No such token.");
+
+    }
+
+}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/auth/TokenSessionMap.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/auth/TokenSessionMap.java
new file mode 100644
index 0000000..3509886
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/auth/TokenSessionMap.java
@@ -0,0 +1,69 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.basic.rest.auth;
+
+import org.glyptodon.guacamole.net.basic.GuacamoleSession;
+
+/**
+ * Represents a mapping of auth token to Guacamole session for the REST 
+ * authentication system.
+ * 
+ * @author James Muehlner
+ */
+public interface TokenSessionMap {
+    
+    /**
+     * Registers that a user has just logged in with the specified authToken and
+     * GuacamoleSession.
+     * 
+     * @param authToken The authentication token for the logged in user.
+     * @param session The GuacamoleSession for the logged in user.
+     */
+    public void put(String authToken, GuacamoleSession session);
+    
+    /**
+     * Get the GuacamoleSession for a logged in user. If the auth token does not
+     * represent a user who is currently logged in, returns null. 
+     * 
+     * @param authToken The authentication token for the logged in user.
+     * @return The GuacamoleSession for the given auth token, if the auth token
+     *         represents a currently logged in user, null otherwise.
+     */
+    public GuacamoleSession get(String authToken);
+
+    /**
+     * Removes the GuacamoleSession associated with the given auth token.
+     *
+     * @param authToken The token to remove.
+     * @return The GuacamoleSession for the given auth token, if the auth token
+     *         represents a currently logged in user, null otherwise.
+     */
+    public GuacamoleSession remove(String authToken);
+    
+    /**
+     * Shuts down this session map, disallowing future sessions and reclaiming
+     * any resources.
+     */
+    public void shutdown();
+    
+}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/auth/package-info.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/auth/package-info.java
new file mode 100644
index 0000000..ece55a3
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/auth/package-info.java
@@ -0,0 +1,27 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Classes related to the authentication aspect of the Guacamole REST API.
+ */
+package org.glyptodon.guacamole.net.basic.rest.auth;
+
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/connection/APIConnection.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/connection/APIConnection.java
new file mode 100644
index 0000000..628ee1c
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/connection/APIConnection.java
@@ -0,0 +1,233 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.basic.rest.connection;
+
+import java.util.Map;
+import org.codehaus.jackson.annotate.JsonIgnoreProperties;
+import org.codehaus.jackson.map.annotate.JsonSerialize;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.net.auth.Connection;
+import org.glyptodon.guacamole.protocol.GuacamoleConfiguration;
+
+/**
+ * A simple connection to expose through the REST endpoints.
+ * 
+ * @author James Muehlner
+ */
+ at JsonIgnoreProperties(ignoreUnknown = true)
+ at JsonSerialize(include=JsonSerialize.Inclusion.NON_NULL)
+public class APIConnection {
+
+    /**
+     * The name of this connection.
+     */
+    private String name;
+    
+    /**
+     * The identifier of this connection.
+     */
+    private String identifier;
+    
+    /**
+     * The identifier of the parent connection group for this connection.
+     */
+    private String parentIdentifier;
+
+    /**
+     * The protocol of this connection.
+     */
+    private String protocol;
+    
+    /**
+     * Map of all associated parameter values, indexed by parameter name.
+     */
+    private Map<String, String> parameters;
+    
+    /**
+     * Map of all associated attributes by attribute identifier.
+     */
+    private Map<String, String> attributes;
+
+    /**
+     * The count of currently active connections using this connection.
+     */
+    private int activeConnections;
+    
+    /**
+     * Create an empty APIConnection.
+     */
+    public APIConnection() {}
+    
+    /**
+     * Create an APIConnection from a Connection record. Parameters for the
+     * connection will not be included.
+     *
+     * @param connection The connection to create this APIConnection from.
+     * @throws GuacamoleException If a problem is encountered while
+     *                            instantiating this new APIConnection.
+     */
+    public APIConnection(Connection connection) 
+            throws GuacamoleException {
+
+        // Set connection information
+        this.name = connection.getName();
+        this.identifier = connection.getIdentifier();
+        this.parentIdentifier = connection.getParentIdentifier();
+        this.activeConnections = connection.getActiveConnections();
+        
+        // Set protocol from configuration
+        GuacamoleConfiguration configuration = connection.getConfiguration();
+        this.protocol = configuration.getProtocol();
+
+        // Associate any attributes
+        this.attributes = connection.getAttributes();
+
+    }
+
+    /**
+     * Returns the name of this connection.
+     * @return The name of this connection.
+     */
+    public String getName() {
+        return name;
+    }
+
+    /**
+     * Set the name of this connection.
+     * @param name The name of this connection.
+     */
+    public void setName(String name) {
+        this.name = name;
+    }
+    
+    /**
+     * Returns the unique identifier for this connection.
+     * @return The unique identifier for this connection.
+     */
+    public String getIdentifier() {
+        return identifier;
+    }
+
+    /**
+     * Sets the unique identifier for this connection.
+     * @param identifier The unique identifier for this connection.
+     */
+    public void setIdentifier(String identifier) {
+        this.identifier = identifier;
+    }
+    
+    /**
+     * Returns the unique identifier for this connection.
+     * @return The unique identifier for this connection.
+     */
+    public String getParentIdentifier() {
+        return parentIdentifier;
+    }
+
+    /**
+     * Sets the parent connection group identifier for this connection.
+     * @param parentIdentifier The parent connection group identifier 
+     *                         for this connection.
+     */
+    public void setParentIdentifier(String parentIdentifier) {
+        this.parentIdentifier = parentIdentifier;
+    }
+
+    /**
+     * Returns the parameter map for this connection.
+     * @return The parameter map for this connection.
+     */
+    public Map<String, String> getParameters() {
+        return parameters;
+    }
+
+    /**
+     * Sets the parameter map for this connection.
+     * @param parameters The parameter map for this connection.
+     */
+    public void setParameters(Map<String, String> parameters) {
+        this.parameters = parameters;
+    }
+
+    /**
+     * Returns the protocol for this connection.
+     * @return The protocol for this connection.
+     */
+    public String getProtocol() {
+        return protocol;
+    }
+
+    /**
+     * Sets the protocol for this connection.
+     * @param protocol protocol for this connection.
+     */
+    public void setProtocol(String protocol) {
+        this.protocol = protocol;
+    }
+
+    /**
+     * Returns the number of currently active connections using this
+     * connection.
+     *
+     * @return
+     *     The number of currently active usages of this connection.
+     */
+    public int getActiveConnections() {
+        return activeConnections;
+    }
+
+    /**
+     * Set the number of currently active connections using this connection.
+     *
+     * @param activeConnections
+     *     The number of currently active usages of this connection.
+     */
+    public void setActiveUsers(int activeConnections) {
+        this.activeConnections = activeConnections;
+    }
+
+    /**
+     * Returns a map of all attributes associated with this connection. Each
+     * entry key is the attribute identifier, while each value is the attribute
+     * value itself.
+     *
+     * @return
+     *     The attribute map for this connection.
+     */
+    public Map<String, String> getAttributes() {
+        return attributes;
+    }
+
+    /**
+     * Sets the map of all attributes associated with this connection. Each
+     * entry key is the attribute identifier, while each value is the attribute
+     * value itself.
+     *
+     * @param attributes
+     *     The attribute map for this connection.
+     */
+    public void setAttributes(Map<String, String> attributes) {
+        this.attributes = attributes;
+    }
+
+}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/connection/APIConnectionWrapper.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/connection/APIConnectionWrapper.java
new file mode 100644
index 0000000..ec0b3c7
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/connection/APIConnectionWrapper.java
@@ -0,0 +1,138 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.basic.rest.connection;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.net.GuacamoleTunnel;
+import org.glyptodon.guacamole.net.auth.Connection;
+import org.glyptodon.guacamole.net.auth.ConnectionRecord;
+import org.glyptodon.guacamole.protocol.GuacamoleClientInformation;
+import org.glyptodon.guacamole.protocol.GuacamoleConfiguration;
+
+/**
+ * A wrapper to make an APIConnection look like a Connection. Useful where a
+ * org.glyptodon.guacamole.net.auth.Connection is required.
+ * 
+ * @author James Muehlner
+ */
+public class APIConnectionWrapper implements Connection {
+
+    /**
+     * The wrapped APIConnection.
+     */
+    private final APIConnection apiConnection;
+
+    /**
+     * Creates a new APIConnectionWrapper which wraps the given APIConnection
+     * as a Connection.
+     *
+     * @param apiConnection
+     *     The APIConnection to wrap.
+     */
+    public APIConnectionWrapper(APIConnection apiConnection) {
+        this.apiConnection = apiConnection;
+    }
+
+    @Override
+    public String getName() {
+        return apiConnection.getName();
+    }
+
+    @Override
+    public void setName(String name) {
+        apiConnection.setName(name);
+    }
+
+    @Override
+    public String getIdentifier() {
+        return apiConnection.getIdentifier();
+    }
+
+    @Override
+    public void setIdentifier(String identifier) {
+        apiConnection.setIdentifier(identifier);
+    }
+
+    @Override
+    public String getParentIdentifier() {
+        return apiConnection.getParentIdentifier();
+    }
+
+    @Override
+    public void setParentIdentifier(String parentIdentifier) {
+        apiConnection.setParentIdentifier(parentIdentifier);
+    }
+
+    @Override
+    public int getActiveConnections() {
+        return apiConnection.getActiveConnections();
+    }
+
+    @Override
+    public GuacamoleConfiguration getConfiguration() {
+        
+        // Create the GuacamoleConfiguration with current protocol
+        GuacamoleConfiguration configuration = new GuacamoleConfiguration();
+        configuration.setProtocol(apiConnection.getProtocol());
+
+        // Add parameters, if available
+        Map<String, String> parameters = apiConnection.getParameters();
+        if (parameters != null)
+            configuration.setParameters(parameters);
+        
+        return configuration;
+    }
+
+    @Override
+    public void setConfiguration(GuacamoleConfiguration config) {
+        
+        // Set protocol and parameters
+        apiConnection.setProtocol(config.getProtocol());
+        apiConnection.setParameters(config.getParameters());
+
+    }
+
+    @Override
+    public Map<String, String> getAttributes() {
+        return apiConnection.getAttributes();
+    }
+
+    @Override
+    public void setAttributes(Map<String, String> attributes) {
+        apiConnection.setAttributes(attributes);
+    }
+
+    @Override
+    public GuacamoleTunnel connect(GuacamoleClientInformation info) throws GuacamoleException {
+        throw new UnsupportedOperationException("Operation not supported.");
+    }
+
+    @Override
+    public List<? extends ConnectionRecord> getHistory() throws GuacamoleException {
+        return Collections.<ConnectionRecord>emptyList();
+    }
+    
+}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/connection/ConnectionRESTService.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/connection/ConnectionRESTService.java
new file mode 100644
index 0000000..fad3c4e
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/connection/ConnectionRESTService.java
@@ -0,0 +1,349 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.basic.rest.connection;
+
+import com.google.inject.Inject;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import javax.ws.rs.Consumes;
+import javax.ws.rs.DELETE;
+import javax.ws.rs.GET;
+import javax.ws.rs.POST;
+import javax.ws.rs.PUT;
+import javax.ws.rs.Path;
+import javax.ws.rs.PathParam;
+import javax.ws.rs.Produces;
+import javax.ws.rs.QueryParam;
+import javax.ws.rs.core.MediaType;
+import org.glyptodon.guacamole.GuacamoleClientException;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.GuacamoleSecurityException;
+import org.glyptodon.guacamole.net.auth.Connection;
+import org.glyptodon.guacamole.net.auth.ConnectionRecord;
+import org.glyptodon.guacamole.net.auth.Directory;
+import org.glyptodon.guacamole.net.auth.User;
+import org.glyptodon.guacamole.net.auth.UserContext;
+import org.glyptodon.guacamole.net.auth.permission.ObjectPermission;
+import org.glyptodon.guacamole.net.auth.permission.ObjectPermissionSet;
+import org.glyptodon.guacamole.net.auth.permission.SystemPermission;
+import org.glyptodon.guacamole.net.auth.permission.SystemPermissionSet;
+import org.glyptodon.guacamole.net.basic.GuacamoleSession;
+import org.glyptodon.guacamole.net.basic.rest.ObjectRetrievalService;
+import org.glyptodon.guacamole.net.basic.rest.auth.AuthenticationService;
+import org.glyptodon.guacamole.net.basic.rest.history.APIConnectionRecord;
+import org.glyptodon.guacamole.protocol.GuacamoleConfiguration;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * A REST Service for handling connection CRUD operations.
+ * 
+ * @author James Muehlner
+ */
+ at Path("/data/{dataSource}/connections")
+ at Produces(MediaType.APPLICATION_JSON)
+ at Consumes(MediaType.APPLICATION_JSON)
+public class ConnectionRESTService {
+
+    /**
+     * Logger for this class.
+     */
+    private static final Logger logger = LoggerFactory.getLogger(ConnectionRESTService.class);
+
+    /**
+     * A service for authenticating users from auth tokens.
+     */
+    @Inject
+    private AuthenticationService authenticationService;
+    
+    /**
+     * Service for convenient retrieval of objects.
+     */
+    @Inject
+    private ObjectRetrievalService retrievalService;
+    
+    /**
+     * Retrieves an individual connection.
+     * 
+     * @param authToken
+     *     The authentication token that is used to authenticate the user
+     *     performing the operation.
+     *
+     * @param authProviderIdentifier
+     *     The unique identifier of the AuthenticationProvider associated with
+     *     the UserContext containing the connection to be retrieved.
+     *
+     * @param connectionID
+     *     The identifier of the connection to retrieve.
+     *
+     * @return
+     *     The connection having the given identifier.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while retrieving the connection.
+     */
+    @GET
+    @Path("/{connectionID}")
+    public APIConnection getConnection(@QueryParam("token") String authToken, 
+            @PathParam("dataSource") String authProviderIdentifier,
+            @PathParam("connectionID") String connectionID)
+            throws GuacamoleException {
+
+        GuacamoleSession session = authenticationService.getGuacamoleSession(authToken);
+        
+        // Retrieve the requested connection
+        return new APIConnection(retrievalService.retrieveConnection(session, authProviderIdentifier, connectionID));
+
+    }
+
+    /**
+     * Retrieves the parameters associated with a single connection.
+     * 
+     * @param authToken
+     *     The authentication token that is used to authenticate the user
+     *     performing the operation.
+     *
+     * @param authProviderIdentifier
+     *     The unique identifier of the AuthenticationProvider associated with
+     *     the UserContext containing the connection whose parameters are to be
+     *     retrieved.
+     *
+     * @param connectionID
+     *     The identifier of the connection.
+     *
+     * @return
+     *     A map of parameter name/value pairs.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while retrieving the connection parameters.
+     */
+    @GET
+    @Path("/{connectionID}/parameters")
+    public Map<String, String> getConnectionParameters(@QueryParam("token") String authToken, 
+            @PathParam("dataSource") String authProviderIdentifier,
+            @PathParam("connectionID") String connectionID)
+            throws GuacamoleException {
+
+        GuacamoleSession session = authenticationService.getGuacamoleSession(authToken);
+        UserContext userContext = retrievalService.retrieveUserContext(session, authProviderIdentifier);
+        User self = userContext.self();
+
+        // Retrieve permission sets
+        SystemPermissionSet systemPermissions = self.getSystemPermissions();
+        ObjectPermissionSet connectionPermissions = self.getConnectionPermissions();
+
+        // Deny access if adminstrative or update permission is missing
+        if (!systemPermissions.hasPermission(SystemPermission.Type.ADMINISTER)
+         && !connectionPermissions.hasPermission(ObjectPermission.Type.UPDATE, connectionID))
+            throw new GuacamoleSecurityException("Permission to read connection parameters denied.");
+
+        // Retrieve the requested connection
+        Connection connection = retrievalService.retrieveConnection(userContext, connectionID);
+
+        // Retrieve connection configuration
+        GuacamoleConfiguration config = connection.getConfiguration();
+
+        // Return parameter map
+        return config.getParameters();
+
+    }
+
+    /**
+     * Retrieves the usage history of a single connection.
+     * 
+     * @param authToken
+     *     The authentication token that is used to authenticate the user
+     *     performing the operation.
+     *
+     * @param authProviderIdentifier
+     *     The unique identifier of the AuthenticationProvider associated with
+     *     the UserContext containing the connection whose history is to be
+     *     retrieved.
+     *
+     * @param connectionID
+     *     The identifier of the connection.
+     *
+     * @return
+     *     A list of connection records, describing the start and end times of
+     *     various usages of this connection.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while retrieving the connection history.
+     */
+    @GET
+    @Path("/{connectionID}/history")
+    public List<APIConnectionRecord> getConnectionHistory(@QueryParam("token") String authToken, 
+            @PathParam("dataSource") String authProviderIdentifier,
+            @PathParam("connectionID") String connectionID)
+            throws GuacamoleException {
+
+        GuacamoleSession session = authenticationService.getGuacamoleSession(authToken);
+
+        // Retrieve the requested connection
+        Connection connection = retrievalService.retrieveConnection(session, authProviderIdentifier, connectionID);
+
+        // Retrieve the requested connection's history
+        List<APIConnectionRecord> apiRecords = new ArrayList<APIConnectionRecord>();
+        for (ConnectionRecord record : connection.getHistory())
+            apiRecords.add(new APIConnectionRecord(record));
+
+        // Return the converted history
+        return apiRecords;
+
+    }
+
+    /**
+     * Deletes an individual connection.
+     * 
+     * @param authToken
+     *     The authentication token that is used to authenticate the user
+     *     performing the operation.
+     *
+     * @param authProviderIdentifier
+     *     The unique identifier of the AuthenticationProvider associated with
+     *     the UserContext containing the connection to be deleted.
+     *
+     * @param connectionID
+     *     The identifier of the connection to delete.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while deleting the connection.
+     */
+    @DELETE
+    @Path("/{connectionID}")
+    public void deleteConnection(@QueryParam("token") String authToken,
+            @PathParam("dataSource") String authProviderIdentifier,
+            @PathParam("connectionID") String connectionID)
+            throws GuacamoleException {
+
+        GuacamoleSession session = authenticationService.getGuacamoleSession(authToken);
+        UserContext userContext = retrievalService.retrieveUserContext(session, authProviderIdentifier);
+
+        // Get the connection directory
+        Directory<Connection> connectionDirectory = userContext.getConnectionDirectory();
+
+        // Delete the specified connection
+        connectionDirectory.remove(connectionID);
+
+    }
+
+    /**
+     * Creates a new connection and returns the new connection, with identifier
+     * field populated.
+     * 
+     * @param authToken
+     *     The authentication token that is used to authenticate the user
+     *     performing the operation.
+     *
+     * @param authProviderIdentifier
+     *     The unique identifier of the AuthenticationProvider associated with
+     *     the UserContext in which the connection is to be created.
+     *
+     * @param connection
+     *     The connection to create.
+     *
+     * @return
+     *     The new connection.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while creating the connection.
+     */
+    @POST
+    public APIConnection createConnection(@QueryParam("token") String authToken,
+            @PathParam("dataSource") String authProviderIdentifier,
+            APIConnection connection) throws GuacamoleException {
+
+        GuacamoleSession session = authenticationService.getGuacamoleSession(authToken);
+        UserContext userContext = retrievalService.retrieveUserContext(session, authProviderIdentifier);
+        
+        // Validate that connection data was provided
+        if (connection == null)
+            throw new GuacamoleClientException("Connection JSON must be submitted when creating connections.");
+
+        // Add the new connection
+        Directory<Connection> connectionDirectory = userContext.getConnectionDirectory();
+        connectionDirectory.add(new APIConnectionWrapper(connection));
+
+        // Return the new connection
+        return connection;
+
+    }
+  
+    /**
+     * Updates an existing connection. If the parent identifier of the
+     * connection is changed, the connection will also be moved to the new
+     * parent group.
+     * 
+     * @param authToken
+     *     The authentication token that is used to authenticate the user
+     *     performing the operation.
+     *
+     * @param authProviderIdentifier
+     *     The unique identifier of the AuthenticationProvider associated with
+     *     the UserContext containing the connection to be updated.
+     *
+     * @param connectionID
+     *     The identifier of the connection to update.
+     *
+     * @param connection
+     *     The connection data to update the specified connection with.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while updating the connection.
+     */
+    @PUT
+    @Path("/{connectionID}")
+    public void updateConnection(@QueryParam("token") String authToken, 
+            @PathParam("dataSource") String authProviderIdentifier,
+            @PathParam("connectionID") String connectionID,
+            APIConnection connection) throws GuacamoleException {
+
+        GuacamoleSession session = authenticationService.getGuacamoleSession(authToken);
+        UserContext userContext = retrievalService.retrieveUserContext(session, authProviderIdentifier);
+        
+        // Validate that connection data was provided
+        if (connection == null)
+            throw new GuacamoleClientException("Connection JSON must be submitted when updating connections.");
+
+        // Get the connection directory
+        Directory<Connection> connectionDirectory = userContext.getConnectionDirectory();
+        
+        // Retrieve connection to update
+        Connection existingConnection = retrievalService.retrieveConnection(userContext, connectionID);
+
+        // Build updated configuration
+        GuacamoleConfiguration config = new GuacamoleConfiguration();
+        config.setProtocol(connection.getProtocol());
+        config.setParameters(connection.getParameters());
+
+        // Update the connection
+        existingConnection.setConfiguration(config);
+        existingConnection.setParentIdentifier(connection.getParentIdentifier());
+        existingConnection.setName(connection.getName());
+        existingConnection.setAttributes(connection.getAttributes());
+        connectionDirectory.update(existingConnection);
+
+    }
+    
+}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/connection/package-info.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/connection/package-info.java
new file mode 100644
index 0000000..3d55216
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/connection/package-info.java
@@ -0,0 +1,27 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Classes related to the connection manipulation aspect of the Guacamole REST API.
+ */
+package org.glyptodon.guacamole.net.basic.rest.connection;
+
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/connectiongroup/APIConnectionGroup.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/connectiongroup/APIConnectionGroup.java
new file mode 100644
index 0000000..82418b9
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/connectiongroup/APIConnectionGroup.java
@@ -0,0 +1,272 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.basic.rest.connectiongroup;
+
+import java.util.Collection;
+import java.util.Map;
+import org.codehaus.jackson.annotate.JsonIgnoreProperties;
+import org.codehaus.jackson.map.annotate.JsonSerialize;
+import org.glyptodon.guacamole.net.auth.ConnectionGroup;
+import org.glyptodon.guacamole.net.auth.ConnectionGroup.Type;
+import org.glyptodon.guacamole.net.basic.rest.connection.APIConnection;
+
+/**
+ * A simple connection group to expose through the REST endpoints.
+ * 
+ * @author James Muehlner
+ */
+ at JsonIgnoreProperties(ignoreUnknown = true)
+ at JsonSerialize(include=JsonSerialize.Inclusion.NON_NULL)
+public class APIConnectionGroup {
+
+    /**
+     * The identifier of the root connection group.
+     */
+    public static final String ROOT_IDENTIFIER = "ROOT";
+ 
+    /**
+     * The name of this connection group.
+     */
+    private String name;
+    
+    /**
+     * The identifier of this connection group.
+     */
+    private String identifier;
+    
+    /**
+     * The identifier of the parent connection group for this connection group.
+     */
+    private String parentIdentifier;
+    
+    /**
+     * The type of this connection group.
+     */
+    private Type type;
+
+    /**
+     * The count of currently active connections using this connection group.
+     */
+    private int activeConnections;
+
+    /**
+     * All child connection groups. If children are not being queried, this may
+     * be omitted.
+     */
+    private Collection<APIConnectionGroup> childConnectionGroups;
+
+    /**
+     * All child connections. If children are not being queried, this may be
+     * omitted.
+     */
+    private Collection<APIConnection> childConnections;
+    
+    /**
+     * Map of all associated attributes by attribute identifier.
+     */
+    private Map<String, String> attributes;
+
+    /**
+     * Create an empty APIConnectionGroup.
+     */
+    public APIConnectionGroup() {}
+    
+    /**
+     * Create a new APIConnectionGroup from the given ConnectionGroup record.
+     * 
+     * @param connectionGroup The ConnectionGroup record to initialize this 
+     *                        APIConnectionGroup from.
+     */
+    public APIConnectionGroup(ConnectionGroup connectionGroup) {
+
+        // Set connection group information
+        this.identifier = connectionGroup.getIdentifier();
+        this.parentIdentifier = connectionGroup.getParentIdentifier();
+        this.name = connectionGroup.getName();
+        this.type = connectionGroup.getType();
+        this.activeConnections = connectionGroup.getActiveConnections();
+
+        // Associate any attributes
+        this.attributes = connectionGroup.getAttributes();
+
+    }
+
+    /**
+     * Returns the name of this connection group.
+     * @return The name of this connection group.
+     */
+    public String getName() {
+        return name;
+    }
+
+    /**
+     * Set the name of this connection group.
+     * @param name The name of this connection group.
+     */
+    public void setName(String name) {
+        this.name = name;
+    }
+
+    /**
+     * Returns the identifier of this connection group.
+     * @return The identifier of this connection group.
+     */
+    public String getIdentifier() {
+        return identifier;
+    }
+
+    /**
+     * Set the identifier of this connection group.
+     * @param identifier The identifier of this connection group.
+     */
+    public void setIdentifier(String identifier) {
+        this.identifier = identifier;
+    }
+    
+    /**
+     * Returns the unique identifier for this connection group.
+     * @return The unique identifier for this connection group.
+     */
+    public String getParentIdentifier() {
+        return parentIdentifier;
+    }
+    /**
+     * Sets the parent connection group identifier for this connection group.
+     * @param parentIdentifier The parent connection group identifier 
+     *                         for this connection group.
+     */
+    public void setParentIdentifier(String parentIdentifier) {
+        this.parentIdentifier = parentIdentifier;
+    }
+
+    /**
+     * Returns the type of this connection group.
+     * @return The type of this connection group.
+     */
+    public Type getType() {
+        return type;
+    }
+
+    /**
+     * Set the type of this connection group.
+     * @param type The Type of this connection group.
+     */
+    public void setType(Type type) {
+        this.type = type;
+    }
+
+    /**
+     * Returns a collection of all child connection groups, or null if children
+     * have not been queried.
+     *
+     * @return
+     *     A collection of all child connection groups, or null if children
+     *     have not been queried.
+     */
+    public Collection<APIConnectionGroup> getChildConnectionGroups() {
+        return childConnectionGroups;
+    }
+
+    /**
+     * Sets the collection of all child connection groups to the given
+     * collection, which may be null if children have not been queried.
+     *
+     * @param childConnectionGroups
+     *     The collection containing all child connection groups of this
+     *     connection group, or null if children have not been queried.
+     */
+    public void setChildConnectionGroups(Collection<APIConnectionGroup> childConnectionGroups) {
+        this.childConnectionGroups = childConnectionGroups;
+    }
+
+    /**
+     * Returns a collection of all child connections, or null if children have
+     * not been queried.
+     *
+     * @return
+     *     A collection of all child connections, or null if children have not
+     *     been queried.
+     */
+    public Collection<APIConnection> getChildConnections() {
+        return childConnections;
+    }
+
+    /**
+     * Sets the collection of all child connections to the given collection,
+     * which may be null if children have not been queried.
+     *
+     * @param childConnections
+     *     The collection containing all child connections of this connection
+     *     group, or null if children have not been queried.
+     */
+    public void setChildConnections(Collection<APIConnection> childConnections) {
+        this.childConnections = childConnections;
+    }
+
+    /**
+     * Returns the number of currently active connections using this
+     * connection group.
+     *
+     * @return
+     *     The number of currently active usages of this connection group.
+     */
+    public int getActiveConnections() {
+        return activeConnections;
+    }
+
+    /**
+     * Set the number of currently active connections using this connection
+     * group.
+     *
+     * @param activeConnections
+     *     The number of currently active usages of this connection group.
+     */
+    public void setActiveUsers(int activeConnections) {
+        this.activeConnections = activeConnections;
+    }
+
+    /**
+     * Returns a map of all attributes associated with this connection group.
+     * Each entry key is the attribute identifier, while each value is the
+     * attribute value itself.
+     *
+     * @return
+     *     The attribute map for this connection group.
+     */
+    public Map<String, String> getAttributes() {
+        return attributes;
+    }
+
+    /**
+     * Sets the map of all attributes associated with this connection group.
+     * Each entry key is the attribute identifier, while each value is the
+     * attribute value itself.
+     *
+     * @param attributes
+     *     The attribute map for this connection group.
+     */
+    public void setAttributes(Map<String, String> attributes) {
+        this.attributes = attributes;
+    }
+
+}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/connectiongroup/APIConnectionGroupWrapper.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/connectiongroup/APIConnectionGroupWrapper.java
new file mode 100644
index 0000000..8d3dd26
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/connectiongroup/APIConnectionGroupWrapper.java
@@ -0,0 +1,124 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.basic.rest.connectiongroup;
+
+import java.util.Map;
+import java.util.Set;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.net.GuacamoleTunnel;
+import org.glyptodon.guacamole.net.auth.ConnectionGroup;
+import org.glyptodon.guacamole.protocol.GuacamoleClientInformation;
+
+/**
+ * A wrapper to make an APIConnection look like a ConnectionGroup.
+ * Useful where a org.glyptodon.guacamole.net.auth.ConnectionGroup is required.
+ * 
+ * @author James Muehlner
+ */
+public class APIConnectionGroupWrapper implements ConnectionGroup {
+
+    /**
+     * The wrapped APIConnectionGroup.
+     */
+    private final APIConnectionGroup apiConnectionGroup;
+    
+    /**
+     * Create a new APIConnectionGroupWrapper to wrap the given 
+     * APIConnectionGroup as a ConnectionGroup.
+     * @param apiConnectionGroup the APIConnectionGroup to wrap.
+     */
+    public APIConnectionGroupWrapper(APIConnectionGroup apiConnectionGroup) {
+        this.apiConnectionGroup = apiConnectionGroup;
+    }
+    
+    @Override
+    public String getName() {
+        return apiConnectionGroup.getName();
+    }
+
+    @Override
+    public void setName(String name) {
+        apiConnectionGroup.setName(name);
+    }
+
+    @Override
+    public String getIdentifier() {
+        return apiConnectionGroup.getIdentifier();
+    }
+
+    @Override
+    public void setIdentifier(String identifier) {
+        apiConnectionGroup.setIdentifier(identifier);
+    }
+
+    @Override
+    public String getParentIdentifier() {
+        return apiConnectionGroup.getParentIdentifier();
+    }
+
+    @Override
+    public void setParentIdentifier(String parentIdentifier) {
+        apiConnectionGroup.setParentIdentifier(parentIdentifier);
+    }
+
+    @Override
+    public void setType(Type type) {
+        apiConnectionGroup.setType(type);
+    }
+
+    @Override
+    public Type getType() {
+        return apiConnectionGroup.getType();
+    }
+
+    @Override
+    public int getActiveConnections() {
+        return apiConnectionGroup.getActiveConnections();
+    }
+
+    @Override
+    public Set<String> getConnectionIdentifiers() {
+        throw new UnsupportedOperationException("Operation not supported.");
+    }
+
+    @Override
+    public Set<String> getConnectionGroupIdentifiers() {
+        throw new UnsupportedOperationException("Operation not supported.");
+    }
+
+    @Override
+    public Map<String, String> getAttributes() {
+        return apiConnectionGroup.getAttributes();
+    }
+
+    @Override
+    public void setAttributes(Map<String, String> attributes) {
+        apiConnectionGroup.setAttributes(attributes);
+    }
+
+    @Override
+    public GuacamoleTunnel connect(GuacamoleClientInformation info) throws GuacamoleException {
+        throw new UnsupportedOperationException("Operation not supported.");
+    }
+
+}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/connectiongroup/ConnectionGroupRESTService.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/connectiongroup/ConnectionGroupRESTService.java
new file mode 100644
index 0000000..1a1bcad
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/connectiongroup/ConnectionGroupRESTService.java
@@ -0,0 +1,287 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.basic.rest.connectiongroup;
+
+import com.google.inject.Inject;
+import java.util.List;
+import javax.ws.rs.Consumes;
+import javax.ws.rs.DELETE;
+import javax.ws.rs.GET;
+import javax.ws.rs.POST;
+import javax.ws.rs.PUT;
+import javax.ws.rs.Path;
+import javax.ws.rs.PathParam;
+import javax.ws.rs.Produces;
+import javax.ws.rs.QueryParam;
+import javax.ws.rs.core.MediaType;
+import org.glyptodon.guacamole.GuacamoleClientException;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.net.auth.ConnectionGroup;
+import org.glyptodon.guacamole.net.auth.Directory;
+import org.glyptodon.guacamole.net.auth.UserContext;
+import org.glyptodon.guacamole.net.auth.permission.ObjectPermission;
+import org.glyptodon.guacamole.net.basic.GuacamoleSession;
+import org.glyptodon.guacamole.net.basic.rest.ObjectRetrievalService;
+import org.glyptodon.guacamole.net.basic.rest.auth.AuthenticationService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * A REST Service for handling connection group CRUD operations.
+ * 
+ * @author James Muehlner
+ */
+ at Path("/data/{dataSource}/connectionGroups")
+ at Produces(MediaType.APPLICATION_JSON)
+ at Consumes(MediaType.APPLICATION_JSON)
+public class ConnectionGroupRESTService {
+
+    /**
+     * Logger for this class.
+     */
+    private static final Logger logger = LoggerFactory.getLogger(ConnectionGroupRESTService.class);
+    
+    /**
+     * A service for authenticating users from auth tokens.
+     */
+    @Inject
+    private AuthenticationService authenticationService;
+    
+    /**
+     * Service for convenient retrieval of objects.
+     */
+    @Inject
+    private ObjectRetrievalService retrievalService;
+    
+    /**
+     * Gets an individual connection group.
+     * 
+     * @param authToken
+     *     The authentication token that is used to authenticate the user
+     *     performing the operation.
+     * 
+     * @param authProviderIdentifier
+     *     The unique identifier of the AuthenticationProvider associated with
+     *     the UserContext containing the connection group to be retrieved.
+     *
+     * @param connectionGroupID
+     *     The ID of the connection group to retrieve.
+     * 
+     * @return
+     *     The connection group, without any descendants.
+     *
+     * @throws GuacamoleException
+     *     If a problem is encountered while retrieving the connection group.
+     */
+    @GET
+    @Path("/{connectionGroupID}")
+    public APIConnectionGroup getConnectionGroup(@QueryParam("token") String authToken,
+            @PathParam("dataSource") String authProviderIdentifier,
+            @PathParam("connectionGroupID") String connectionGroupID)
+            throws GuacamoleException {
+
+        GuacamoleSession session = authenticationService.getGuacamoleSession(authToken);
+
+        // Retrieve the requested connection group
+        return new APIConnectionGroup(retrievalService.retrieveConnectionGroup(session, authProviderIdentifier, connectionGroupID));
+
+    }
+
+    /**
+     * Gets an individual connection group and all children.
+     * 
+     * @param authToken
+     *     The authentication token that is used to authenticate the user
+     *     performing the operation.
+     *
+     * @param authProviderIdentifier
+     *     The unique identifier of the AuthenticationProvider associated with
+     *     the UserContext containing the connection group to be retrieved.
+     *
+     * @param connectionGroupID
+     *     The ID of the connection group to retrieve.
+     *
+     * @param permissions
+     *     If specified and non-empty, limit the returned list to only those
+     *     connections for which the current user has any of the given
+     *     permissions. Otherwise, all visible connections are returned.
+     *     Connection groups are unaffected by this parameter.
+     * 
+     * @return
+     *     The requested connection group, including all descendants.
+     *
+     * @throws GuacamoleException
+     *     If a problem is encountered while retrieving the connection group or
+     *     its descendants.
+     */
+    @GET
+    @Path("/{connectionGroupID}/tree")
+    public APIConnectionGroup getConnectionGroupTree(@QueryParam("token") String authToken, 
+            @PathParam("dataSource") String authProviderIdentifier,
+            @PathParam("connectionGroupID") String connectionGroupID,
+            @QueryParam("permission") List<ObjectPermission.Type> permissions)
+            throws GuacamoleException {
+
+        GuacamoleSession session = authenticationService.getGuacamoleSession(authToken);
+        UserContext userContext = retrievalService.retrieveUserContext(session, authProviderIdentifier);
+
+        // Retrieve the requested tree, filtering by the given permissions
+        ConnectionGroup treeRoot = retrievalService.retrieveConnectionGroup(userContext, connectionGroupID);
+        ConnectionGroupTree tree = new ConnectionGroupTree(userContext, treeRoot, permissions);
+
+        // Return tree as a connection group
+        return tree.getRootAPIConnectionGroup();
+
+    }
+
+    /**
+     * Deletes an individual connection group.
+     * 
+     * @param authToken
+     *     The authentication token that is used to authenticate the user
+     *     performing the operation.
+     *
+     * @param authProviderIdentifier
+     *     The unique identifier of the AuthenticationProvider associated with
+     *     the UserContext containing the connection group to be deleted.
+     *
+     * @param connectionGroupID
+     *     The identifier of the connection group to delete.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while deleting the connection group.
+     */
+    @DELETE
+    @Path("/{connectionGroupID}")
+    public void deleteConnectionGroup(@QueryParam("token") String authToken, 
+            @PathParam("dataSource") String authProviderIdentifier,
+            @PathParam("connectionGroupID") String connectionGroupID)
+            throws GuacamoleException {
+
+        GuacamoleSession session = authenticationService.getGuacamoleSession(authToken);
+        UserContext userContext = retrievalService.retrieveUserContext(session, authProviderIdentifier);
+        
+        // Get the connection group directory
+        Directory<ConnectionGroup> connectionGroupDirectory = userContext.getConnectionGroupDirectory();
+
+        // Delete the connection group
+        connectionGroupDirectory.remove(connectionGroupID);
+
+    }
+    
+    /**
+     * Creates a new connection group and returns the new connection group,
+     * with identifier field populated.
+     * 
+     * @param authToken
+     *     The authentication token that is used to authenticate the user
+     *     performing the operation.
+     *
+     * @param authProviderIdentifier
+     *     The unique identifier of the AuthenticationProvider associated with
+     *     the UserContext in which the connection group is to be created.
+     *
+     * @param connectionGroup
+     *     The connection group to create.
+     * 
+     * @return
+     *     The new connection group.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while creating the connection group.
+     */
+    @POST
+    public APIConnectionGroup createConnectionGroup(
+            @QueryParam("token") String authToken,
+            @PathParam("dataSource") String authProviderIdentifier,
+            APIConnectionGroup connectionGroup) throws GuacamoleException {
+
+        GuacamoleSession session = authenticationService.getGuacamoleSession(authToken);
+        UserContext userContext = retrievalService.retrieveUserContext(session, authProviderIdentifier);
+
+        // Validate that connection group data was provided
+        if (connectionGroup == null)
+            throw new GuacamoleClientException("Connection group JSON must be submitted when creating connections groups.");
+
+        // Add the new connection group
+        Directory<ConnectionGroup> connectionGroupDirectory = userContext.getConnectionGroupDirectory();
+        connectionGroupDirectory.add(new APIConnectionGroupWrapper(connectionGroup));
+
+        // Return the new connection group
+        return connectionGroup;
+
+    }
+    
+    /**
+     * Updates a connection group. If the parent identifier of the
+     * connection group is changed, the connection group will also be moved to
+     * the new parent group.
+     * 
+     * @param authToken
+     *     The authentication token that is used to authenticate the user
+     *     performing the operation.
+     *
+     * @param authProviderIdentifier
+     *     The unique identifier of the AuthenticationProvider associated with
+     *     the UserContext containing the connection group to be updated.
+     *
+     * @param connectionGroupID
+     *     The identifier of the existing connection group to update.
+     *
+     * @param connectionGroup
+     *     The data to update the existing connection group with.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while updating the connection group.
+     */
+    @PUT
+    @Path("/{connectionGroupID}")
+    public void updateConnectionGroup(@QueryParam("token") String authToken, 
+            @PathParam("dataSource") String authProviderIdentifier,
+            @PathParam("connectionGroupID") String connectionGroupID,
+            APIConnectionGroup connectionGroup)
+            throws GuacamoleException {
+
+        GuacamoleSession session = authenticationService.getGuacamoleSession(authToken);
+        UserContext userContext = retrievalService.retrieveUserContext(session, authProviderIdentifier);
+        
+        // Validate that connection group data was provided
+        if (connectionGroup == null)
+            throw new GuacamoleClientException("Connection group JSON must be submitted when updating connection groups.");
+
+        // Get the connection group directory
+        Directory<ConnectionGroup> connectionGroupDirectory = userContext.getConnectionGroupDirectory();
+
+        // Retrieve connection group to update
+        ConnectionGroup existingConnectionGroup = retrievalService.retrieveConnectionGroup(userContext, connectionGroupID);
+        
+        // Update the connection group
+        existingConnectionGroup.setName(connectionGroup.getName());
+        existingConnectionGroup.setParentIdentifier(connectionGroup.getParentIdentifier());
+        existingConnectionGroup.setType(connectionGroup.getType());
+        existingConnectionGroup.setAttributes(connectionGroup.getAttributes());
+        connectionGroupDirectory.update(existingConnectionGroup);
+
+    }
+    
+}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/connectiongroup/ConnectionGroupTree.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/connectiongroup/ConnectionGroupTree.java
new file mode 100644
index 0000000..918374d
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/connectiongroup/ConnectionGroupTree.java
@@ -0,0 +1,259 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.basic.rest.connectiongroup;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.net.auth.Connection;
+import org.glyptodon.guacamole.net.auth.ConnectionGroup;
+import org.glyptodon.guacamole.net.auth.UserContext;
+import org.glyptodon.guacamole.net.auth.permission.ObjectPermission;
+import org.glyptodon.guacamole.net.auth.permission.ObjectPermissionSet;
+import org.glyptodon.guacamole.net.basic.rest.connection.APIConnection;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Provides access to the entire tree of connection groups and their
+ * connections.
+ *
+ * @author Michael Jumper
+ */
+public class ConnectionGroupTree {
+
+    /**
+     * Logger for this class.
+     */
+    private static final Logger logger = LoggerFactory.getLogger(ConnectionGroupTree.class);
+
+    /**
+     * The context of the user obtaining this tree.
+     */
+    private final UserContext userContext;
+    
+    /**
+     * The root connection group as an APIConnectionGroup.
+     */
+    private final APIConnectionGroup rootAPIGroup;
+
+    /**
+     * All connection groups that have been retrieved, stored by their
+     * identifiers.
+     */
+    private final Map<String, APIConnectionGroup> retrievedGroups =
+            new HashMap<String, APIConnectionGroup>();
+
+    /**
+     * Adds each of the provided connections to the current tree as children
+     * of their respective parents. The parent connection groups must already
+     * be added.
+     *
+     * @param connections
+     *     The connections to add to the tree.
+     * 
+     * @throws GuacamoleException
+     *     If an error occurs while adding the connection to the tree.
+     */
+    private void addConnections(Collection<Connection> connections)
+        throws GuacamoleException {
+
+        // Add each connection to the tree
+        for (Connection connection : connections) {
+
+            // Retrieve the connection's parent group
+            APIConnectionGroup parent = retrievedGroups.get(connection.getParentIdentifier());
+            if (parent != null) {
+
+                Collection<APIConnection> children = parent.getChildConnections();
+                
+                // Create child collection if it does not yet exist
+                if (children == null) {
+                    children = new ArrayList<APIConnection>();
+                    parent.setChildConnections(children);
+                }
+
+                // Add child
+                children.add(new APIConnection(connection));
+                
+            }
+
+            // Warn of internal consistency issues
+            else
+                logger.debug("Connection \"{}\" cannot be added to the tree: parent \"{}\" does not actually exist.",
+                        connection.getIdentifier(),
+                        connection.getParentIdentifier());
+
+        } // end for each connection
+        
+    }
+    
+    /**
+     * Adds each of the provided connection groups to the current tree as
+     * children of their respective parents. The parent connection groups must
+     * already be added.
+     *
+     * @param connectionGroups
+     *     The connection groups to add to the tree.
+     */
+    private void addConnectionGroups(Collection<ConnectionGroup> connectionGroups) {
+
+        // Add each connection group to the tree
+        for (ConnectionGroup connectionGroup : connectionGroups) {
+
+            // Retrieve the connection group's parent group
+            APIConnectionGroup parent = retrievedGroups.get(connectionGroup.getParentIdentifier());
+            if (parent != null) {
+
+                Collection<APIConnectionGroup> children = parent.getChildConnectionGroups();
+                
+                // Create child collection if it does not yet exist
+                if (children == null) {
+                    children = new ArrayList<APIConnectionGroup>();
+                    parent.setChildConnectionGroups(children);
+                }
+
+                // Add child
+                APIConnectionGroup apiConnectionGroup = new APIConnectionGroup(connectionGroup);
+                retrievedGroups.put(connectionGroup.getIdentifier(), apiConnectionGroup);
+                children.add(apiConnectionGroup);
+                
+            }
+
+            // Warn of internal consistency issues
+            else
+                logger.debug("Connection group \"{}\" cannot be added to the tree: parent \"{}\" does not actually exist.",
+                        connectionGroup.getIdentifier(),
+                        connectionGroup.getParentIdentifier());
+
+        } // end for each connection group
+        
+    }
+    
+    /**
+     * Adds all descendants of the given parent groups to their corresponding
+     * parents already stored under root.
+     *
+     * @param parents
+     *     The parents whose descendants should be added to the tree.
+     * 
+     * @param permissions
+     *     If specified and non-empty, limit added connections to only
+     *     connections for which the current user has any of the given
+     *     permissions. Otherwise, all visible connections are added.
+     *     Connection groups are unaffected by this parameter.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while retrieving the descendants.
+     */
+    private void addDescendants(Collection<ConnectionGroup> parents,
+            List<ObjectPermission.Type> permissions)
+        throws GuacamoleException {
+
+        // If no parents, nothing to do
+        if (parents.isEmpty())
+            return;
+
+        Collection<String> childConnectionIdentifiers = new ArrayList<String>();
+        Collection<String> childConnectionGroupIdentifiers = new ArrayList<String>();
+        
+        // Build lists of identifiers for retrieval
+        for (ConnectionGroup parent : parents) {
+            childConnectionIdentifiers.addAll(parent.getConnectionIdentifiers());
+            childConnectionGroupIdentifiers.addAll(parent.getConnectionGroupIdentifiers());
+        }
+
+        // Filter identifiers based on permissions, if requested
+        if (permissions != null && !permissions.isEmpty()) {
+            ObjectPermissionSet permissionSet = userContext.self().getConnectionPermissions();
+            childConnectionIdentifiers = permissionSet.getAccessibleObjects(permissions, childConnectionIdentifiers);
+        }
+        
+        // Retrieve child connections
+        if (!childConnectionIdentifiers.isEmpty()) {
+            Collection<Connection> childConnections = userContext.getConnectionDirectory().getAll(childConnectionIdentifiers);
+            addConnections(childConnections);
+        }
+
+        // Retrieve child connection groups
+        if (!childConnectionGroupIdentifiers.isEmpty()) {
+            Collection<ConnectionGroup> childConnectionGroups = userContext.getConnectionGroupDirectory().getAll(childConnectionGroupIdentifiers);
+            addConnectionGroups(childConnectionGroups);
+            addDescendants(childConnectionGroups, permissions);
+        }
+
+    }
+    
+    /**
+     * Creates a new connection group tree using the given connection group as
+     * the tree root.
+     *
+     * @param userContext
+     *     The context of the user obtaining the connection group tree.
+     *
+     * @param root
+     *     The connection group to use as the root of this connection group
+     *     tree.
+     * 
+     * @param permissions
+     *     If specified and non-empty, limit the contents of the tree to only
+     *     those connections for which the current user has any of the given
+     *     permissions. Otherwise, all visible connections are returned.
+     *     Connection groups are unaffected by this parameter.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while retrieving the tree of connection groups
+     *     and their descendants.
+     */
+    public ConnectionGroupTree(UserContext userContext, ConnectionGroup root,
+            List<ObjectPermission.Type> permissions) throws GuacamoleException {
+
+        this.userContext = userContext;
+        
+        // Store root of tree
+        this.rootAPIGroup = new APIConnectionGroup(root);
+        retrievedGroups.put(root.getIdentifier(), this.rootAPIGroup);
+
+        // Add all descendants
+        addDescendants(Collections.singleton(root), permissions);
+        
+    }
+
+    /**
+     * Returns the entire connection group tree as an APIConnectionGroup. The
+     * returned APIConnectionGroup is the root group and will contain all
+     * descendant connection groups and connections, arranged hierarchically.
+     *
+     * @return
+     *     The root connection group, containing the entire connection group
+     *     tree and all connections.
+     */
+    public APIConnectionGroup getRootAPIConnectionGroup() {
+        return rootAPIGroup;
+    }
+
+}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/connectiongroup/package-info.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/connectiongroup/package-info.java
new file mode 100644
index 0000000..dc404cb
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/connectiongroup/package-info.java
@@ -0,0 +1,28 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Classes related to the connection group manipulation aspect
+ * of the Guacamole REST API.
+ */
+package org.glyptodon.guacamole.net.basic.rest.connectiongroup;
+
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/history/APIConnectionRecord.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/history/APIConnectionRecord.java
new file mode 100644
index 0000000..8c38148
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/history/APIConnectionRecord.java
@@ -0,0 +1,163 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.basic.rest.history;
+
+import java.util.Date;
+import org.glyptodon.guacamole.net.auth.ConnectionRecord;
+
+/**
+ * A connection record which may be exposed through the REST endpoints.
+ *
+ * @author Michael Jumper
+ */
+public class APIConnectionRecord {
+
+    /**
+     * The identifier of the connection associated with this record.
+     */
+    private final String connectionIdentifier;
+
+    /**
+     * The identifier of the connection associated with this record.
+     */
+    private final String connectionName;
+
+    /**
+     * The date and time the connection began.
+     */
+    private final Date startDate;
+
+    /**
+     * The date and time the connection ended, or null if the connection is
+     * still running or if the end time is unknown.
+     */
+    private final Date endDate;
+
+    /**
+     * The host from which the connection originated, if known.
+     */
+    private final String remoteHost;
+
+    /**
+     * The name of the user who used or is using the connection.
+     */
+    private final String username;
+
+    /**
+     * Whether the connection is currently active.
+     */
+    private final boolean active;
+
+    /**
+     * Creates a new APIConnectionRecord, copying the data from the given
+     * record.
+     *
+     * @param record
+     *     The record to copy data from.
+     */
+    public APIConnectionRecord(ConnectionRecord record) {
+        this.connectionIdentifier = record.getConnectionIdentifier();
+        this.connectionName       = record.getConnectionName();
+        this.startDate            = record.getStartDate();
+        this.endDate              = record.getEndDate();
+        this.remoteHost           = record.getRemoteHost();
+        this.username             = record.getUsername();
+        this.active               = record.isActive();
+    }
+
+    /**
+     * Returns the identifier of the connection associated with this
+     * record.
+     *
+     * @return
+     *     The identifier of the connection associated with this record.
+     */
+    public String getConnectionIdentifier() {
+        return connectionIdentifier;
+    }
+
+    /**
+     * Returns the name of the connection associated with this record.
+     *
+     * @return
+     *     The name of the connection associated with this record.
+     */
+    public String getConnectionName() {
+        return connectionName;
+    }
+
+    /**
+     * Returns the date and time the connection began.
+     *
+     * @return
+     *     The date and time the connection began.
+     */
+    public Date getStartDate() {
+        return startDate;
+    }
+
+    /**
+     * Returns the date and time the connection ended, if applicable.
+     *
+     * @return
+     *     The date and time the connection ended, or null if the connection is
+     *     still running or if the end time is unknown.
+     */
+    public Date getEndDate() {
+        return endDate;
+    }
+
+    /**
+     * Returns the remote host from which this connection originated.
+     *
+     * @return
+     *     The remote host from which this connection originated.
+     */
+    public String getRemoteHost() {
+        return remoteHost;
+    }
+
+    /**
+     * Returns the name of the user who used or is using the connection at the
+     * times given by this connection record.
+     *
+     * @return
+     *     The name of the user who used or is using the associated connection.
+     */
+    public String getUsername() {
+        return username;
+    }
+
+    /**
+     * Returns whether the connection associated with this record is still
+     * active.
+     *
+     * @return
+     *     true if the connection associated with this record is still active,
+     *     false otherwise.
+     */
+    public boolean isActive() {
+        return active;
+    }
+
+}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/history/APIConnectionRecordSortPredicate.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/history/APIConnectionRecordSortPredicate.java
new file mode 100644
index 0000000..df3dec5
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/history/APIConnectionRecordSortPredicate.java
@@ -0,0 +1,148 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.basic.rest.history;
+
+import org.glyptodon.guacamole.net.auth.ConnectionRecordSet;
+import org.glyptodon.guacamole.net.basic.rest.APIError;
+import org.glyptodon.guacamole.net.basic.rest.APIException;
+
+/**
+ * A sort predicate which species the property to use when sorting connection
+ * records, along with the sort order.
+ *
+ * @author Michael Jumper
+ */
+public class APIConnectionRecordSortPredicate {
+
+    /**
+     * The prefix which will be included before the name of a sortable property
+     * to indicate that the sort order is descending, not ascending.
+     */
+    public static final String DESCENDING_PREFIX = "-";
+
+    /**
+     * All possible property name strings and their corresponding
+     * ConnectionRecordSet.SortableProperty values.
+     */
+    public enum SortableProperty {
+
+        /**
+         * The date that the connection associated with the connection record
+         * began (connected).
+         */
+        startDate(ConnectionRecordSet.SortableProperty.START_DATE);
+
+        /**
+         * The ConnectionRecordSet.SortableProperty that this property name
+         * string represents.
+         */
+        public final ConnectionRecordSet.SortableProperty recordProperty;
+
+        /**
+         * Creates a new SortableProperty which associates the property name
+         * string (identical to its own name) with the given
+         * ConnectionRecordSet.SortableProperty value.
+         *
+         * @param recordProperty
+         *     The ConnectionRecordSet.SortableProperty value to associate with
+         *     the new SortableProperty.
+         */
+        SortableProperty(ConnectionRecordSet.SortableProperty recordProperty) {
+            this.recordProperty = recordProperty;
+        }
+
+    }
+
+    /**
+     * The property to use when sorting ConnectionRecords.
+     */
+    private ConnectionRecordSet.SortableProperty property;
+
+    /**
+     * Whether the requested sort order is descending (true) or ascending
+     * (false).
+     */
+    private boolean descending;
+
+    /**
+     * Parses the given string value, determining the requested sort property
+     * and ordering. Possible values consist of any valid property name, and
+     * may include an optional prefix to denote descending sort order. Each
+     * possible property name is enumerated by the SortableValue enum.
+     *
+     * @param value
+     *     The sort predicate string to parse, which must consist ONLY of a
+     *     valid property name, possibly preceded by the DESCENDING_PREFIX.
+     *
+     * @throws APIException
+     *     If the provided sort predicate string is invalid.
+     */
+    public APIConnectionRecordSortPredicate(String value)
+        throws APIException {
+
+        // Parse whether sort order is descending
+        if (value.startsWith(DESCENDING_PREFIX)) {
+            descending = true;
+            value = value.substring(DESCENDING_PREFIX.length());
+        }
+
+        // Parse sorting property into ConnectionRecordSet.SortableProperty
+        try {
+            this.property = SortableProperty.valueOf(value).recordProperty;
+        }
+
+        // Bail out if sort property is not valid
+        catch (IllegalArgumentException e) {
+            throw new APIException(
+                APIError.Type.BAD_REQUEST,
+                String.format("Invalid sort property: \"%s\"", value)
+            );
+        }
+
+    }
+
+    /**
+     * Returns the SortableProperty defined by ConnectionRecordSet which
+     * represents the property requested.
+     *
+     * @return
+     *     The ConnectionRecordSet.SortableProperty which refers to the same
+     *     property as the string originally provided when this
+     *     APIConnectionRecordSortPredicate was created.
+     */
+    public ConnectionRecordSet.SortableProperty getProperty() {
+        return property;
+    }
+
+    /**
+     * Returns whether the requested sort order is descending.
+     *
+     * @return
+     *     true if the sort order is descending, false if the sort order is
+     *     ascending.
+     */
+    public boolean isDescending() {
+        return descending;
+    }
+
+}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/history/HistoryRESTService.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/history/HistoryRESTService.java
new file mode 100644
index 0000000..1f9f4c8
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/history/HistoryRESTService.java
@@ -0,0 +1,147 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.basic.rest.history;
+
+import com.google.inject.Inject;
+import java.util.ArrayList;
+import java.util.List;
+import javax.ws.rs.Consumes;
+import javax.ws.rs.GET;
+import javax.ws.rs.Path;
+import javax.ws.rs.PathParam;
+import javax.ws.rs.Produces;
+import javax.ws.rs.QueryParam;
+import javax.ws.rs.core.MediaType;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.net.auth.ConnectionRecord;
+import org.glyptodon.guacamole.net.auth.ConnectionRecordSet;
+import org.glyptodon.guacamole.net.auth.UserContext;
+import org.glyptodon.guacamole.net.basic.GuacamoleSession;
+import org.glyptodon.guacamole.net.basic.rest.ObjectRetrievalService;
+import org.glyptodon.guacamole.net.basic.rest.auth.AuthenticationService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * A REST Service for retrieving and managing the history records of Guacamole
+ * objects.
+ *
+ * @author Michael Jumper
+ */
+ at Path("/data/{dataSource}/history")
+ at Produces(MediaType.APPLICATION_JSON)
+ at Consumes(MediaType.APPLICATION_JSON)
+public class HistoryRESTService {
+
+    /**
+     * Logger for this class.
+     */
+    private static final Logger logger = LoggerFactory.getLogger(HistoryRESTService.class);
+
+    /**
+     * The maximum number of history records to return in any one response.
+     */
+    private static final int MAXIMUM_HISTORY_SIZE = 1000;
+
+    /**
+     * A service for authenticating users from auth tokens.
+     */
+    @Inject
+    private AuthenticationService authenticationService;
+
+    /**
+     * Service for convenient retrieval of objects.
+     */
+    @Inject
+    private ObjectRetrievalService retrievalService;
+
+    /**
+     * Retrieves the usage history for all connections, restricted by optional
+     * filter parameters.
+     *
+     * @param authToken
+     *     The authentication token that is used to authenticate the user
+     *     performing the operation.
+     *
+     * @param authProviderIdentifier
+     *     The unique identifier of the AuthenticationProvider associated with
+     *     the UserContext containing the connection whose history is to be
+     *     retrieved.
+     *
+     * @param requiredContents
+     *     The set of strings that each must occur somewhere within the
+     *     returned connection records, whether within the associated username,
+     *     the name of the associated connection, or any associated date. If
+     *     non-empty, any connection record not matching each of the strings
+     *     within the collection will be excluded from the results.
+     *
+     * @param sortPredicates
+     *     A list of predicates to apply while sorting the resulting connection
+     *     records, describing the properties involved and the sort order for
+     *     those properties.
+     *
+     * @return
+     *     A list of connection records, describing the start and end times of
+     *     various usages of this connection.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while retrieving the connection history.
+     */
+    @GET
+    @Path("/connections")
+    public List<APIConnectionRecord> getConnectionHistory(@QueryParam("token") String authToken,
+            @PathParam("dataSource") String authProviderIdentifier,
+            @QueryParam("contains") List<String> requiredContents,
+            @QueryParam("order") List<APIConnectionRecordSortPredicate> sortPredicates)
+            throws GuacamoleException {
+
+        GuacamoleSession session = authenticationService.getGuacamoleSession(authToken);
+        UserContext userContext = retrievalService.retrieveUserContext(session, authProviderIdentifier);
+
+        // Retrieve overall connection history
+        ConnectionRecordSet history = userContext.getConnectionHistory();
+
+        // Restrict to records which contain the specified strings
+        for (String required : requiredContents) {
+            if (!required.isEmpty())
+                history = history.contains(required);
+        }
+
+        // Sort according to specified ordering
+        for (APIConnectionRecordSortPredicate predicate : sortPredicates)
+            history = history.sort(predicate.getProperty(), predicate.isDescending());
+
+        // Limit to maximum result size
+        history = history.limit(MAXIMUM_HISTORY_SIZE);
+
+        // Convert record set to collection of API connection records
+        List<APIConnectionRecord> apiRecords = new ArrayList<APIConnectionRecord>();
+        for (ConnectionRecord record : history.asCollection())
+            apiRecords.add(new APIConnectionRecord(record));
+
+        // Return the converted history
+        return apiRecords;
+
+    }
+
+}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/history/package-info.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/history/package-info.java
new file mode 100644
index 0000000..0cd154b
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/history/package-info.java
@@ -0,0 +1,28 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Classes related to retrieval or maintenance of history records using the
+ * Guacamole REST API.
+ */
+package org.glyptodon.guacamole.net.basic.rest.history;
+
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/language/LanguageRESTService.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/language/LanguageRESTService.java
new file mode 100644
index 0000000..e1a35f8
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/language/LanguageRESTService.java
@@ -0,0 +1,63 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.basic.rest.language;
+
+import com.google.inject.Inject;
+import java.util.Map;
+import javax.ws.rs.GET;
+import javax.ws.rs.Path;
+import javax.ws.rs.Produces;
+import javax.ws.rs.core.MediaType;
+import org.glyptodon.guacamole.net.basic.extension.LanguageResourceService;
+
+
+/**
+ * A REST Service for handling the listing of languages.
+ * 
+ * @author James Muehlner
+ */
+ at Path("/languages")
+ at Produces(MediaType.APPLICATION_JSON)
+public class LanguageRESTService {
+
+    /**
+     * Service for retrieving information regarding available language
+     * resources.
+     */
+    @Inject
+    private LanguageResourceService languageResourceService;
+
+    /**
+     * Returns a map of all available language keys to their corresponding
+     * human-readable names.
+     * 
+     * @return
+     *     A map of languages defined in the system, of language key to 
+     *     display name.
+     */
+    @GET
+    public Map<String, String> getLanguages() {
+        return languageResourceService.getLanguageNames();
+    }
+
+}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/language/package-info.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/language/package-info.java
new file mode 100644
index 0000000..e0e7ad6
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/language/package-info.java
@@ -0,0 +1,27 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Classes related to the language retrieval aspect of the Guacamole REST API.
+ */
+package org.glyptodon.guacamole.net.basic.rest.language;
+
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/package-info.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/package-info.java
new file mode 100644
index 0000000..31a00e5
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/package-info.java
@@ -0,0 +1,27 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Classes related to the basic Guacamole REST API.
+ */
+package org.glyptodon.guacamole.net.basic.rest;
+
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/permission/APIPermissionSet.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/permission/APIPermissionSet.java
new file mode 100644
index 0000000..d04dd0b
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/permission/APIPermissionSet.java
@@ -0,0 +1,300 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.basic.rest.permission;
+
+import java.util.EnumSet;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.net.auth.User;
+import org.glyptodon.guacamole.net.auth.permission.ObjectPermission;
+import org.glyptodon.guacamole.net.auth.permission.ObjectPermissionSet;
+import org.glyptodon.guacamole.net.auth.permission.SystemPermission;
+import org.glyptodon.guacamole.net.auth.permission.SystemPermissionSet;
+
+/**
+ * The set of permissions which are granted to a specific user, organized by
+ * object type and, if applicable, identifier. This object can be constructed
+ * with arbitrary permissions present, or manipulated after creation through
+ * the manipulation or replacement of its collections of permissions, but is
+ * otherwise not intended for internal use as a data structure for permissions.
+ * Its primary purpose is as a hierarchical format for exchanging granted
+ * permissions with REST clients.
+ */
+public class APIPermissionSet {
+
+    /**
+     * Map of connection ID to the set of granted permissions.
+     */
+    private Map<String, Set<ObjectPermission.Type>> connectionPermissions =
+            new HashMap<String, Set<ObjectPermission.Type>>();
+
+    /**
+     * Map of connection group ID to the set of granted permissions.
+     */
+    private Map<String, Set<ObjectPermission.Type>> connectionGroupPermissions =
+            new HashMap<String, Set<ObjectPermission.Type>>();
+
+    /**
+     * Map of active connection ID to the set of granted permissions.
+     */
+    private Map<String, Set<ObjectPermission.Type>> activeConnectionPermissions =
+            new HashMap<String, Set<ObjectPermission.Type>>();
+
+    /**
+     * Map of user ID to the set of granted permissions.
+     */
+    private Map<String, Set<ObjectPermission.Type>> userPermissions =
+            new HashMap<String, Set<ObjectPermission.Type>>();
+
+    /**
+     * Set of all granted system-level permissions.
+     */
+    private Set<SystemPermission.Type> systemPermissions =
+            EnumSet.noneOf(SystemPermission.Type.class);
+
+    /**
+     * Creates a new permission set which contains no granted permissions. Any
+     * permissions must be added by manipulating or replacing the applicable
+     * permission collection.
+     */
+    public APIPermissionSet() {
+    }
+
+    /**
+     * Adds the system permissions from the given SystemPermissionSet to the
+     * Set of system permissions provided.
+     *
+     * @param permissions
+     *     The Set to add system permissions to.
+     *
+     * @param permSet
+     *     The SystemPermissionSet containing the system permissions to add.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while retrieving system permissions from the
+     *     SystemPermissionSet.
+     */
+    private void addSystemPermissions(Set<SystemPermission.Type> permissions,
+            SystemPermissionSet permSet) throws GuacamoleException {
+
+        // Add all provided system permissions 
+        for (SystemPermission permission : permSet.getPermissions())
+            permissions.add(permission.getType());
+
+    }
+    
+    /**
+     * Adds the object permissions from the given ObjectPermissionSet to the
+     * Map of object permissions provided.
+     *
+     * @param permissions
+     *     The Map to add object permissions to.
+     *
+     * @param permSet
+     *     The ObjectPermissionSet containing the object permissions to add.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while retrieving object permissions from the
+     *     ObjectPermissionSet.
+     */
+    private void addObjectPermissions(Map<String, Set<ObjectPermission.Type>> permissions,
+            ObjectPermissionSet permSet) throws GuacamoleException {
+
+        // Add all provided object permissions 
+        for (ObjectPermission permission : permSet.getPermissions()) {
+
+            // Get associated set of permissions
+            String identifier = permission.getObjectIdentifier();
+            Set<ObjectPermission.Type> objectPermissions = permissions.get(identifier);
+
+            // Create new set if none yet exists
+            if (objectPermissions == null)
+                permissions.put(identifier, EnumSet.of(permission.getType()));
+
+            // Otherwise add to existing set
+            else
+                objectPermissions.add(permission.getType());
+
+        }
+
+    }
+    
+    /**
+     * Creates a new permission set containing all permissions currently
+     * granted to the given user.
+     *
+     * @param user
+     *     The user whose permissions should be stored within this permission
+     *     set.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while retrieving the user's permissions.
+     */
+    public APIPermissionSet(User user) throws GuacamoleException {
+
+        // Add all permissions from the provided user
+        addSystemPermissions(systemPermissions,           user.getSystemPermissions());
+        addObjectPermissions(connectionPermissions,       user.getConnectionPermissions());
+        addObjectPermissions(connectionGroupPermissions,  user.getConnectionGroupPermissions());
+        addObjectPermissions(activeConnectionPermissions, user.getActiveConnectionPermissions());
+        addObjectPermissions(userPermissions,             user.getUserPermissions());
+        
+    }
+
+    /**
+     * Returns a map of connection IDs to the set of permissions granted for
+     * that connection. If no permissions are granted to a particular
+     * connection, its ID will not be present as a key in the map. This map is
+     * mutable, and changes to this map will affect the permission set
+     * directly.
+     *
+     * @return
+     *     A map of connection IDs to the set of permissions granted for that
+     *     connection.
+     */
+    public Map<String, Set<ObjectPermission.Type>> getConnectionPermissions() {
+        return connectionPermissions;
+    }
+
+    /**
+     * Returns a map of connection group IDs to the set of permissions granted
+     * for that connection group. If no permissions are granted to a particular
+     * connection group, its ID will not be present as a key in the map. This
+     * map is mutable, and changes to this map will affect the permission set
+     * directly.
+     *
+     * @return
+     *     A map of connection group IDs to the set of permissions granted for
+     *     that connection group.
+     */
+    public Map<String, Set<ObjectPermission.Type>> getConnectionGroupPermissions() {
+        return connectionGroupPermissions;
+    }
+
+    /**
+     * Returns a map of active connection IDs to the set of permissions granted
+     * for that active connection. If no permissions are granted to a particular
+     * active connection, its ID will not be present as a key in the map. This
+     * map is mutable, and changes to this map will affect the permission set
+     * directly.
+     *
+     * @return
+     *     A map of active connection IDs to the set of permissions granted for
+     *     that active connection.
+     */
+    public Map<String, Set<ObjectPermission.Type>> getActiveConnectionPermissions() {
+        return activeConnectionPermissions;
+    }
+
+    /**
+     * Returns a map of user IDs to the set of permissions granted for that
+     * user. If no permissions are granted to a particular user, its ID will
+     * not be present as a key in the map. This map is mutable, and changes to
+     * to this map will affect the permission set directly.
+     *
+     * @return
+     *     A map of user IDs to the set of permissions granted for that user.
+     */
+    public Map<String, Set<ObjectPermission.Type>> getUserPermissions() {
+        return userPermissions;
+    }
+
+    /**
+     * Returns the set of granted system-level permissions. If no permissions
+     * are granted at the system level, this will be an empty set. This set is
+     * mutable, and changes to this set will affect the permission set
+     * directly.
+     *
+     * @return
+     *     The set of granted system-level permissions.
+     */
+    public Set<SystemPermission.Type> getSystemPermissions() {
+        return systemPermissions;
+    }
+
+    /**
+     * Replaces the current map of connection permissions with the given map,
+     * which must map connection ID to its corresponding set of granted
+     * permissions. If a connection has no permissions, its ID must not be
+     * present as a key in the map.
+     *
+     * @param connectionPermissions
+     *     The map which must replace the currently-stored map of permissions.
+     */
+    public void setConnectionPermissions(Map<String, Set<ObjectPermission.Type>> connectionPermissions) {
+        this.connectionPermissions = connectionPermissions;
+    }
+
+    /**
+     * Replaces the current map of connection group permissions with the given
+     * map, which must map connection group ID to its corresponding set of
+     * granted permissions. If a connection group has no permissions, its ID
+     * must not be present as a key in the map.
+     *
+     * @param connectionGroupPermissions
+     *     The map which must replace the currently-stored map of permissions.
+     */
+    public void setConnectionGroupPermissions(Map<String, Set<ObjectPermission.Type>> connectionGroupPermissions) {
+        this.connectionGroupPermissions = connectionGroupPermissions;
+    }
+
+    /**
+     * Replaces the current map of active connection permissions with the give
+     * map, which must map active connection ID to its corresponding set of
+     * granted permissions. If an active connection has no permissions, its ID
+     * must not be present as a key in the map.
+     *
+     * @param activeConnectionPermissions
+     *     The map which must replace the currently-stored map of permissions.
+     */
+    public void setActiveConnectionPermissions(Map<String, Set<ObjectPermission.Type>> activeConnectionPermissions) {
+        this.activeConnectionPermissions = activeConnectionPermissions;
+    }
+
+    /**
+     * Replaces the current map of user permissions with the given map, which
+     * must map user ID to its corresponding set of granted permissions. If a
+     * user has no permissions, its ID must not be present as a key in the map.
+     *
+     * @param userPermissions
+     *     The map which must replace the currently-stored map of permissions.
+     */
+    public void setUserPermissions(Map<String, Set<ObjectPermission.Type>> userPermissions) {
+        this.userPermissions = userPermissions;
+    }
+
+    /**
+     * Replaces the current set of system-level permissions with the given set.
+     * If no system-level permissions are granted, the empty set must be
+     * specified.
+     *
+     * @param systemPermissions
+     *     The set which must replace the currently-stored set of permissions.
+     */
+    public void setSystemPermissions(Set<SystemPermission.Type> systemPermissions) {
+        this.systemPermissions = systemPermissions;
+    }
+    
+}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/permission/package-info.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/permission/package-info.java
new file mode 100644
index 0000000..274d74b
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/permission/package-info.java
@@ -0,0 +1,27 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Classes related to the permission manipulation aspect of the Guacamole REST API.
+ */
+package org.glyptodon.guacamole.net.basic.rest.permission;
+
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/schema/SchemaRESTService.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/schema/SchemaRESTService.java
new file mode 100644
index 0000000..66a2aa7
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/schema/SchemaRESTService.java
@@ -0,0 +1,199 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.basic.rest.schema;
+
+import com.google.inject.Inject;
+import java.util.Collection;
+import java.util.Map;
+import javax.ws.rs.Consumes;
+import javax.ws.rs.GET;
+import javax.ws.rs.Path;
+import javax.ws.rs.PathParam;
+import javax.ws.rs.Produces;
+import javax.ws.rs.QueryParam;
+import javax.ws.rs.core.MediaType;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.environment.Environment;
+import org.glyptodon.guacamole.environment.LocalEnvironment;
+import org.glyptodon.guacamole.form.Form;
+import org.glyptodon.guacamole.net.auth.UserContext;
+import org.glyptodon.guacamole.net.basic.GuacamoleSession;
+import org.glyptodon.guacamole.net.basic.rest.ObjectRetrievalService;
+import org.glyptodon.guacamole.net.basic.rest.auth.AuthenticationService;
+import org.glyptodon.guacamole.protocols.ProtocolInfo;
+
+/**
+ * A REST service which provides access to descriptions of the properties,
+ * attributes, etc. of objects used within the Guacamole REST API.
+ *
+ * @author Michael Jumper
+ */
+ at Path("/schema/{dataSource}")
+ at Produces(MediaType.APPLICATION_JSON)
+ at Consumes(MediaType.APPLICATION_JSON)
+public class SchemaRESTService {
+
+    /**
+     * A service for authenticating users from auth tokens.
+     */
+    @Inject
+    private AuthenticationService authenticationService;
+
+    /**
+     * Service for convenient retrieval of objects.
+     */
+    @Inject
+    private ObjectRetrievalService retrievalService;
+
+    /**
+     * Retrieves the possible attributes of a user object.
+     *
+     * @param authToken
+     *     The authentication token that is used to authenticate the user
+     *     performing the operation.
+     *
+     * @param authProviderIdentifier
+     *     The unique identifier of the AuthenticationProvider associated with
+     *     the UserContext dictating the available user attributes.
+     *
+     * @return
+     *     A collection of forms which describe the possible attributes of a
+     *     user object.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while retrieving the possible attributes.
+     */
+    @GET
+    @Path("/users/attributes")
+    public Collection<Form> getUserAttributes(@QueryParam("token") String authToken,
+            @PathParam("dataSource") String authProviderIdentifier)
+            throws GuacamoleException {
+
+        // Retrieve all possible user attributes
+        GuacamoleSession session = authenticationService.getGuacamoleSession(authToken);
+        UserContext userContext = retrievalService.retrieveUserContext(session, authProviderIdentifier);
+        return userContext.getUserAttributes();
+
+    }
+
+    /**
+     * Retrieves the possible attributes of a connection object.
+     *
+     * @param authToken
+     *     The authentication token that is used to authenticate the user
+     *     performing the operation.
+     *
+     * @param authProviderIdentifier
+     *     The unique identifier of the AuthenticationProvider associated with
+     *     the UserContext dictating the available connection attributes.
+     *
+     * @return
+     *     A collection of forms which describe the possible attributes of a
+     *     connection object.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while retrieving the possible attributes.
+     */
+    @GET
+    @Path("/connections/attributes")
+    public Collection<Form> getConnectionAttributes(@QueryParam("token") String authToken,
+            @PathParam("dataSource") String authProviderIdentifier)
+            throws GuacamoleException {
+
+        // Retrieve all possible connection attributes
+        GuacamoleSession session = authenticationService.getGuacamoleSession(authToken);
+        UserContext userContext = retrievalService.retrieveUserContext(session, authProviderIdentifier);
+        return userContext.getConnectionAttributes();
+
+    }
+
+    /**
+     * Retrieves the possible attributes of a connection group object.
+     *
+     * @param authToken
+     *     The authentication token that is used to authenticate the user
+     *     performing the operation.
+     *
+     * @param authProviderIdentifier
+     *     The unique identifier of the AuthenticationProvider associated with
+     *     the UserContext dictating the available connection group
+     *     attributes.
+     *
+     * @return
+     *     A collection of forms which describe the possible attributes of a
+     *     connection group object.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while retrieving the possible attributes.
+     */
+    @GET
+    @Path("/connectionGroups/attributes")
+    public Collection<Form> getConnectionGroupAttributes(@QueryParam("token") String authToken,
+            @PathParam("dataSource") String authProviderIdentifier)
+            throws GuacamoleException {
+
+        // Retrieve all possible connection group attributes
+        GuacamoleSession session = authenticationService.getGuacamoleSession(authToken);
+        UserContext userContext = retrievalService.retrieveUserContext(session, authProviderIdentifier);
+        return userContext.getConnectionGroupAttributes();
+
+    }
+
+    /**
+     * Gets a map of protocols defined in the system - protocol name to protocol.
+     *
+     * @param authToken
+     *     The authentication token that is used to authenticate the user
+     *     performing the operation.
+     *
+     * @param authProviderIdentifier
+     *     The unique identifier of the AuthenticationProvider associated with
+     *     the UserContext dictating the protocols available. Currently, the
+     *     UserContext actually does not dictate this, the the same set of
+     *     protocols will be retrieved for all users, though the identifier
+     *     given here will be validated.
+     *
+     * @return
+     *     A map of protocol information, where each key is the unique name
+     *     associated with that protocol.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while retrieving the available protocols.
+     */
+    @GET
+    @Path("/protocols")
+    public Map<String, ProtocolInfo> getProtocols(@QueryParam("token") String authToken,
+            @PathParam("dataSource") String authProviderIdentifier)
+            throws GuacamoleException {
+
+        // Verify the given auth token and auth provider identifier are valid
+        GuacamoleSession session = authenticationService.getGuacamoleSession(authToken);
+        retrievalService.retrieveUserContext(session, authProviderIdentifier);
+
+        // Get and return a map of all protocols.
+        Environment env = new LocalEnvironment();
+        return env.getProtocols();
+
+    }
+
+}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/schema/package-info.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/schema/package-info.java
new file mode 100644
index 0000000..e9aaaa5
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/schema/package-info.java
@@ -0,0 +1,27 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Classes related to the self-description of the Guacamole REST API, such as
+ * the attributes or parameters applicable to specific objects.
+ */
+package org.glyptodon.guacamole.net.basic.rest.schema;
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/user/APIUser.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/user/APIUser.java
new file mode 100644
index 0000000..a278e07
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/user/APIUser.java
@@ -0,0 +1,130 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.basic.rest.user;
+
+import java.util.Map;
+import org.codehaus.jackson.annotate.JsonIgnoreProperties;
+import org.codehaus.jackson.map.annotate.JsonSerialize;
+import org.glyptodon.guacamole.net.auth.User;
+
+/**
+ * A simple User to expose through the REST endpoints.
+ * 
+ * @author James Muehlner
+ */
+ at JsonIgnoreProperties(ignoreUnknown = true)
+ at JsonSerialize(include=JsonSerialize.Inclusion.NON_NULL)
+public class APIUser {
+    
+    /**
+     * The username of this user.
+     */
+    private String username;
+    
+    /**
+     * The password of this user.
+     */
+    private String password;
+    
+    /**
+     * Map of all associated attributes by attribute identifier.
+     */
+    private Map<String, String> attributes;
+
+    /**
+     * Construct a new empty APIUser.
+     */
+    public APIUser() {}
+    
+    /**
+     * Construct a new APIUser from the provided User.
+     * @param user The User to construct the APIUser from.
+     */
+    public APIUser(User user) {
+
+        // Set user information
+        this.username = user.getIdentifier();
+        this.password = user.getPassword();
+
+        // Associate any attributes
+        this.attributes = user.getAttributes();
+
+    }
+
+    /**
+     * Returns the username for this user.
+     * @return The username for this user. 
+     */
+    public String getUsername() {
+        return username;
+    }
+
+    /**
+     * Set the username for this user.
+     * @param username The username for this user.
+     */
+    public void setUsername(String username) {
+        this.username = username;
+    }
+
+    /**
+     * Returns the password for this user.
+     * @return The password for this user.
+     */
+    public String getPassword() {
+        return password;
+    }
+
+    /**
+     * Set the password for this user.
+     * @param password The password for this user.
+     */
+    public void setPassword(String password) {
+        this.password = password;
+    }
+
+    /**
+     * Returns a map of all attributes associated with this user. Each entry
+     * key is the attribute identifier, while each value is the attribute
+     * value itself.
+     *
+     * @return
+     *     The attribute map for this user.
+     */
+    public Map<String, String> getAttributes() {
+        return attributes;
+    }
+
+    /**
+     * Sets the map of all attributes associated with this user. Each entry key
+     * is the attribute identifier, while each value is the attribute value
+     * itself.
+     *
+     * @param attributes
+     *     The attribute map for this user.
+     */
+    public void setAttributes(Map<String, String> attributes) {
+        this.attributes = attributes;
+    }
+
+}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/user/APIUserPasswordUpdate.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/user/APIUserPasswordUpdate.java
new file mode 100644
index 0000000..7bdb601
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/user/APIUserPasswordUpdate.java
@@ -0,0 +1,82 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+package org.glyptodon.guacamole.net.basic.rest.user;
+
+/**
+ * All the information necessary for the password update operation on a user.
+ * 
+ * @author James Muehlner
+ */
+public class APIUserPasswordUpdate {
+    
+    /**
+     * The old (current) password of this user.
+     */
+    private String oldPassword;
+    
+    /**
+     * The new password of this user.
+     */
+    private String newPassword;
+
+    /**
+     * Returns the old password for this user. This password must match the
+     * user's current password for the password update operation to succeed.
+     *
+     * @return
+     *     The old password for this user.
+     */
+    public String getOldPassword() {
+        return oldPassword;
+    }
+
+    /**
+     * Set the old password for this user. This password must match the
+     * user's current password for the password update operation to succeed.
+     *
+     * @param oldPassword
+     *     The old password for this user.
+     */
+    public void setOldPassword(String oldPassword) {
+        this.oldPassword = oldPassword;
+    }
+
+    /**
+     * Returns the new password that will be assigned to this user.
+     *
+     * @return
+     *     The new password for this user.
+     */
+    public String getNewPassword() {
+        return newPassword;
+    }
+
+    /**
+     * Set the new password that will be assigned to this user.
+     *
+     * @param newPassword
+     *     The new password for this user.
+     */
+    public void setNewPassword(String newPassword) {
+        this.newPassword = newPassword;
+    }
+}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/user/APIUserWrapper.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/user/APIUserWrapper.java
new file mode 100644
index 0000000..f918dbf
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/user/APIUserWrapper.java
@@ -0,0 +1,115 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.basic.rest.user;
+
+import java.util.Map;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.GuacamoleUnsupportedException;
+import org.glyptodon.guacamole.net.auth.User;
+import org.glyptodon.guacamole.net.auth.permission.ObjectPermissionSet;
+import org.glyptodon.guacamole.net.auth.permission.SystemPermissionSet;
+
+/**
+ * A wrapper to make an APIUser look like a User. Useful where an
+ * org.glyptodon.guacamole.net.auth.User is required. As a simple wrapper for
+ * APIUser, access to permissions is not provided. Any attempt to access or
+ * manipulate permissions on an APIUserWrapper will result in an exception.
+ * 
+ * @author James Muehlner
+ */
+public class APIUserWrapper implements User {
+    
+    /**
+     * The wrapped APIUser.
+     */
+    private final APIUser apiUser;
+    
+    /**
+     * Wrap a given APIUser to expose as a User.
+     * @param apiUser The APIUser to wrap.
+     */
+    public APIUserWrapper(APIUser apiUser) {
+        this.apiUser = apiUser;
+    }
+    
+    @Override
+    public String getIdentifier() {
+        return apiUser.getUsername();
+    }
+
+    @Override
+    public void setIdentifier(String username) {
+        apiUser.setUsername(username);
+    }
+
+    @Override
+    public String getPassword() {
+        return apiUser.getPassword();
+    }
+
+    @Override
+    public void setPassword(String password) {
+        apiUser.setPassword(password);
+    }
+
+    @Override
+    public Map<String, String> getAttributes() {
+        return apiUser.getAttributes();
+    }
+
+    @Override
+    public void setAttributes(Map<String, String> attributes) {
+        apiUser.setAttributes(attributes);
+    }
+
+    @Override
+    public SystemPermissionSet getSystemPermissions()
+            throws GuacamoleException {
+        throw new GuacamoleUnsupportedException("APIUserWrapper does not provide permission access.");
+    }
+
+    @Override
+    public ObjectPermissionSet getConnectionPermissions()
+            throws GuacamoleException {
+        throw new GuacamoleUnsupportedException("APIUserWrapper does not provide permission access.");
+    }
+
+    @Override
+    public ObjectPermissionSet getConnectionGroupPermissions()
+            throws GuacamoleException {
+        throw new GuacamoleUnsupportedException("APIUserWrapper does not provide permission access.");
+    }
+
+    @Override
+    public ObjectPermissionSet getUserPermissions()
+            throws GuacamoleException {
+        throw new GuacamoleUnsupportedException("APIUserWrapper does not provide permission access.");
+    }
+
+    @Override
+    public ObjectPermissionSet getActiveConnectionPermissions()
+            throws GuacamoleException {
+        throw new GuacamoleUnsupportedException("APIUserWrapper does not provide permission access.");
+    }
+
+}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/user/PermissionSetPatch.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/user/PermissionSetPatch.java
new file mode 100644
index 0000000..cb887e6
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/user/PermissionSetPatch.java
@@ -0,0 +1,98 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.basic.rest.user;
+
+import java.util.HashSet;
+import java.util.Set;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.net.auth.permission.Permission;
+import org.glyptodon.guacamole.net.auth.permission.PermissionSet;
+
+/**
+ * A set of changes to be applied to a PermissionSet, describing the set of
+ * permissions being added and removed.
+ * 
+ * @author Michael Jumper
+ * @param <PermissionType>
+ *     The type of permissions being added and removed.
+ */
+public class PermissionSetPatch<PermissionType extends Permission> {
+
+    /**
+     * The set of all permissions being added.
+     */
+    private final Set<PermissionType> addedPermissions =
+            new HashSet<PermissionType>();
+    
+    /**
+     * The set of all permissions being removed.
+     */
+    private final Set<PermissionType> removedPermissions =
+            new HashSet<PermissionType>();
+
+    /**
+     * Queues the given permission to be added. The add operation will be
+     * performed only when apply() is called.
+     *
+     * @param permission
+     *     The permission to add.
+     */
+    public void addPermission(PermissionType permission) {
+        addedPermissions.add(permission);
+    }
+    
+    /**
+     * Queues the given permission to be removed. The remove operation will be
+     * performed only when apply() is called.
+     *
+     * @param permission
+     *     The permission to remove.
+     */
+    public void removePermission(PermissionType permission) {
+        removedPermissions.add(permission);
+    }
+
+    /**
+     * Applies all queued changes to the given permission set.
+     *
+     * @param permissionSet
+     *     The permission set to add and remove permissions from.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while manipulating the permissions of the given
+     *     permission set.
+     */
+    public void apply(PermissionSet<PermissionType> permissionSet)
+        throws GuacamoleException {
+
+        // Add any added permissions
+        if (!addedPermissions.isEmpty())
+            permissionSet.addPermissions(addedPermissions);
+
+        // Remove any removed permissions
+        if (!removedPermissions.isEmpty())
+            permissionSet.removePermissions(removedPermissions);
+
+    }
+    
+}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/user/UserRESTService.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/user/UserRESTService.java
new file mode 100644
index 0000000..0aa9244
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/user/UserRESTService.java
@@ -0,0 +1,647 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.basic.rest.user;
+
+import com.google.inject.Inject;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.UUID;
+import javax.servlet.http.HttpServletRequest;
+import javax.ws.rs.Consumes;
+import javax.ws.rs.DELETE;
+import javax.ws.rs.GET;
+import javax.ws.rs.POST;
+import javax.ws.rs.PUT;
+import javax.ws.rs.Path;
+import javax.ws.rs.PathParam;
+import javax.ws.rs.Produces;
+import javax.ws.rs.QueryParam;
+import javax.ws.rs.core.Context;
+import javax.ws.rs.core.MediaType;
+import org.glyptodon.guacamole.GuacamoleClientException;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.GuacamoleResourceNotFoundException;
+import org.glyptodon.guacamole.GuacamoleSecurityException;
+import org.glyptodon.guacamole.net.auth.AuthenticationProvider;
+import org.glyptodon.guacamole.net.auth.Credentials;
+import org.glyptodon.guacamole.net.auth.Directory;
+import org.glyptodon.guacamole.net.auth.User;
+import org.glyptodon.guacamole.net.auth.UserContext;
+import org.glyptodon.guacamole.net.auth.credentials.GuacamoleCredentialsException;
+import org.glyptodon.guacamole.net.auth.permission.ObjectPermission;
+import org.glyptodon.guacamole.net.auth.permission.ObjectPermissionSet;
+import org.glyptodon.guacamole.net.auth.permission.Permission;
+import org.glyptodon.guacamole.net.auth.permission.SystemPermission;
+import org.glyptodon.guacamole.net.auth.permission.SystemPermissionSet;
+import org.glyptodon.guacamole.net.basic.GuacamoleSession;
+import org.glyptodon.guacamole.net.basic.rest.APIPatch;
+import static org.glyptodon.guacamole.net.basic.rest.APIPatch.Operation.add;
+import static org.glyptodon.guacamole.net.basic.rest.APIPatch.Operation.remove;
+import org.glyptodon.guacamole.net.basic.rest.ObjectRetrievalService;
+import org.glyptodon.guacamole.net.basic.rest.PATCH;
+import org.glyptodon.guacamole.net.basic.rest.auth.AuthenticationService;
+import org.glyptodon.guacamole.net.basic.rest.permission.APIPermissionSet;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * A REST Service for handling user CRUD operations.
+ * 
+ * @author James Muehlner
+ * @author Michael Jumper
+ */
+ at Path("/data/{dataSource}/users")
+ at Produces(MediaType.APPLICATION_JSON)
+ at Consumes(MediaType.APPLICATION_JSON)
+public class UserRESTService {
+
+    /**
+     * Logger for this class.
+     */
+    private static final Logger logger = LoggerFactory.getLogger(UserRESTService.class);
+
+    /**
+     * The prefix of any path within an operation of a JSON patch which
+     * modifies the permissions of a user regarding a specific connection.
+     */
+    private static final String CONNECTION_PERMISSION_PATCH_PATH_PREFIX = "/connectionPermissions/";
+    
+    /**
+     * The prefix of any path within an operation of a JSON patch which
+     * modifies the permissions of a user regarding a specific connection group.
+     */
+    private static final String CONNECTION_GROUP_PERMISSION_PATCH_PATH_PREFIX = "/connectionGroupPermissions/";
+
+    /**
+     * The prefix of any path within an operation of a JSON patch which
+     * modifies the permissions of a user regarding a specific active connection.
+     */
+    private static final String ACTIVE_CONNECTION_PERMISSION_PATCH_PATH_PREFIX = "/activeConnectionPermissions/";
+
+    /**
+     * The prefix of any path within an operation of a JSON patch which
+     * modifies the permissions of a user regarding another, specific user.
+     */
+    private static final String USER_PERMISSION_PATCH_PATH_PREFIX = "/userPermissions/";
+
+    /**
+     * The path of any operation within a JSON patch which modifies the
+     * permissions of a user regarding the entire system.
+     */
+    private static final String SYSTEM_PERMISSION_PATCH_PATH = "/systemPermissions";
+    
+    /**
+     * A service for authenticating users from auth tokens.
+     */
+    @Inject
+    private AuthenticationService authenticationService;
+    
+    /**
+     * Service for convenient retrieval of objects.
+     */
+    @Inject
+    private ObjectRetrievalService retrievalService;
+
+    /**
+     * Gets a list of users in the given data source (UserContext), filtering
+     * the returned list by the given permission, if specified.
+     *
+     * @param authToken
+     *     The authentication token that is used to authenticate the user
+     *     performing the operation.
+     *
+     * @param authProviderIdentifier
+     *     The unique identifier of the AuthenticationProvider associated with
+     *     the UserContext from which the users are to be retrieved.
+     *
+     * @param permissions
+     *     The set of permissions to filter with. A user must have one or more
+     *     of these permissions for a user to appear in the result.
+     *     If null, no filtering will be performed.
+     *
+     * @return
+     *     A list of all visible users. If a permission was specified, this
+     *     list will contain only those users for whom the current user has
+     *     that permission.
+     *
+     * @throws GuacamoleException
+     *     If an error is encountered while retrieving users.
+     */
+    @GET
+    public List<APIUser> getUsers(@QueryParam("token") String authToken,
+            @PathParam("dataSource") String authProviderIdentifier,
+            @QueryParam("permission") List<ObjectPermission.Type> permissions)
+            throws GuacamoleException {
+
+        GuacamoleSession session = authenticationService.getGuacamoleSession(authToken);
+        UserContext userContext = retrievalService.retrieveUserContext(session, authProviderIdentifier);
+
+        // An admin user has access to any user
+        User self = userContext.self();
+        SystemPermissionSet systemPermissions = self.getSystemPermissions();
+        boolean isAdmin = systemPermissions.hasPermission(SystemPermission.Type.ADMINISTER);
+
+        // Get the directory
+        Directory<User> userDirectory = userContext.getUserDirectory();
+
+        // Filter users, if requested
+        Collection<String> userIdentifiers = userDirectory.getIdentifiers();
+        if (!isAdmin && permissions != null && !permissions.isEmpty()) {
+            ObjectPermissionSet userPermissions = self.getUserPermissions();
+            userIdentifiers = userPermissions.getAccessibleObjects(permissions, userIdentifiers);
+        }
+            
+        // Retrieve all users, converting to API users
+        List<APIUser> apiUsers = new ArrayList<APIUser>();
+        for (User user : userDirectory.getAll(userIdentifiers))
+            apiUsers.add(new APIUser(user));
+
+        return apiUsers;
+
+    }
+    
+    /**
+     * Retrieves an individual user.
+     *
+     * @param authToken
+     *     The authentication token that is used to authenticate the user
+     *     performing the operation.
+     *
+     * @param authProviderIdentifier
+     *     The unique identifier of the AuthenticationProvider associated with
+     *     the UserContext from which the requested user is to be retrieved.
+     *
+     * @param username
+     *     The username of the user to retrieve.
+     *
+     * @return user
+     *     The user having the given username.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while retrieving the user.
+     */
+    @GET
+    @Path("/{username}")
+    public APIUser getUser(@QueryParam("token") String authToken,
+            @PathParam("dataSource") String authProviderIdentifier,
+            @PathParam("username") String username)
+            throws GuacamoleException {
+
+        GuacamoleSession session = authenticationService.getGuacamoleSession(authToken);
+
+        // Retrieve the requested user
+        User user = retrievalService.retrieveUser(session, authProviderIdentifier, username);
+        return new APIUser(user);
+
+    }
+    
+    /**
+     * Creates a new user and returns the user that was created.
+     *
+     * @param authToken
+     *     The authentication token that is used to authenticate the user
+     *     performing the operation.
+     *
+     * @param authProviderIdentifier
+     *     The unique identifier of the AuthenticationProvider associated with
+     *     the UserContext in which the requested user is to be created.
+     *
+     * @param user
+     *     The new user to create.
+     *
+     * @throws GuacamoleException
+     *     If a problem is encountered while creating the user.
+     *
+     * @return
+     *     The newly created user.
+     */
+    @POST
+    public APIUser createUser(@QueryParam("token") String authToken,
+            @PathParam("dataSource") String authProviderIdentifier, APIUser user)
+            throws GuacamoleException {
+
+        GuacamoleSession session = authenticationService.getGuacamoleSession(authToken);
+        UserContext userContext = retrievalService.retrieveUserContext(session, authProviderIdentifier);
+
+        // Get the directory
+        Directory<User> userDirectory = userContext.getUserDirectory();
+        
+        // Randomly set the password if it wasn't provided
+        if (user.getPassword() == null)
+            user.setPassword(UUID.randomUUID().toString());
+
+        // Create the user
+        userDirectory.add(new APIUserWrapper(user));
+
+        return user;
+
+    }
+    
+    /**
+     * Updates an individual existing user.
+     *
+     * @param authToken
+     *     The authentication token that is used to authenticate the user
+     *     performing the operation.
+     *
+     * @param authProviderIdentifier
+     *     The unique identifier of the AuthenticationProvider associated with
+     *     the UserContext in which the requested user is to be updated.
+     *
+     * @param username
+     *     The username of the user to update.
+     *
+     * @param user
+     *     The data to update the user with.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while updating the user.
+     */
+    @PUT
+    @Path("/{username}")
+    public void updateUser(@QueryParam("token") String authToken,
+            @PathParam("dataSource") String authProviderIdentifier,
+            @PathParam("username") String username, APIUser user) 
+            throws GuacamoleException {
+
+        GuacamoleSession session = authenticationService.getGuacamoleSession(authToken);
+        UserContext userContext = retrievalService.retrieveUserContext(session, authProviderIdentifier);
+
+        // Get the directory
+        Directory<User> userDirectory = userContext.getUserDirectory();
+
+        // Validate data and path are sane
+        if (!user.getUsername().equals(username))
+            throw new GuacamoleClientException("Username in path does not match username provided JSON data.");
+        
+        // A user may not use this endpoint to modify himself
+        if (userContext.self().getIdentifier().equals(user.getUsername()))
+            throw new GuacamoleSecurityException("Permission denied.");
+
+        // Get the user
+        User existingUser = retrievalService.retrieveUser(userContext, username);
+
+        // Do not update the user password if no password was provided
+        if (user.getPassword() != null)
+            existingUser.setPassword(user.getPassword());
+
+        // Update user attributes
+        existingUser.setAttributes(user.getAttributes());
+
+        // Update the user
+        userDirectory.update(existingUser);
+
+    }
+    
+    /**
+     * Updates the password for an individual existing user.
+     *
+     * @param authToken
+     *     The authentication token that is used to authenticate the user
+     *     performing the operation.
+     *
+     * @param authProviderIdentifier
+     *     The unique identifier of the AuthenticationProvider associated with
+     *     the UserContext in which the requested user is to be updated.
+     *
+     * @param username
+     *     The username of the user to update.
+     *
+     * @param userPasswordUpdate
+     *     The object containing the old password for the user, as well as the
+     *     new password to set for that user.
+     *
+     * @param request
+     *     The HttpServletRequest associated with the password update attempt.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while updating the user's password.
+     */
+    @PUT
+    @Path("/{username}/password")
+    public void updatePassword(@QueryParam("token") String authToken,
+            @PathParam("dataSource") String authProviderIdentifier,
+            @PathParam("username") String username,
+            APIUserPasswordUpdate userPasswordUpdate,
+            @Context HttpServletRequest request) throws GuacamoleException {
+
+        GuacamoleSession session = authenticationService.getGuacamoleSession(authToken);
+        UserContext userContext = retrievalService.retrieveUserContext(session, authProviderIdentifier);
+
+        // Build credentials
+        Credentials credentials = new Credentials();
+        credentials.setUsername(username);
+        credentials.setPassword(userPasswordUpdate.getOldPassword());
+        credentials.setRequest(request);
+        credentials.setSession(request.getSession(true));
+        
+        // Verify that the old password was correct
+        try {
+            AuthenticationProvider authProvider = userContext.getAuthenticationProvider();
+            if (authProvider.authenticateUser(credentials) == null)
+                throw new GuacamoleSecurityException("Permission denied.");
+        }
+
+        // Pass through any credentials exceptions as simple permission denied
+        catch (GuacamoleCredentialsException e) {
+            throw new GuacamoleSecurityException("Permission denied.");
+        }
+
+        // Get the user directory
+        Directory<User> userDirectory = userContext.getUserDirectory();
+        
+        // Get the user that we want to updates
+        User user = retrievalService.retrieveUser(userContext, username);
+        
+        // Set password to the newly provided one
+        user.setPassword(userPasswordUpdate.getNewPassword());
+        
+        // Update the user
+        userDirectory.update(user);
+        
+    }
+    
+    /**
+     * Deletes an individual existing user.
+     *
+     * @param authToken
+     *     The authentication token that is used to authenticate the user
+     *     performing the operation.
+     *
+     * @param authProviderIdentifier
+     *     The unique identifier of the AuthenticationProvider associated with
+     *     the UserContext from which the requested user is to be deleted.
+     *
+     * @param username
+     *     The username of the user to delete.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while deleting the user.
+     */
+    @DELETE
+    @Path("/{username}")
+    public void deleteUser(@QueryParam("token") String authToken,
+            @PathParam("dataSource") String authProviderIdentifier,
+            @PathParam("username") String username) 
+            throws GuacamoleException {
+
+        GuacamoleSession session = authenticationService.getGuacamoleSession(authToken);
+        UserContext userContext = retrievalService.retrieveUserContext(session, authProviderIdentifier);
+
+        // Get the directory
+        Directory<User> userDirectory = userContext.getUserDirectory();
+
+        // Get the user
+        User existingUser = userDirectory.get(username);
+        if (existingUser == null)
+            throw new GuacamoleResourceNotFoundException("No such user: \"" + username + "\"");
+
+        // Delete the user
+        userDirectory.remove(username);
+
+    }
+
+    /**
+     * Gets a list of permissions for the user with the given username.
+     * 
+     * @param authToken
+     *     The authentication token that is used to authenticate the user
+     *     performing the operation.
+     *
+     * @param authProviderIdentifier
+     *     The unique identifier of the AuthenticationProvider associated with
+     *     the UserContext in which the requested user is to be found.
+     *
+     * @param username
+     *     The username of the user to retrieve permissions for.
+     *
+     * @return
+     *     A list of all permissions granted to the specified user.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while retrieving permissions.
+     */
+    @GET
+    @Path("/{username}/permissions")
+    public APIPermissionSet getPermissions(@QueryParam("token") String authToken,
+            @PathParam("dataSource") String authProviderIdentifier,
+            @PathParam("username") String username) 
+            throws GuacamoleException {
+
+        GuacamoleSession session = authenticationService.getGuacamoleSession(authToken);
+        UserContext userContext = retrievalService.retrieveUserContext(session, authProviderIdentifier);
+
+        User user;
+
+        // If username is own username, just use self - might not have query permissions
+        if (userContext.self().getIdentifier().equals(username))
+            user = userContext.self();
+
+        // If not self, query corresponding user from directory
+        else {
+            user = userContext.getUserDirectory().get(username);
+            if (user == null)
+                throw new GuacamoleResourceNotFoundException("No such user: \"" + username + "\"");
+        }
+
+        return new APIPermissionSet(user);
+
+    }
+
+    /**
+     * Updates the given permission set patch by queuing an add or remove
+     * operation for the given permission based on the given patch operation.
+     *
+     * @param <PermissionType>
+     *     The type of permission stored within the permission set.
+     *
+     * @param operation
+     *     The patch operation to perform.
+     *
+     * @param permissionSetPatch
+     *     The permission set patch being modified.
+     *
+     * @param permission
+     *     The permission being added or removed from the set.
+     *
+     * @throws GuacamoleException
+     *     If the requested patch operation is not supported.
+     */
+    private <PermissionType extends Permission> void updatePermissionSet(
+            APIPatch.Operation operation,
+            PermissionSetPatch<PermissionType> permissionSetPatch,
+            PermissionType permission) throws GuacamoleException {
+
+        // Add or remove permission based on operation
+        switch (operation) {
+
+            // Add permission
+            case add:
+                permissionSetPatch.addPermission(permission);
+                break;
+
+            // Remove permission
+            case remove:
+                permissionSetPatch.removePermission(permission);
+                break;
+
+            // Unsupported patch operation
+            default:
+                throw new GuacamoleClientException("Unsupported patch operation: \"" + operation + "\"");
+
+        }
+
+    }
+    
+    /**
+     * Applies a given list of permission patches. Each patch specifies either
+     * an "add" or a "remove" operation for a permission type, represented by
+     * a string. Valid permission types depend on the path of each patch
+     * operation, as the path dictates the permission being modified, such as
+     * "/connectionPermissions/42" or "/systemPermissions".
+     * 
+     * @param authToken
+     *     The authentication token that is used to authenticate the user
+     *     performing the operation.
+     *
+     * @param authProviderIdentifier
+     *     The unique identifier of the AuthenticationProvider associated with
+     *     the UserContext in which the requested user is to be found.
+     *
+     * @param username
+     *     The username of the user to modify the permissions of.
+     *
+     * @param patches
+     *     The permission patches to apply for this request.
+     *
+     * @throws GuacamoleException
+     *     If a problem is encountered while modifying permissions.
+     */
+    @PATCH
+    @Path("/{username}/permissions")
+    public void patchPermissions(@QueryParam("token") String authToken,
+            @PathParam("dataSource") String authProviderIdentifier,
+            @PathParam("username") String username,
+            List<APIPatch<String>> patches) throws GuacamoleException {
+
+        GuacamoleSession session = authenticationService.getGuacamoleSession(authToken);
+        UserContext userContext = retrievalService.retrieveUserContext(session, authProviderIdentifier);
+
+        // Get the user
+        User user = userContext.getUserDirectory().get(username);
+        if (user == null)
+            throw new GuacamoleResourceNotFoundException("No such user: \"" + username + "\"");
+
+        // Permission patches for all types of permissions
+        PermissionSetPatch<ObjectPermission> connectionPermissionPatch       = new PermissionSetPatch<ObjectPermission>();
+        PermissionSetPatch<ObjectPermission> connectionGroupPermissionPatch  = new PermissionSetPatch<ObjectPermission>();
+        PermissionSetPatch<ObjectPermission> activeConnectionPermissionPatch = new PermissionSetPatch<ObjectPermission>();
+        PermissionSetPatch<ObjectPermission> userPermissionPatch             = new PermissionSetPatch<ObjectPermission>();
+        PermissionSetPatch<SystemPermission> systemPermissionPatch           = new PermissionSetPatch<SystemPermission>();
+        
+        // Apply all patch operations individually
+        for (APIPatch<String> patch : patches) {
+
+            String path = patch.getPath();
+
+            // Create connection permission if path has connection prefix
+            if (path.startsWith(CONNECTION_PERMISSION_PATCH_PATH_PREFIX)) {
+
+                // Get identifier and type from patch operation
+                String identifier = path.substring(CONNECTION_PERMISSION_PATCH_PATH_PREFIX.length());
+                ObjectPermission.Type type = ObjectPermission.Type.valueOf(patch.getValue());
+
+                // Create and update corresponding permission
+                ObjectPermission permission = new ObjectPermission(type, identifier);
+                updatePermissionSet(patch.getOp(), connectionPermissionPatch, permission);
+                
+            }
+
+            // Create connection group permission if path has connection group prefix
+            else if (path.startsWith(CONNECTION_GROUP_PERMISSION_PATCH_PATH_PREFIX)) {
+
+                // Get identifier and type from patch operation
+                String identifier = path.substring(CONNECTION_GROUP_PERMISSION_PATCH_PATH_PREFIX.length());
+                ObjectPermission.Type type = ObjectPermission.Type.valueOf(patch.getValue());
+
+                // Create and update corresponding permission
+                ObjectPermission permission = new ObjectPermission(type, identifier);
+                updatePermissionSet(patch.getOp(), connectionGroupPermissionPatch, permission);
+                
+            }
+
+            // Create active connection permission if path has active connection prefix
+            else if (path.startsWith(ACTIVE_CONNECTION_PERMISSION_PATCH_PATH_PREFIX)) {
+
+                // Get identifier and type from patch operation
+                String identifier = path.substring(ACTIVE_CONNECTION_PERMISSION_PATCH_PATH_PREFIX.length());
+                ObjectPermission.Type type = ObjectPermission.Type.valueOf(patch.getValue());
+
+                // Create and update corresponding permission
+                ObjectPermission permission = new ObjectPermission(type, identifier);
+                updatePermissionSet(patch.getOp(), activeConnectionPermissionPatch, permission);
+                
+            }
+
+            // Create user permission if path has user prefix
+            else if (path.startsWith(USER_PERMISSION_PATCH_PATH_PREFIX)) {
+
+                // Get identifier and type from patch operation
+                String identifier = path.substring(USER_PERMISSION_PATCH_PATH_PREFIX.length());
+                ObjectPermission.Type type = ObjectPermission.Type.valueOf(patch.getValue());
+
+                // Create and update corresponding permission
+                ObjectPermission permission = new ObjectPermission(type, identifier);
+                updatePermissionSet(patch.getOp(), userPermissionPatch, permission);
+
+            }
+
+            // Create system permission if path is system path
+            else if (path.equals(SYSTEM_PERMISSION_PATCH_PATH)) {
+
+                // Get identifier and type from patch operation
+                SystemPermission.Type type = SystemPermission.Type.valueOf(patch.getValue());
+
+                // Create and update corresponding permission
+                SystemPermission permission = new SystemPermission(type);
+                updatePermissionSet(patch.getOp(), systemPermissionPatch, permission);
+                
+            }
+
+            // Otherwise, the path is not supported
+            else
+                throw new GuacamoleClientException("Unsupported patch path: \"" + path + "\"");
+
+        } // end for each patch operation
+        
+        // Save the permission changes
+        connectionPermissionPatch.apply(user.getConnectionPermissions());
+        connectionGroupPermissionPatch.apply(user.getConnectionGroupPermissions());
+        activeConnectionPermissionPatch.apply(user.getActiveConnectionPermissions());
+        userPermissionPatch.apply(user.getUserPermissions());
+        systemPermissionPatch.apply(user.getSystemPermissions());
+
+    }
+
+}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/user/package-info.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/user/package-info.java
new file mode 100644
index 0000000..d45b5f4
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/rest/user/package-info.java
@@ -0,0 +1,27 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Classes related to the user manipulation aspect of the Guacamole REST API.
+ */
+package org.glyptodon.guacamole.net.basic.rest.user;
+
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/websocket/BasicGuacamoleWebSocketTunnelEndpoint.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/websocket/BasicGuacamoleWebSocketTunnelEndpoint.java
new file mode 100644
index 0000000..479d602
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/websocket/BasicGuacamoleWebSocketTunnelEndpoint.java
@@ -0,0 +1,120 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.basic.websocket;
+
+import com.google.inject.Provider;
+import java.util.Map;
+import javax.websocket.EndpointConfig;
+import javax.websocket.HandshakeResponse;
+import javax.websocket.Session;
+import javax.websocket.server.HandshakeRequest;
+import javax.websocket.server.ServerEndpointConfig;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.net.GuacamoleTunnel;
+import org.glyptodon.guacamole.net.basic.TunnelRequest;
+import org.glyptodon.guacamole.net.basic.TunnelRequestService;
+import org.glyptodon.guacamole.websocket.GuacamoleWebSocketTunnelEndpoint;
+
+/**
+ * Tunnel implementation which uses WebSocket as a tunnel backend, rather than
+ * HTTP, properly parsing connection IDs included in the connection request.
+ */
+public class BasicGuacamoleWebSocketTunnelEndpoint extends GuacamoleWebSocketTunnelEndpoint {
+
+    /**
+     * Unique string which shall be used to store the TunnelRequest
+     * associated with a WebSocket connection.
+     */
+    private static final String TUNNEL_REQUEST_PROPERTY = "WS_GUAC_TUNNEL_REQUEST";
+
+    /**
+     * Unique string which shall be used to store the TunnelRequestService to
+     * be used for processing TunnelRequests.
+     */
+    private static final String TUNNEL_REQUEST_SERVICE_PROPERTY = "WS_GUAC_TUNNEL_REQUEST_SERVICE";
+
+    /**
+     * Configurator implementation which stores the requested GuacamoleTunnel
+     * within the user properties. The GuacamoleTunnel will be later retrieved
+     * during the connection process.
+     */
+    public static class Configurator extends ServerEndpointConfig.Configurator {
+
+        /**
+         * Provider which provides instances of a service for handling
+         * tunnel requests.
+         */
+        private final Provider<TunnelRequestService> tunnelRequestServiceProvider;
+         
+        /**
+         * Creates a new Configurator which uses the given tunnel request
+         * service provider to retrieve the necessary service to handle new
+         * connections requests.
+         * 
+         * @param tunnelRequestServiceProvider
+         *     The tunnel request service provider to use for all new
+         *     connections.
+         */
+        public Configurator(Provider<TunnelRequestService> tunnelRequestServiceProvider) {
+            this.tunnelRequestServiceProvider = tunnelRequestServiceProvider;
+        }
+        
+        @Override
+        public void modifyHandshake(ServerEndpointConfig config,
+                HandshakeRequest request, HandshakeResponse response) {
+
+            super.modifyHandshake(config, request, response);
+            
+            // Store tunnel request and tunnel request service for retrieval
+            // upon WebSocket open
+            Map<String, Object> userProperties = config.getUserProperties();
+            userProperties.clear();
+            userProperties.put(TUNNEL_REQUEST_PROPERTY, new WebSocketTunnelRequest(request));
+            userProperties.put(TUNNEL_REQUEST_SERVICE_PROPERTY, tunnelRequestServiceProvider.get());
+
+        }
+        
+    }
+    
+    @Override
+    protected GuacamoleTunnel createTunnel(Session session,
+            EndpointConfig config) throws GuacamoleException {
+
+        Map<String, Object> userProperties = config.getUserProperties();
+
+        // Get original tunnel request
+        TunnelRequest tunnelRequest = (TunnelRequest) userProperties.get(TUNNEL_REQUEST_PROPERTY);
+        if (tunnelRequest == null)
+            return null;
+
+        // Get tunnel request service
+        TunnelRequestService tunnelRequestService = (TunnelRequestService) userProperties.get(TUNNEL_REQUEST_SERVICE_PROPERTY);
+        if (tunnelRequestService == null)
+            return null;
+
+        // Create and return tunnel
+        return tunnelRequestService.createTunnel(tunnelRequest);
+
+    }
+
+}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/websocket/WebSocketTunnelModule.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/websocket/WebSocketTunnelModule.java
new file mode 100644
index 0000000..69a62fa
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/websocket/WebSocketTunnelModule.java
@@ -0,0 +1,104 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.basic.websocket;
+
+import com.google.inject.Provider;
+import com.google.inject.servlet.ServletModule;
+import java.util.Arrays;
+import javax.websocket.DeploymentException;
+import javax.websocket.server.ServerContainer;
+import javax.websocket.server.ServerEndpointConfig;
+import org.glyptodon.guacamole.net.basic.TunnelLoader;
+import org.glyptodon.guacamole.net.basic.TunnelRequestService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Loads the JSR-356 WebSocket tunnel implementation.
+ * 
+ * @author Michael Jumper
+ */
+public class WebSocketTunnelModule extends ServletModule implements TunnelLoader {
+
+    /**
+     * Logger for this class.
+     */
+    private final Logger logger = LoggerFactory.getLogger(WebSocketTunnelModule.class);
+
+    @Override
+    public boolean isSupported() {
+
+        try {
+
+            // Attempt to find WebSocket servlet
+            Class.forName("javax.websocket.Endpoint");
+
+            // Support found
+            return true;
+
+        }
+
+        // If no such servlet class, this particular WebSocket support
+        // is not present
+        catch (ClassNotFoundException e) {}
+        catch (NoClassDefFoundError e) {}
+
+        // Support not found
+        return false;
+        
+    }
+    
+    @Override
+    public void configureServlets() {
+
+        logger.info("Loading JSR-356 WebSocket support...");
+
+        // Get container
+        ServerContainer container = (ServerContainer) getServletContext().getAttribute("javax.websocket.server.ServerContainer"); 
+        if (container == null) {
+            logger.warn("ServerContainer attribute required by JSR-356 is missing. Cannot load JSR-356 WebSocket support.");
+            return;
+        }
+
+        Provider<TunnelRequestService> tunnelRequestServiceProvider = getProvider(TunnelRequestService.class);
+
+        // Build configuration for WebSocket tunnel
+        ServerEndpointConfig config =
+                ServerEndpointConfig.Builder.create(BasicGuacamoleWebSocketTunnelEndpoint.class, "/websocket-tunnel")
+                                            .configurator(new BasicGuacamoleWebSocketTunnelEndpoint.Configurator(tunnelRequestServiceProvider))
+                                            .subprotocols(Arrays.asList(new String[]{"guacamole"}))
+                                            .build();
+
+        try {
+
+            // Add configuration to container
+            container.addEndpoint(config);
+
+        }
+        catch (DeploymentException e) {
+            logger.error("Unable to deploy WebSocket tunnel.", e);
+        }
+
+    }
+
+}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/websocket/WebSocketTunnelRequest.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/websocket/WebSocketTunnelRequest.java
new file mode 100644
index 0000000..6a687f8
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/websocket/WebSocketTunnelRequest.java
@@ -0,0 +1,70 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.basic.websocket;
+
+import java.util.List;
+import java.util.Map;
+import javax.websocket.server.HandshakeRequest;
+import org.glyptodon.guacamole.net.basic.TunnelRequest;
+
+/**
+ * WebSocket-specific implementation of TunnelRequest.
+ *
+ * @author Michael Jumper
+ */
+public class WebSocketTunnelRequest extends TunnelRequest {
+
+    /**
+     * All parameters passed via HTTP to the WebSocket handshake.
+     */
+    private final Map<String, List<String>> handshakeParameters;
+    
+    /**
+     * Creates a TunnelRequest implementation which delegates parameter and
+     * session retrieval to the given HandshakeRequest.
+     *
+     * @param request The HandshakeRequest to wrap.
+     */
+    public WebSocketTunnelRequest(HandshakeRequest request) {
+        this.handshakeParameters = request.getParameterMap();
+    }
+
+    @Override
+    public String getParameter(String name) {
+
+        // Pull list of values, if present
+        List<String> values = getParameterValues(name);
+        if (values == null || values.isEmpty())
+            return null;
+
+        // Return first parameter value arbitrarily
+        return values.get(0);
+
+    }
+
+    @Override
+    public List<String> getParameterValues(String name) {
+        return handshakeParameters.get(name);
+    }
+    
+}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/websocket/jetty8/BasicGuacamoleWebSocketTunnelServlet.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/websocket/jetty8/BasicGuacamoleWebSocketTunnelServlet.java
new file mode 100644
index 0000000..418e167
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/websocket/jetty8/BasicGuacamoleWebSocketTunnelServlet.java
@@ -0,0 +1,52 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.basic.websocket.jetty8;
+
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.net.GuacamoleTunnel;
+import org.glyptodon.guacamole.net.basic.TunnelRequestService;
+import org.glyptodon.guacamole.net.basic.TunnelRequest;
+
+/**
+ * Tunnel servlet implementation which uses WebSocket as a tunnel backend,
+ * rather than HTTP, properly parsing connection IDs included in the connection
+ * request.
+ */
+ at Singleton
+public class BasicGuacamoleWebSocketTunnelServlet extends GuacamoleWebSocketTunnelServlet {
+
+    /**
+     * Service for handling tunnel requests.
+     */
+    @Inject
+    private TunnelRequestService tunnelRequestService;
+ 
+    @Override
+    protected GuacamoleTunnel doConnect(TunnelRequest request)
+            throws GuacamoleException {
+        return tunnelRequestService.createTunnel(request);
+    }
+
+}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/websocket/jetty8/GuacamoleWebSocketTunnelServlet.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/websocket/jetty8/GuacamoleWebSocketTunnelServlet.java
new file mode 100644
index 0000000..8c09780
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/websocket/jetty8/GuacamoleWebSocketTunnelServlet.java
@@ -0,0 +1,232 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.basic.websocket.jetty8;
+
+import java.io.IOException;
+import javax.servlet.http.HttpServletRequest;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.io.GuacamoleReader;
+import org.glyptodon.guacamole.io.GuacamoleWriter;
+import org.glyptodon.guacamole.net.GuacamoleTunnel;
+import org.eclipse.jetty.websocket.WebSocket;
+import org.eclipse.jetty.websocket.WebSocket.Connection;
+import org.eclipse.jetty.websocket.WebSocketServlet;
+import org.glyptodon.guacamole.GuacamoleClientException;
+import org.glyptodon.guacamole.GuacamoleConnectionClosedException;
+import org.glyptodon.guacamole.net.basic.HTTPTunnelRequest;
+import org.glyptodon.guacamole.net.basic.TunnelRequest;
+import org.glyptodon.guacamole.protocol.GuacamoleStatus;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * A WebSocketServlet partial re-implementation of GuacamoleTunnelServlet.
+ *
+ * @author Michael Jumper
+ */
+public abstract class GuacamoleWebSocketTunnelServlet extends WebSocketServlet {
+
+    /**
+     * Logger for this class.
+     */
+    private static final Logger logger = LoggerFactory.getLogger(GuacamoleWebSocketTunnelServlet.class);
+    
+    /**
+     * The default, minimum buffer size for instructions.
+     */
+    private static final int BUFFER_SIZE = 8192;
+
+    /**
+     * Sends the given status on the given WebSocket connection and closes the
+     * connection.
+     *
+     * @param connection The WebSocket connection to close.
+     * @param guac_status The status to send.
+     */
+    public static void closeConnection(Connection connection,
+            GuacamoleStatus guac_status) {
+
+        connection.close(guac_status.getWebSocketCode(),
+                Integer.toString(guac_status.getGuacamoleStatusCode()));
+
+    }
+
+    @Override
+    public WebSocket doWebSocketConnect(HttpServletRequest request, String protocol) {
+
+        final TunnelRequest tunnelRequest = new HTTPTunnelRequest(request);
+
+        // Return new WebSocket which communicates through tunnel
+        return new WebSocket.OnTextMessage() {
+
+            /**
+             * The GuacamoleTunnel associated with the connected WebSocket. If
+             * the WebSocket has not yet been connected, this will be null.
+             */
+            private GuacamoleTunnel tunnel = null;
+
+            @Override
+            public void onMessage(String string) {
+
+                // Ignore inbound messages if there is no associated tunnel
+                if (tunnel == null)
+                    return;
+
+                GuacamoleWriter writer = tunnel.acquireWriter();
+
+                // Write message received
+                try {
+                    writer.write(string.toCharArray());
+                }
+                catch (GuacamoleConnectionClosedException e) {
+                    logger.debug("Connection to guacd closed.", e);
+                }
+                catch (GuacamoleException e) {
+                    logger.debug("WebSocket tunnel write failed.", e);
+                }
+
+                tunnel.releaseWriter();
+
+            }
+
+            @Override
+            public void onOpen(final Connection connection) {
+
+                try {
+                    tunnel = doConnect(tunnelRequest);
+                }
+                catch (GuacamoleException e) {
+                    logger.error("Creation of WebSocket tunnel to guacd failed: {}", e.getMessage());
+                    logger.debug("Error connecting WebSocket tunnel.", e);
+                    closeConnection(connection, e.getStatus());
+                    return;
+                }
+
+                // Do not start connection if tunnel does not exist
+                if (tunnel == null) {
+                    closeConnection(connection, GuacamoleStatus.RESOURCE_NOT_FOUND);
+                    return;
+                }
+
+                Thread readThread = new Thread() {
+
+                    @Override
+                    public void run() {
+
+                        StringBuilder buffer = new StringBuilder(BUFFER_SIZE);
+                        GuacamoleReader reader = tunnel.acquireReader();
+                        char[] readMessage;
+
+                        try {
+
+                            try {
+
+                                // Attempt to read
+                                while ((readMessage = reader.read()) != null) {
+
+                                    // Buffer message
+                                    buffer.append(readMessage);
+
+                                    // Flush if we expect to wait or buffer is getting full
+                                    if (!reader.available() || buffer.length() >= BUFFER_SIZE) {
+                                        connection.sendMessage(buffer.toString());
+                                        buffer.setLength(0);
+                                    }
+
+                                }
+
+                                // No more data
+                                closeConnection(connection, GuacamoleStatus.SUCCESS);
+                                
+                            }
+
+                            // Catch any thrown guacamole exception and attempt
+                            // to pass within the WebSocket connection, logging
+                            // each error appropriately.
+                            catch (GuacamoleClientException e) {
+                                logger.info("WebSocket connection terminated: {}", e.getMessage());
+                                logger.debug("WebSocket connection terminated due to client error.", e);
+                                closeConnection(connection, e.getStatus());
+                            }
+                            catch (GuacamoleConnectionClosedException e) {
+                                logger.debug("Connection to guacd closed.", e);
+                                closeConnection(connection, GuacamoleStatus.SUCCESS);
+                            }
+                            catch (GuacamoleException e) {
+                                logger.error("Connection to guacd terminated abnormally: {}", e.getMessage());
+                                logger.debug("Internal error during connection to guacd.", e);
+                                closeConnection(connection, e.getStatus());
+                            }
+
+                        }
+                        catch (IOException e) {
+                            logger.debug("WebSocket tunnel read failed due to I/O error.", e);
+                        }
+
+                    }
+
+                };
+
+                readThread.start();
+
+            }
+
+            @Override
+            public void onClose(int i, String string) {
+                try {
+                    if (tunnel != null)
+                        tunnel.close();
+                }
+                catch (GuacamoleException e) {
+                    logger.debug("Unable to close connection to guacd.", e);
+                }
+            }
+
+        };
+
+    }
+
+    /**
+     * Called whenever the JavaScript Guacamole client makes a connection
+     * request. It it up to the implementor of this function to define what
+     * conditions must be met for a tunnel to be configured and returned as a
+     * result of this connection request (whether some sort of credentials must
+     * be specified, for example).
+     *
+     * @param request
+     *     The TunnelRequest associated with the connection request received.
+     *     Any parameters specified along with the connection request can be
+     *     read from this object.
+     *
+     * @return
+     *     A newly constructed GuacamoleTunnel if successful, null otherwise.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while constructing the GuacamoleTunnel, or if the
+     *     conditions required for connection are not met.
+     */
+    protected abstract GuacamoleTunnel doConnect(TunnelRequest request)
+            throws GuacamoleException;
+
+}
+
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/websocket/jetty8/WebSocketTunnelModule.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/websocket/jetty8/WebSocketTunnelModule.java
new file mode 100644
index 0000000..9e94da9
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/websocket/jetty8/WebSocketTunnelModule.java
@@ -0,0 +1,73 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.basic.websocket.jetty8;
+
+import com.google.inject.servlet.ServletModule;
+import org.glyptodon.guacamole.net.basic.TunnelLoader;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Loads the Jetty 8 WebSocket tunnel implementation.
+ * 
+ * @author Michael Jumper
+ */
+public class WebSocketTunnelModule extends ServletModule implements TunnelLoader {
+
+    /**
+     * Logger for this class.
+     */
+    private final Logger logger = LoggerFactory.getLogger(WebSocketTunnelModule.class);
+
+    @Override
+    public boolean isSupported() {
+
+        try {
+
+            // Attempt to find WebSocket servlet
+            Class.forName("org.glyptodon.guacamole.net.basic.websocket.jetty8.BasicGuacamoleWebSocketTunnelServlet");
+
+            // Support found
+            return true;
+
+        }
+
+        // If no such servlet class, this particular WebSocket support
+        // is not present
+        catch (ClassNotFoundException e) {}
+        catch (NoClassDefFoundError e) {}
+
+        // Support not found
+        return false;
+        
+    }
+    
+    @Override
+    public void configureServlets() {
+
+        logger.info("Loading Jetty 8 WebSocket support...");
+        serve("/websocket-tunnel").with(BasicGuacamoleWebSocketTunnelServlet.class);
+
+    }
+
+}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/websocket/jetty8/package-info.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/websocket/jetty8/package-info.java
new file mode 100644
index 0000000..9b63a7d
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/websocket/jetty8/package-info.java
@@ -0,0 +1,27 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Jetty 8 WebSocket tunnel implementation. The classes here require Jetty 8.
+ */
+package org.glyptodon.guacamole.net.basic.websocket.jetty8;
+
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/websocket/jetty9/BasicGuacamoleWebSocketCreator.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/websocket/jetty9/BasicGuacamoleWebSocketCreator.java
new file mode 100644
index 0000000..c833ccc
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/websocket/jetty9/BasicGuacamoleWebSocketCreator.java
@@ -0,0 +1,72 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.basic.websocket.jetty9;
+
+import org.eclipse.jetty.websocket.api.UpgradeRequest;
+import org.eclipse.jetty.websocket.api.UpgradeResponse;
+import org.eclipse.jetty.websocket.servlet.WebSocketCreator;
+import org.glyptodon.guacamole.net.basic.TunnelRequestService;
+
+/**
+ * WebSocketCreator which selects the appropriate WebSocketListener
+ * implementation if the "guacamole" subprotocol is in use.
+ * 
+ * @author Michael Jumper
+ */
+public class BasicGuacamoleWebSocketCreator implements WebSocketCreator {
+
+    /**
+     * Service for handling tunnel requests.
+     */
+    private final TunnelRequestService tunnelRequestService;
+
+    /**
+     * Creates a new WebSocketCreator which uses the given TunnelRequestService
+     * to create new GuacamoleTunnels for inbound requests.
+     *
+     * @param tunnelRequestService The service to use for inbound tunnel
+     *                             requests.
+     */
+    public BasicGuacamoleWebSocketCreator(TunnelRequestService tunnelRequestService) {
+        this.tunnelRequestService = tunnelRequestService;
+    }
+
+    @Override
+    public Object createWebSocket(UpgradeRequest request, UpgradeResponse response) {
+
+        // Validate and use "guacamole" subprotocol
+        for (String subprotocol : request.getSubProtocols()) {
+
+            if ("guacamole".equals(subprotocol)) {
+                response.setAcceptedSubProtocol(subprotocol);
+                return new BasicGuacamoleWebSocketTunnelListener(tunnelRequestService);
+            }
+
+        }
+
+        // Invalid protocol
+        return null;
+
+    }
+
+}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/websocket/jetty9/BasicGuacamoleWebSocketTunnelListener.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/websocket/jetty9/BasicGuacamoleWebSocketTunnelListener.java
new file mode 100644
index 0000000..b44b562
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/websocket/jetty9/BasicGuacamoleWebSocketTunnelListener.java
@@ -0,0 +1,59 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.basic.websocket.jetty9;
+
+import org.eclipse.jetty.websocket.api.Session;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.net.GuacamoleTunnel;
+import org.glyptodon.guacamole.net.basic.TunnelRequestService;
+
+/**
+ * WebSocket listener implementation which properly parses connection IDs
+ * included in the connection request.
+ * 
+ * @author Michael Jumper
+ */
+public class BasicGuacamoleWebSocketTunnelListener extends GuacamoleWebSocketTunnelListener {
+
+    /**
+     * Service for handling tunnel requests.
+     */
+    private final TunnelRequestService tunnelRequestService;
+
+    /**
+     * Creates a new WebSocketListener which uses the given TunnelRequestService
+     * to create new GuacamoleTunnels for inbound requests.
+     *
+     * @param tunnelRequestService The service to use for inbound tunnel
+     *                             requests.
+     */
+    public BasicGuacamoleWebSocketTunnelListener(TunnelRequestService tunnelRequestService) {
+        this.tunnelRequestService = tunnelRequestService;
+    }
+
+    @Override
+    protected GuacamoleTunnel createTunnel(Session session) throws GuacamoleException {
+        return tunnelRequestService.createTunnel(new WebSocketTunnelRequest(session.getUpgradeRequest()));
+    }
+
+}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/websocket/jetty9/BasicGuacamoleWebSocketTunnelServlet.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/websocket/jetty9/BasicGuacamoleWebSocketTunnelServlet.java
new file mode 100644
index 0000000..4c495ae
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/websocket/jetty9/BasicGuacamoleWebSocketTunnelServlet.java
@@ -0,0 +1,54 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.basic.websocket.jetty9;
+
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import org.eclipse.jetty.websocket.servlet.WebSocketServlet;
+import org.eclipse.jetty.websocket.servlet.WebSocketServletFactory;
+import org.glyptodon.guacamole.net.basic.TunnelRequestService;
+
+/**
+ * A WebSocketServlet partial re-implementation of GuacamoleTunnelServlet.
+ *
+ * @author Michael Jumper
+ */
+ at Singleton
+public class BasicGuacamoleWebSocketTunnelServlet extends WebSocketServlet {
+
+    /**
+     * Service for handling tunnel requests.
+     */
+    @Inject
+    private TunnelRequestService tunnelRequestService;
+ 
+    @Override
+    public void configure(WebSocketServletFactory factory) {
+
+        // Register WebSocket implementation
+        factory.setCreator(new BasicGuacamoleWebSocketCreator(tunnelRequestService));
+        
+    }
+    
+}
+
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/websocket/jetty9/GuacamoleWebSocketTunnelListener.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/websocket/jetty9/GuacamoleWebSocketTunnelListener.java
new file mode 100644
index 0000000..802ebbf
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/websocket/jetty9/GuacamoleWebSocketTunnelListener.java
@@ -0,0 +1,243 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.basic.websocket.jetty9;
+
+import java.io.IOException;
+import org.eclipse.jetty.websocket.api.CloseStatus;
+import org.eclipse.jetty.websocket.api.RemoteEndpoint;
+import org.eclipse.jetty.websocket.api.Session;
+import org.eclipse.jetty.websocket.api.WebSocketListener;
+import org.glyptodon.guacamole.GuacamoleClientException;
+import org.glyptodon.guacamole.GuacamoleConnectionClosedException;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.io.GuacamoleReader;
+import org.glyptodon.guacamole.io.GuacamoleWriter;
+import org.glyptodon.guacamole.net.GuacamoleTunnel;
+import org.glyptodon.guacamole.protocol.GuacamoleStatus;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * WebSocket listener implementation which provides a Guacamole tunnel
+ * 
+ * @author Michael Jumper
+ */
+public abstract class GuacamoleWebSocketTunnelListener implements WebSocketListener {
+
+    /**
+     * The default, minimum buffer size for instructions.
+     */
+    private static final int BUFFER_SIZE = 8192;
+
+    /**
+     * Logger for this class.
+     */
+    private static final Logger logger = LoggerFactory.getLogger(BasicGuacamoleWebSocketTunnelServlet.class);
+
+    /**
+     * The underlying GuacamoleTunnel. WebSocket reads/writes will be handled
+     * as reads/writes to this tunnel.
+     */
+    private GuacamoleTunnel tunnel;
+ 
+    /**
+     * Sends the given status on the given WebSocket connection and closes the
+     * connection.
+     *
+     * @param session The outbound WebSocket connection to close.
+     * @param guac_status The status to send.
+     */
+    private void closeConnection(Session session, GuacamoleStatus guac_status) {
+
+        try {
+            int code = guac_status.getWebSocketCode();
+            String message = Integer.toString(guac_status.getGuacamoleStatusCode());
+            session.close(new CloseStatus(code, message));
+        }
+        catch (IOException e) {
+            logger.debug("Unable to close WebSocket connection.", e);
+        }
+
+    }
+
+    /**
+     * Returns a new tunnel for the given session. How this tunnel is created
+     * or retrieved is implementation-dependent.
+     *
+     * @param session The session associated with the active WebSocket
+     *                connection.
+     * @return A connected tunnel, or null if no such tunnel exists.
+     * @throws GuacamoleException If an error occurs while retrieving the
+     *                            tunnel, or if access to the tunnel is denied.
+     */
+    protected abstract GuacamoleTunnel createTunnel(Session session)
+            throws GuacamoleException;
+
+    @Override
+    public void onWebSocketConnect(final Session session) {
+
+        try {
+
+            // Get tunnel
+            tunnel = createTunnel(session);
+            if (tunnel == null) {
+                closeConnection(session, GuacamoleStatus.RESOURCE_NOT_FOUND);
+                return;
+            }
+
+        }
+        catch (GuacamoleException e) {
+            logger.error("Creation of WebSocket tunnel to guacd failed: {}", e.getMessage());
+            logger.debug("Error connecting WebSocket tunnel.", e);
+            closeConnection(session, e.getStatus());
+            return;
+        }
+
+        // Prepare read transfer thread
+        Thread readThread = new Thread() {
+
+            /**
+             * Remote (client) side of this connection
+             */
+            private final RemoteEndpoint remote = session.getRemote();
+                
+            @Override
+            public void run() {
+
+                StringBuilder buffer = new StringBuilder(BUFFER_SIZE);
+                GuacamoleReader reader = tunnel.acquireReader();
+                char[] readMessage;
+
+                try {
+
+                    try {
+
+                        // Attempt to read
+                        while ((readMessage = reader.read()) != null) {
+
+                            // Buffer message
+                            buffer.append(readMessage);
+
+                            // Flush if we expect to wait or buffer is getting full
+                            if (!reader.available() || buffer.length() >= BUFFER_SIZE) {
+                                remote.sendString(buffer.toString());
+                                buffer.setLength(0);
+                            }
+
+                        }
+
+                        // No more data
+                        closeConnection(session, GuacamoleStatus.SUCCESS);
+
+                    }
+
+                    // Catch any thrown guacamole exception and attempt
+                    // to pass within the WebSocket connection, logging
+                    // each error appropriately.
+                    catch (GuacamoleClientException e) {
+                        logger.info("WebSocket connection terminated: {}", e.getMessage());
+                        logger.debug("WebSocket connection terminated due to client error.", e);
+                        closeConnection(session, e.getStatus());
+                    }
+                    catch (GuacamoleConnectionClosedException e) {
+                        logger.debug("Connection to guacd closed.", e);
+                        closeConnection(session, GuacamoleStatus.SUCCESS);
+                    }
+                    catch (GuacamoleException e) {
+                        logger.error("Connection to guacd terminated abnormally: {}", e.getMessage());
+                        logger.debug("Internal error during connection to guacd.", e);
+                        closeConnection(session, e.getStatus());
+                    }
+
+                }
+                catch (IOException e) {
+                    logger.debug("I/O error prevents further reads.", e);
+                }
+
+            }
+
+        };
+
+        readThread.start();
+
+    }
+
+    @Override
+    public void onWebSocketText(String message) {
+
+        // Ignore inbound messages if there is no associated tunnel
+        if (tunnel == null)
+            return;
+
+        GuacamoleWriter writer = tunnel.acquireWriter();
+
+        try {
+            // Write received message
+            writer.write(message.toCharArray());
+        }
+        catch (GuacamoleConnectionClosedException e) {
+            logger.debug("Connection to guacd closed.", e);
+        }
+        catch (GuacamoleException e) {
+            logger.debug("WebSocket tunnel write failed.", e);
+        }
+
+        tunnel.releaseWriter();
+
+    }
+
+    @Override
+    public void onWebSocketBinary(byte[] payload, int offset, int length) {
+        throw new UnsupportedOperationException("Binary WebSocket messages are not supported.");
+    }
+
+    @Override
+    public void onWebSocketError(Throwable t) {
+
+        logger.debug("WebSocket tunnel closing due to error.", t);
+        
+        try {
+            if (tunnel != null)
+                tunnel.close();
+        }
+        catch (GuacamoleException e) {
+            logger.debug("Unable to close connection to guacd.", e);
+        }
+
+     }
+
+   
+    @Override
+    public void onWebSocketClose(int statusCode, String reason) {
+
+        try {
+            if (tunnel != null)
+                tunnel.close();
+        }
+        catch (GuacamoleException e) {
+            logger.debug("Unable to close connection to guacd.", e);
+        }
+        
+    }
+
+}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/websocket/jetty9/WebSocketTunnelModule.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/websocket/jetty9/WebSocketTunnelModule.java
new file mode 100644
index 0000000..de27e02
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/websocket/jetty9/WebSocketTunnelModule.java
@@ -0,0 +1,73 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.basic.websocket.jetty9;
+
+import com.google.inject.servlet.ServletModule;
+import org.glyptodon.guacamole.net.basic.TunnelLoader;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Loads the Jetty 9 WebSocket tunnel implementation.
+ * 
+ * @author Michael Jumper
+ */
+public class WebSocketTunnelModule extends ServletModule implements TunnelLoader {
+
+    /**
+     * Logger for this class.
+     */
+    private final Logger logger = LoggerFactory.getLogger(WebSocketTunnelModule.class);
+
+    @Override
+    public boolean isSupported() {
+
+        try {
+
+            // Attempt to find WebSocket servlet
+            Class.forName("org.glyptodon.guacamole.net.basic.websocket.jetty9.BasicGuacamoleWebSocketTunnelServlet");
+
+            // Support found
+            return true;
+
+        }
+
+        // If no such servlet class, this particular WebSocket support
+        // is not present
+        catch (ClassNotFoundException e) {}
+        catch (NoClassDefFoundError e) {}
+
+        // Support not found
+        return false;
+        
+    }
+    
+    @Override
+    public void configureServlets() {
+
+        logger.info("Loading Jetty 9 WebSocket support...");
+        serve("/websocket-tunnel").with(BasicGuacamoleWebSocketTunnelServlet.class);
+
+    }
+
+}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/websocket/jetty9/WebSocketTunnelRequest.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/websocket/jetty9/WebSocketTunnelRequest.java
new file mode 100644
index 0000000..5e71b38
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/websocket/jetty9/WebSocketTunnelRequest.java
@@ -0,0 +1,76 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.basic.websocket.jetty9;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import org.eclipse.jetty.websocket.api.UpgradeRequest;
+import org.glyptodon.guacamole.net.basic.TunnelRequest;
+
+/**
+ * Jetty 9 WebSocket-specific implementation of TunnelRequest.
+ *
+ * @author Michael Jumper
+ */
+public class WebSocketTunnelRequest extends TunnelRequest {
+
+    /**
+     * All parameters passed via HTTP to the WebSocket handshake.
+     */
+    private final Map<String, String[]> handshakeParameters;
+    
+    /**
+     * Creates a TunnelRequest implementation which delegates parameter and
+     * session retrieval to the given UpgradeRequest.
+     *
+     * @param request The UpgradeRequest to wrap.
+     */
+    public WebSocketTunnelRequest(UpgradeRequest request) {
+        this.handshakeParameters = request.getParameterMap();
+    }
+
+    @Override
+    public String getParameter(String name) {
+
+        // Pull list of values, if present
+        List<String> values = getParameterValues(name);
+        if (values == null || values.isEmpty())
+            return null;
+
+        // Return first parameter value arbitrarily
+        return values.get(0);
+
+    }
+
+    @Override
+    public List<String> getParameterValues(String name) {
+
+        String[] values = handshakeParameters.get(name);
+        if (values == null)
+            return null;
+
+        return Arrays.asList(values);
+    }
+    
+}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/websocket/jetty9/package-info.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/websocket/jetty9/package-info.java
new file mode 100644
index 0000000..dd41e55
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/websocket/jetty9/package-info.java
@@ -0,0 +1,28 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Jetty 9 WebSocket tunnel implementation. The classes here require at least
+ * Jetty 9, prior to Jetty 9.1 (when support for JSR 356 was implemented).
+ */
+package org.glyptodon.guacamole.net.basic.websocket.jetty9;
+
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/websocket/package-info.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/websocket/package-info.java
new file mode 100644
index 0000000..1d9d35e
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/websocket/package-info.java
@@ -0,0 +1,28 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Standard WebSocket tunnel implementation. The classes here require a recent
+ * servlet container that supports JSR 356.
+ */
+package org.glyptodon.guacamole.net.basic.websocket;
+
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/websocket/tomcat/BasicGuacamoleWebSocketTunnelServlet.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/websocket/tomcat/BasicGuacamoleWebSocketTunnelServlet.java
new file mode 100644
index 0000000..a891575
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/websocket/tomcat/BasicGuacamoleWebSocketTunnelServlet.java
@@ -0,0 +1,52 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.basic.websocket.tomcat;
+
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.net.GuacamoleTunnel;
+import org.glyptodon.guacamole.net.basic.TunnelRequestService;
+import org.glyptodon.guacamole.net.basic.TunnelRequest;
+
+/**
+ * Tunnel servlet implementation which uses WebSocket as a tunnel backend,
+ * rather than HTTP, properly parsing connection IDs included in the connection
+ * request.
+ */
+ at Singleton
+public class BasicGuacamoleWebSocketTunnelServlet extends GuacamoleWebSocketTunnelServlet {
+
+    /**
+     * Service for handling tunnel requests.
+     */
+    @Inject
+    private TunnelRequestService tunnelRequestService;
+ 
+    @Override
+    protected GuacamoleTunnel doConnect(TunnelRequest request)
+            throws GuacamoleException {
+        return tunnelRequestService.createTunnel(request);
+    };
+
+}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/websocket/tomcat/GuacamoleWebSocketTunnelServlet.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/websocket/tomcat/GuacamoleWebSocketTunnelServlet.java
new file mode 100644
index 0000000..fb94371
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/websocket/tomcat/GuacamoleWebSocketTunnelServlet.java
@@ -0,0 +1,265 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.basic.websocket.tomcat;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.Reader;
+import java.nio.ByteBuffer;
+import java.nio.CharBuffer;
+import java.util.List;
+import javax.servlet.http.HttpServletRequest;
+import org.glyptodon.guacamole.GuacamoleException;
+import org.glyptodon.guacamole.io.GuacamoleReader;
+import org.glyptodon.guacamole.io.GuacamoleWriter;
+import org.glyptodon.guacamole.net.GuacamoleTunnel;
+import org.apache.catalina.websocket.StreamInbound;
+import org.apache.catalina.websocket.WebSocketServlet;
+import org.apache.catalina.websocket.WsOutbound;
+import org.glyptodon.guacamole.GuacamoleClientException;
+import org.glyptodon.guacamole.GuacamoleConnectionClosedException;
+import org.glyptodon.guacamole.net.basic.HTTPTunnelRequest;
+import org.glyptodon.guacamole.net.basic.TunnelRequest;
+import org.glyptodon.guacamole.protocol.GuacamoleStatus;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * A WebSocketServlet partial re-implementation of GuacamoleTunnelServlet.
+ *
+ * @author Michael Jumper
+ */
+public abstract class GuacamoleWebSocketTunnelServlet extends WebSocketServlet {
+
+    /**
+     * The default, minimum buffer size for instructions.
+     */
+    private static final int BUFFER_SIZE = 8192;
+
+    /**
+     * Logger for this class.
+     */
+    private final Logger logger = LoggerFactory.getLogger(GuacamoleWebSocketTunnelServlet.class);
+
+    /**
+     * Sends the given status on the given WebSocket connection and closes the
+     * connection.
+     *
+     * @param outbound The outbound WebSocket connection to close.
+     * @param guac_status The status to send.
+     */
+    public void closeConnection(WsOutbound outbound, GuacamoleStatus guac_status) {
+
+        try {
+            byte[] message = Integer.toString(guac_status.getGuacamoleStatusCode()).getBytes("UTF-8");
+            outbound.close(guac_status.getWebSocketCode(), ByteBuffer.wrap(message));
+        }
+        catch (IOException e) {
+            logger.debug("Unable to close WebSocket tunnel.", e);
+        }
+
+    }
+
+    @Override
+    protected String selectSubProtocol(List<String> subProtocols) {
+
+        // Search for expected protocol
+        for (String protocol : subProtocols)
+            if ("guacamole".equals(protocol))
+                return "guacamole";
+        
+        // Otherwise, fail
+        return null;
+
+    }
+
+    @Override
+    public StreamInbound createWebSocketInbound(String protocol,
+            HttpServletRequest request) {
+
+        final TunnelRequest tunnelRequest = new HTTPTunnelRequest(request);
+
+        // Return new WebSocket which communicates through tunnel
+        return new StreamInbound() {
+
+            /**
+             * The GuacamoleTunnel associated with the connected WebSocket. If
+             * the WebSocket has not yet been connected, this will be null.
+             */
+            private GuacamoleTunnel tunnel = null;
+
+            @Override
+            protected void onTextData(Reader reader) throws IOException {
+
+                // Ignore inbound messages if there is no associated tunnel
+                if (tunnel == null)
+                    return;
+
+                GuacamoleWriter writer = tunnel.acquireWriter();
+
+                // Write all available data
+                try {
+
+                    char[] buffer = new char[BUFFER_SIZE];
+
+                    int num_read;
+                    while ((num_read = reader.read(buffer)) > 0)
+                        writer.write(buffer, 0, num_read);
+
+                }
+                catch (GuacamoleConnectionClosedException e) {
+                    logger.debug("Connection to guacd closed.", e);
+                }
+                catch (GuacamoleException e) {
+                    logger.debug("WebSocket tunnel write failed.", e);
+                }
+
+                tunnel.releaseWriter();
+            }
+
+            @Override
+            public void onOpen(final WsOutbound outbound) {
+
+                try {
+                    tunnel = doConnect(tunnelRequest);
+                }
+                catch (GuacamoleException e) {
+                    logger.error("Creation of WebSocket tunnel to guacd failed: {}", e.getMessage());
+                    logger.debug("Error connecting WebSocket tunnel.", e);
+                    closeConnection(outbound, e.getStatus());
+                    return;
+                }
+
+                // Do not start connection if tunnel does not exist
+                if (tunnel == null) {
+                    closeConnection(outbound, GuacamoleStatus.RESOURCE_NOT_FOUND);
+                    return;
+                }
+
+                Thread readThread = new Thread() {
+
+                    @Override
+                    public void run() {
+
+                        StringBuilder buffer = new StringBuilder(BUFFER_SIZE);
+                        GuacamoleReader reader = tunnel.acquireReader();
+                        char[] readMessage;
+
+                        try {
+
+                            try {
+
+                                // Attempt to read
+                                while ((readMessage = reader.read()) != null) {
+
+                                    // Buffer message
+                                    buffer.append(readMessage);
+
+                                    // Flush if we expect to wait or buffer is getting full
+                                    if (!reader.available() || buffer.length() >= BUFFER_SIZE) {
+                                        outbound.writeTextMessage(CharBuffer.wrap(buffer));
+                                        buffer.setLength(0);
+                                    }
+
+                                }
+
+                                // No more data
+                                closeConnection(outbound, GuacamoleStatus.SUCCESS);
+
+                            }
+
+                            // Catch any thrown guacamole exception and attempt
+                            // to pass within the WebSocket connection, logging
+                            // each error appropriately.
+                            catch (GuacamoleClientException e) {
+                                logger.info("WebSocket connection terminated: {}", e.getMessage());
+                                logger.debug("WebSocket connection terminated due to client error.", e);
+                                closeConnection(outbound, e.getStatus());
+                            }
+                            catch (GuacamoleConnectionClosedException e) {
+                                logger.debug("Connection to guacd closed.", e);
+                                closeConnection(outbound, GuacamoleStatus.SUCCESS);
+                            }
+                            catch (GuacamoleException e) {
+                                logger.error("Connection to guacd terminated abnormally: {}", e.getMessage());
+                                logger.debug("Internal error during connection to guacd.", e);
+                                closeConnection(outbound, e.getStatus());
+                            }
+
+                        }
+                        catch (IOException e) {
+                            logger.debug("I/O error prevents further reads.", e);
+                        }
+
+                    }
+
+                };
+
+                readThread.start();
+
+            }
+
+            @Override
+            public void onClose(int i) {
+                try {
+                    if (tunnel != null)
+                        tunnel.close();
+                }
+                catch (GuacamoleException e) {
+                    logger.debug("Unable to close connection to guacd.", e);
+                }
+            }
+
+            @Override
+            protected void onBinaryData(InputStream in) throws IOException {
+                throw new UnsupportedOperationException("Not supported yet.");
+            }
+
+        };
+
+    }
+
+    /**
+     * Called whenever the JavaScript Guacamole client makes a connection
+     * request. It it up to the implementor of this function to define what
+     * conditions must be met for a tunnel to be configured and returned as a
+     * result of this connection request (whether some sort of credentials must
+     * be specified, for example).
+     *
+     * @param request
+     *     The TunnelRequest associated with the connection request received.
+     *     Any parameters specified along with the connection request can be
+     *     read from this object.
+     *
+     * @return
+     *     A newly constructed GuacamoleTunnel if successful, null otherwise.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while constructing the GuacamoleTunnel, or if the
+     *     conditions required for connection are not met.
+     */
+    protected abstract GuacamoleTunnel doConnect(TunnelRequest request)
+            throws GuacamoleException;
+
+}
+
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/websocket/tomcat/WebSocketTunnelModule.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/websocket/tomcat/WebSocketTunnelModule.java
new file mode 100644
index 0000000..4328186
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/websocket/tomcat/WebSocketTunnelModule.java
@@ -0,0 +1,73 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.basic.websocket.tomcat;
+
+import com.google.inject.servlet.ServletModule;
+import org.glyptodon.guacamole.net.basic.TunnelLoader;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Loads the Jetty 9 WebSocket tunnel implementation.
+ * 
+ * @author Michael Jumper
+ */
+public class WebSocketTunnelModule extends ServletModule implements TunnelLoader {
+
+    /**
+     * Logger for this class.
+     */
+    private final Logger logger = LoggerFactory.getLogger(WebSocketTunnelModule.class);
+
+    @Override
+    public boolean isSupported() {
+
+        try {
+
+            // Attempt to find WebSocket servlet
+            Class.forName("org.glyptodon.guacamole.net.basic.websocket.tomcat.BasicGuacamoleWebSocketTunnelServlet");
+
+            // Support found
+            return true;
+
+        }
+
+        // If no such servlet class, this particular WebSocket support
+        // is not present
+        catch (ClassNotFoundException e) {}
+        catch (NoClassDefFoundError e) {}
+
+        // Support not found
+        return false;
+        
+    }
+    
+    @Override
+    public void configureServlets() {
+
+        logger.info("Loading Tomcat 7 WebSocket support...");
+        serve("/websocket-tunnel").with(BasicGuacamoleWebSocketTunnelServlet.class);
+
+    }
+
+}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/websocket/tomcat/package-info.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/websocket/tomcat/package-info.java
new file mode 100644
index 0000000..ee8f9c5
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/websocket/tomcat/package-info.java
@@ -0,0 +1,29 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Tomcat WebSocket tunnel implementation. The classes here require at least
+ * Tomcat 7.0, and may change significantly as there is no common WebSocket
+ * API for Java yet.
+ */
+package org.glyptodon.guacamole.net.basic.websocket.tomcat;
+
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/xml/DocumentHandler.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/xml/DocumentHandler.java
deleted file mode 100644
index 8bbe4b0..0000000
--- a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/xml/DocumentHandler.java
+++ /dev/null
@@ -1,196 +0,0 @@
-package org.glyptodon.guacamole.net.basic.xml;
-
-/*
- *  Guacamole - Clientless Remote Desktop
- *  Copyright (C) 2010  Michael Jumper
- *
- *  This program is free software: you can redistribute it and/or modify
- *  it under the terms of the GNU Affero General Public License as published by
- *  the Free Software Foundation, either version 3 of the License, or
- *  (at your option) any later version.
- *
- *  This program is distributed in the hope that it will be useful,
- *  but WITHOUT ANY WARRANTY; without even the implied warranty of
- *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- *  GNU Affero General Public License for more details.
- *
- *  You should have received a copy of the GNU Affero General Public License
- *  along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-import java.util.Deque;
-import java.util.LinkedList;
-import org.xml.sax.Attributes;
-import org.xml.sax.SAXException;
-import org.xml.sax.helpers.DefaultHandler;
-
-/**
- * A simple ContentHandler implementation which digests SAX document events and
- * produces simpler tag-level events, maintaining its own stack for the
- * convenience of the tag handlers.
- *
- * @author Mike Jumper
- */
-public class DocumentHandler extends DefaultHandler {
-
-    /**
-     * The name of the root element of the document.
-     */
-    private String rootElementName;
-
-    /**
-     * The handler which will be used to handle element events for the root
-     * element of the document.
-     */
-    private TagHandler root;
-
-    /**
-     * The stack of all states applicable to the current parser state. Each
-     * element of the stack references the TagHandler for the element being
-     * parsed at that level of the document, where the current element is
-     * last in the stack, and the root element is first.
-     */
-    private Deque<DocumentHandlerState> stack =
-            new LinkedList<DocumentHandlerState>();
-
-    /**
-     * Creates a new DocumentHandler which will use the given TagHandler
-     * to handle the root element.
-     *
-     * @param rootElementName The name of the root element of the document
-     *                        being handled.
-     * @param root The TagHandler to use for the root element.
-     */
-    public DocumentHandler(String rootElementName, TagHandler root) {
-        this.root = root;
-        this.rootElementName = rootElementName;
-    }
-
-    /**
-     * Returns the current element state. The current element state is the
-     * state of the element the parser is currently within.
-     *
-     * @return The current element state.
-     */
-    private DocumentHandlerState getCurrentState() {
-
-        // If no state, return null
-        if (stack.isEmpty())
-            return null;
-
-        return stack.getLast();
-    }
-
-    @Override
-    public void startElement(String uri, String localName, String qName,
-        Attributes attributes) throws SAXException {
-
-        // Get current state
-        DocumentHandlerState current = getCurrentState();
-
-        // Handler for tag just read
-        TagHandler handler;
-
-        // If no stack, use root handler
-        if (current == null) {
-
-            // Validate element name
-            if (!localName.equals(rootElementName))
-                throw new SAXException("Root element must be '" + rootElementName + "'");
-
-            handler = root;
-        }
-
-        // Otherwise, get handler from parent
-        else {
-            TagHandler parent_handler = current.getTagHandler();
-            handler = parent_handler.childElement(localName);
-        }
-
-        // If no handler returned, the element was not expected
-        if (handler == null)
-            throw new SAXException("Unexpected element: '" + localName + "'");
-
-        // Initialize handler
-        handler.init(attributes);
-
-        // Append new element state to stack
-        stack.addLast(new DocumentHandlerState(handler));
-
-    }
-
-    @Override
-    public void endElement(String uri, String localName, String qName)
-            throws SAXException {
-
-        // Pop last element from stack
-        DocumentHandlerState completed = stack.removeLast();
-
-        // Finish element by sending text content
-        completed.getTagHandler().complete(
-                completed.getTextContent().toString());
-
-    }
-
-    @Override
-    public void characters(char[] ch, int start, int length)
-            throws SAXException {
-
-        // Append received chunk to text content
-        getCurrentState().getTextContent().append(ch, start, length);
-
-    }
-
-    /**
-     * The current state of the DocumentHandler.
-     */
-    private static class DocumentHandlerState {
-
-        /**
-         * The current text content of the current element being parsed.
-         */
-        private StringBuilder textContent = new StringBuilder();
-
-        /**
-         * The TagHandler which must handle document events related to the
-         * element currently being parsed.
-         */
-        private TagHandler tagHandler;
-
-        /**
-         * Creates a new DocumentHandlerState which will maintain the state
-         * of parsing of the current element, as well as contain the TagHandler
-         * which will receive events related to that element.
-         *
-         * @param tagHandler The TagHandler which should receive any events
-         *                   related to the element being parsed.
-         */
-        public DocumentHandlerState(TagHandler tagHandler) {
-            this.tagHandler = tagHandler;
-        }
-
-        /**
-         * Returns the mutable StringBuilder which contains the current text
-         * content of the element being parsed.
-         *
-         * @return The mutable StringBuilder which contains the current text
-         *         content of the element being parsed.
-         */
-        public StringBuilder getTextContent() {
-            return textContent;
-        }
-
-        /**
-         * Returns the TagHandler which must handle any events relating to the
-         * element being parsed.
-         *
-         * @return The TagHandler which must handle any events relating to the
-         *         element being parsed.
-         */
-        public TagHandler getTagHandler() {
-            return tagHandler;
-        }
-
-    }
-
-}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/xml/TagHandler.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/xml/TagHandler.java
deleted file mode 100644
index 08b7ad1..0000000
--- a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/xml/TagHandler.java
+++ /dev/null
@@ -1,66 +0,0 @@
-package org.glyptodon.guacamole.net.basic.xml;
-
-/*
- *  Guacamole - Clientless Remote Desktop
- *  Copyright (C) 2010  Michael Jumper
- *
- *  This program is free software: you can redistribute it and/or modify
- *  it under the terms of the GNU Affero General Public License as published by
- *  the Free Software Foundation, either version 3 of the License, or
- *  (at your option) any later version.
- *
- *  This program is distributed in the hope that it will be useful,
- *  but WITHOUT ANY WARRANTY; without even the implied warranty of
- *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- *  GNU Affero General Public License for more details.
- *
- *  You should have received a copy of the GNU Affero General Public License
- *  along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-import org.xml.sax.Attributes;
-import org.xml.sax.SAXException;
-
-/**
- * A simple element-level event handler for events triggered by the
- * SAX-driven DocumentHandler parser.
- *
- * @author Mike Jumper
- */
-public interface TagHandler {
-
-    /**
-     * Called when a child element of the current element is parsed.
-     *
-     * @param localName The local name of the child element seen.
-     * @return The TagHandler which should handle all element-level events
-     *         related to the child element.
-     * @throws SAXException If the child element being parsed was not expected,
-     *                      or some other error prevents a proper TagHandler
-     *                      from being constructed for the child element.
-     */
-    public TagHandler childElement(String localName)
-            throws SAXException;
-
-    /**
-     * Called when the element corresponding to this TagHandler is first seen,
-     * just after an instance is created.
-     *
-     * @param attributes The attributes of the element seen.
-     * @throws SAXException If an error prevents a the TagHandler from being
-     *                      from being initialized.
-     */
-    public void init(Attributes attributes) throws SAXException;
-
-    /**
-     * Called when this element, and all child elements, have been fully parsed,
-     * and the entire text content of this element (if any) is available.
-     *
-     * @param textContent The full text content of this element, if any.
-     * @throws SAXException If the text content received is not valid for any
-     *                      reason, or the child elements parsed are not
-     *                      correct.
-     */
-    public void complete(String textContent) throws SAXException;
-
-}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/xml/package-info.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/xml/package-info.java
deleted file mode 100644
index 082fb4c..0000000
--- a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/xml/package-info.java
+++ /dev/null
@@ -1,7 +0,0 @@
-
-/**
- * Classes driving the SAX-based XML parser used by the Guacamole web
- * application.
- */
-package org.glyptodon.guacamole.net.basic.xml;
-
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/xml/protocol/OptionTagHandler.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/xml/protocol/OptionTagHandler.java
deleted file mode 100644
index 8c3f7a4..0000000
--- a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/xml/protocol/OptionTagHandler.java
+++ /dev/null
@@ -1,61 +0,0 @@
-package org.glyptodon.guacamole.net.basic.xml.protocol;
-
-/*
- *  Guacamole - Clientless Remote Desktop
- *  Copyright (C) 2010  Michael Jumper
- *
- *  This program is free software: you can redistribute it and/or modify
- *  it under the terms of the GNU Affero General Public License as published by
- *  the Free Software Foundation, either version 3 of the License, or
- *  (at your option) any later version.
- *
- *  This program is distributed in the hope that it will be useful,
- *  but WITHOUT ANY WARRANTY; without even the implied warranty of
- *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- *  GNU Affero General Public License for more details.
- *
- *  You should have received a copy of the GNU Affero General Public License
- *  along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-import org.glyptodon.guacamole.net.basic.ProtocolParameterOption;
-import org.glyptodon.guacamole.net.basic.xml.TagHandler;
-import org.xml.sax.Attributes;
-import org.xml.sax.SAXException;
-
-/**
- * TagHandler for the "option" element.
- *
- * @author Mike Jumper
- */
-public class OptionTagHandler implements TagHandler {
-
-    /**
-     * The option backing this option tag.
-     */
-    private ProtocolParameterOption option = new ProtocolParameterOption();
-
-    @Override
-    public void init(Attributes attributes) throws SAXException {
-        option.setValue(attributes.getValue("value"));
-    }
-
-    @Override
-    public TagHandler childElement(String localName) throws SAXException {
-        throw new SAXException("The 'param' tag can contain no elements.");
-    }
-
-    @Override
-    public void complete(String textContent) throws SAXException {
-        option.setTitle(textContent);
-    }
-
-    /**
-     * Returns the ProtocolParameterOption backing this tag.
-     * @return The ProtocolParameterOption backing this tag.
-     */
-    public ProtocolParameterOption asProtocolParameterOption() {
-        return option;
-    }
-
-}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/xml/protocol/ParamTagHandler.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/xml/protocol/ParamTagHandler.java
deleted file mode 100644
index b4223f7..0000000
--- a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/xml/protocol/ParamTagHandler.java
+++ /dev/null
@@ -1,112 +0,0 @@
-package org.glyptodon.guacamole.net.basic.xml.protocol;
-
-/*
- *  Guacamole - Clientless Remote Desktop
- *  Copyright (C) 2010  Michael Jumper
- *
- *  This program is free software: you can redistribute it and/or modify
- *  it under the terms of the GNU Affero General Public License as published by
- *  the Free Software Foundation, either version 3 of the License, or
- *  (at your option) any later version.
- *
- *  This program is distributed in the hope that it will be useful,
- *  but WITHOUT ANY WARRANTY; without even the implied warranty of
- *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- *  GNU Affero General Public License for more details.
- *
- *  You should have received a copy of the GNU Affero General Public License
- *  along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-import org.glyptodon.guacamole.net.basic.ProtocolParameter;
-import org.glyptodon.guacamole.net.basic.xml.TagHandler;
-import org.xml.sax.Attributes;
-import org.xml.sax.SAXException;
-
-/**
- * TagHandler for the "param" element.
- *
- * @author Mike Jumper
- */
-public class ParamTagHandler implements TagHandler {
-
-    /**
-     * The ProtocolParameter backing this tag handler.
-     */
-    private ProtocolParameter protocolParameter = new ProtocolParameter();
-
-    @Override
-    public void init(Attributes attributes) throws SAXException {
-
-        protocolParameter.setName(attributes.getValue("name"));
-        protocolParameter.setTitle(attributes.getValue("title"));
-        protocolParameter.setValue(attributes.getValue("value"));
-
-        // Parse type
-        String type = attributes.getValue("type");
-
-        // Text field
-        if ("text".equals(type))
-            protocolParameter.setType(ProtocolParameter.Type.TEXT);
-
-        // Numeric field
-        else if ("numeric".equals(type))
-            protocolParameter.setType(ProtocolParameter.Type.NUMERIC);
-
-        // Password field
-        else if ("password".equals(type))
-            protocolParameter.setType(ProtocolParameter.Type.PASSWORD);
-
-        // Enumerated field
-        else if ("enum".equals(type))
-            protocolParameter.setType(ProtocolParameter.Type.ENUM);
-
-        // Boolean field
-        else if ("boolean".equals(type)) {
-            protocolParameter.setType(ProtocolParameter.Type.BOOLEAN);
-
-            if(protocolParameter.getValue() == null)
-                throw new SAXException
-                        ("A value is required for the boolean parameter type.");
-        }
-
-        // Otherwise, fail with unrecognized type
-        else
-            throw new SAXException("Invalid parameter type: " + type);
-
-    }
-
-    @Override
-    public TagHandler childElement(String localName) throws SAXException {
-
-        // Start parsing of option tags
-        if (localName.equals("option")) {
-
-            // Get tag handler for option tag
-            OptionTagHandler tagHandler = new OptionTagHandler();
-
-            // Store stub in options collection
-            protocolParameter.getOptions().add(
-                tagHandler.asProtocolParameterOption());
-            return tagHandler;
-
-        }
-
-        return null;
-
-    }
-
-    @Override
-    public void complete(String textContent) throws SAXException {
-        // Do nothing
-    }
-
-    /**
-     * Returns the ProtocolParameter backing this tag.
-     * @return The ProtocolParameter backing this tag.
-     */
-    public ProtocolParameter asProtocolParameter() {
-        return protocolParameter;
-    }
-
-}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/xml/protocol/ProtocolTagHandler.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/xml/protocol/ProtocolTagHandler.java
deleted file mode 100644
index e532b3a..0000000
--- a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/xml/protocol/ProtocolTagHandler.java
+++ /dev/null
@@ -1,77 +0,0 @@
-package org.glyptodon.guacamole.net.basic.xml.protocol;
-
-/*
- *  Guacamole - Clientless Remote Desktop
- *  Copyright (C) 2010  Michael Jumper
- *
- *  This program is free software: you can redistribute it and/or modify
- *  it under the terms of the GNU Affero General Public License as published by
- *  the Free Software Foundation, either version 3 of the License, or
- *  (at your option) any later version.
- *
- *  This program is distributed in the hope that it will be useful,
- *  but WITHOUT ANY WARRANTY; without even the implied warranty of
- *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- *  GNU Affero General Public License for more details.
- *
- *  You should have received a copy of the GNU Affero General Public License
- *  along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-import org.glyptodon.guacamole.net.basic.ProtocolInfo;
-import org.glyptodon.guacamole.net.basic.xml.TagHandler;
-import org.xml.sax.Attributes;
-import org.xml.sax.SAXException;
-
-/**
- * TagHandler for the "protocol" element.
- *
- * @author Mike Jumper
- */
-public class ProtocolTagHandler implements TagHandler {
-
-    /**
-     * The ProtocolInfo object which will contain all data parsed by this tag
-     * handler.
-     */
-    private ProtocolInfo info = new ProtocolInfo();
-
-    @Override
-    public void init(Attributes attributes) throws SAXException {
-        info.setName(attributes.getValue("name"));
-        info.setTitle(attributes.getValue("title"));
-    }
-
-    @Override
-    public TagHandler childElement(String localName) throws SAXException {
-
-        // Start parsing of param tags, add to list of all parameters
-        if (localName.equals("param")) {
-
-            // Get tag handler for param tag
-            ParamTagHandler tagHandler = new ParamTagHandler();
-
-            // Store stub in parameters collection
-            info.getParameters().add(tagHandler.asProtocolParameter());
-            return tagHandler;
-
-        }
-
-        return null;
-
-    }
-
-    @Override
-    public void complete(String textContent) throws SAXException {
-        // Do nothing
-    }
-
-    /**
-     * Returns the ProtocolInfo backing this tag.
-     * @return The ProtocolInfo backing this tag.
-     */
-    public ProtocolInfo asProtocolInfo() {
-        return info;
-    }
-
-}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/xml/protocol/package-info.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/xml/protocol/package-info.java
deleted file mode 100644
index dbf7862..0000000
--- a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/xml/protocol/package-info.java
+++ /dev/null
@@ -1,7 +0,0 @@
-
-/**
- * Classes related to parsing XML files which describe the parameters of a
- * protocol.
- */
-package org.glyptodon.guacamole.net.basic.xml.protocol;
-
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/xml/user_mapping/AuthorizeTagHandler.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/xml/user_mapping/AuthorizeTagHandler.java
deleted file mode 100644
index 3e0d059..0000000
--- a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/xml/user_mapping/AuthorizeTagHandler.java
+++ /dev/null
@@ -1,147 +0,0 @@
-package org.glyptodon.guacamole.net.basic.xml.user_mapping;
-
-/*
- *  Guacamole - Clientless Remote Desktop
- *  Copyright (C) 2010  Michael Jumper
- *
- *  This program is free software: you can redistribute it and/or modify
- *  it under the terms of the GNU Affero General Public License as published by
- *  the Free Software Foundation, either version 3 of the License, or
- *  (at your option) any later version.
- *
- *  This program is distributed in the hope that it will be useful,
- *  but WITHOUT ANY WARRANTY; without even the implied warranty of
- *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- *  GNU Affero General Public License for more details.
- *
- *  You should have received a copy of the GNU Affero General Public License
- *  along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-import org.glyptodon.guacamole.net.basic.auth.Authorization;
-import org.glyptodon.guacamole.net.basic.auth.UserMapping;
-import org.glyptodon.guacamole.net.basic.xml.TagHandler;
-import org.glyptodon.guacamole.protocol.GuacamoleConfiguration;
-import org.xml.sax.Attributes;
-import org.xml.sax.SAXException;
-
-/**
- * TagHandler for the "authorize" element.
- *
- * @author Mike Jumper
- */
-public class AuthorizeTagHandler implements TagHandler {
-
-    /**
-     * The Authorization corresponding to the "authorize" tag being handled
-     * by this tag handler. The data of this Authorization will be populated
-     * as the tag is parsed.
-     */
-    private Authorization authorization = new Authorization();
-
-    /**
-     * The default GuacamoleConfiguration to use if "param" or "protocol"
-     * tags occur outside a "connection" tag.
-     */
-    private GuacamoleConfiguration default_config = null;
-
-    /**
-     * The UserMapping this authorization belongs to.
-     */
-    private UserMapping parent;
-
-    /**
-     * Creates a new AuthorizeTagHandler that parses an Authorization owned
-     * by the given UserMapping.
-     *
-     * @param parent The UserMapping that owns the Authorization this handler
-     *               will parse.
-     */
-    public AuthorizeTagHandler(UserMapping parent) {
-        this.parent = parent;
-    }
-
-    @Override
-    public void init(Attributes attributes) throws SAXException {
-
-        // Init username and password
-        authorization.setUsername(attributes.getValue("username"));
-        authorization.setPassword(attributes.getValue("password"));
-
-        // Get encoding
-        String encoding = attributes.getValue("encoding");
-        if (encoding != null) {
-
-            // If "md5", use MD5 encoding
-            if (encoding.equals("md5"))
-                authorization.setEncoding(Authorization.Encoding.MD5);
-
-            // If "plain", use plain text
-            else if (encoding.equals("plain"))
-                authorization.setEncoding(Authorization.Encoding.PLAIN_TEXT);
-
-            // Otherwise, bad encoding
-            else
-                throw new SAXException(
-                        "Invalid encoding: '" + encoding + "'");
-
-        }
-
-        parent.addAuthorization(this.asAuthorization());
-
-    }
-
-    @Override
-    public TagHandler childElement(String localName) throws SAXException {
-
-        // "connection" tag
-        if (localName.equals("connection"))
-            return new ConnectionTagHandler(authorization);
-
-        // "param" tag
-        if (localName.equals("param")) {
-
-            // Create default config if it doesn't exist
-            if (default_config == null) {
-                default_config = new GuacamoleConfiguration();
-                authorization.addConfiguration("DEFAULT", default_config);
-            }
-
-            return new ParamTagHandler(default_config);
-        }
-
-        // "protocol" tag
-        if (localName.equals("protocol")) {
-
-            // Create default config if it doesn't exist
-            if (default_config == null) {
-                default_config = new GuacamoleConfiguration();
-                authorization.addConfiguration("DEFAULT", default_config);
-            }
-
-            return new ProtocolTagHandler(default_config);
-        }
-
-        return null;
-
-    }
-
-    @Override
-    public void complete(String textContent) throws SAXException {
-        // Do nothing
-    }
-
-    /**
-     * Returns an Authorization backed by the data of this authorize tag
-     * handler. This Authorization is guaranteed to at least have the username,
-     * password, and encoding available. Any associated configurations will be
-     * added dynamically as the authorize tag is parsed.
-     *
-     * @return An Authorization backed by the data of this authorize tag
-     *         handler.
-     */
-    public Authorization asAuthorization() {
-        return authorization;
-    }
-
-}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/xml/user_mapping/ConnectionTagHandler.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/xml/user_mapping/ConnectionTagHandler.java
deleted file mode 100644
index ef30ddb..0000000
--- a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/xml/user_mapping/ConnectionTagHandler.java
+++ /dev/null
@@ -1,106 +0,0 @@
-package org.glyptodon.guacamole.net.basic.xml.user_mapping;
-
-/*
- *  Guacamole - Clientless Remote Desktop
- *  Copyright (C) 2010  Michael Jumper
- *
- *  This program is free software: you can redistribute it and/or modify
- *  it under the terms of the GNU Affero General Public License as published by
- *  the Free Software Foundation, either version 3 of the License, or
- *  (at your option) any later version.
- *
- *  This program is distributed in the hope that it will be useful,
- *  but WITHOUT ANY WARRANTY; without even the implied warranty of
- *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- *  GNU Affero General Public License for more details.
- *
- *  You should have received a copy of the GNU Affero General Public License
- *  along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-import org.glyptodon.guacamole.net.basic.auth.Authorization;
-import org.glyptodon.guacamole.net.basic.xml.TagHandler;
-import org.glyptodon.guacamole.protocol.GuacamoleConfiguration;
-import org.xml.sax.Attributes;
-import org.xml.sax.SAXException;
-
-/**
- * TagHandler for the "connection" element.
- *
- * @author Mike Jumper
- */
-public class ConnectionTagHandler implements TagHandler {
-
-    /**
-     * The GuacamoleConfiguration backing this tag handler.
-     */
-    private GuacamoleConfiguration config = new GuacamoleConfiguration();
-
-    /**
-     * The name associated with the connection being parsed.
-     */
-    private String name;
-
-    /**
-     * The Authorization this connection belongs to.
-     */
-    private Authorization parent;
-
-    /**
-     * Creates a new ConnectionTagHandler that parses a Connection owned by
-     * the given Authorization.
-     *
-     * @param parent The Authorization that will own this Connection once
-     *               parsed.
-     */
-    public ConnectionTagHandler(Authorization parent) {
-        this.parent = parent;
-    }
-
-    @Override
-    public void init(Attributes attributes) throws SAXException {
-        name = attributes.getValue("name");
-        parent.addConfiguration(name, this.asGuacamoleConfiguration());
-    }
-
-    @Override
-    public TagHandler childElement(String localName) throws SAXException {
-
-        if (localName.equals("param"))
-            return new ParamTagHandler(config);
-
-        if (localName.equals("protocol"))
-            return new ProtocolTagHandler(config);
-
-        return null;
-
-    }
-
-    @Override
-    public void complete(String textContent) throws SAXException {
-        // Do nothing
-    }
-
-    /**
-     * Returns a GuacamoleConfiguration whose contents are populated from data
-     * within this connection element and child elements. This
-     * GuacamoleConfiguration will continue to be modified as the user mapping
-     * is parsed.
-     *
-     * @return A GuacamoleConfiguration whose contents are populated from data
-     *         within this connection element.
-     */
-    public GuacamoleConfiguration asGuacamoleConfiguration() {
-        return config;
-    }
-
-    /**
-     * Returns the name associated with this connection.
-     *
-     * @return The name associated with this connection.
-     */
-    public String getName() {
-        return name;
-    }
-
-}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/xml/user_mapping/ParamTagHandler.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/xml/user_mapping/ParamTagHandler.java
deleted file mode 100644
index 1e65e2c..0000000
--- a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/xml/user_mapping/ParamTagHandler.java
+++ /dev/null
@@ -1,70 +0,0 @@
-package org.glyptodon.guacamole.net.basic.xml.user_mapping;
-
-/*
- *  Guacamole - Clientless Remote Desktop
- *  Copyright (C) 2010  Michael Jumper
- *
- *  This program is free software: you can redistribute it and/or modify
- *  it under the terms of the GNU Affero General Public License as published by
- *  the Free Software Foundation, either version 3 of the License, or
- *  (at your option) any later version.
- *
- *  This program is distributed in the hope that it will be useful,
- *  but WITHOUT ANY WARRANTY; without even the implied warranty of
- *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- *  GNU Affero General Public License for more details.
- *
- *  You should have received a copy of the GNU Affero General Public License
- *  along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-import org.glyptodon.guacamole.net.basic.xml.TagHandler;
-import org.glyptodon.guacamole.protocol.GuacamoleConfiguration;
-import org.xml.sax.Attributes;
-import org.xml.sax.SAXException;
-
-/**
- * TagHandler for the "param" element.
- *
- * @author Mike Jumper
- */
-public class ParamTagHandler implements TagHandler {
-
-    /**
-     * The GuacamoleConfiguration which will be populated with data from
-     * the tag handled by this tag handler.
-     */
-    private GuacamoleConfiguration config;
-
-    /**
-     * The name of the parameter.
-     */
-    private String name;
-
-    /**
-     * Creates a new handler for an "param" tag having the given
-     * attributes.
-     *
-     * @param config The GuacamoleConfiguration to update with the data parsed
-     *               from the "protocol" tag.
-     */
-    public ParamTagHandler(GuacamoleConfiguration config) {
-        this.config = config;
-    }
-
-    @Override
-    public void init(Attributes attributes) throws SAXException {
-        this.name = attributes.getValue("name");
-    }
-
-    @Override
-    public TagHandler childElement(String localName) throws SAXException {
-        throw new SAXException("The 'param' tag can contain no elements.");
-    }
-
-    @Override
-    public void complete(String textContent) throws SAXException {
-        config.setParameter(name, textContent);
-    }
-
-}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/xml/user_mapping/ProtocolTagHandler.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/xml/user_mapping/ProtocolTagHandler.java
deleted file mode 100644
index 14f0753..0000000
--- a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/xml/user_mapping/ProtocolTagHandler.java
+++ /dev/null
@@ -1,66 +0,0 @@
-package org.glyptodon.guacamole.net.basic.xml.user_mapping;
-
-/*
- *  Guacamole - Clientless Remote Desktop
- *  Copyright (C) 2010  Michael Jumper
- *
- *  This program is free software: you can redistribute it and/or modify
- *  it under the terms of the GNU Affero General Public License as published by
- *  the Free Software Foundation, either version 3 of the License, or
- *  (at your option) any later version.
- *
- *  This program is distributed in the hope that it will be useful,
- *  but WITHOUT ANY WARRANTY; without even the implied warranty of
- *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- *  GNU Affero General Public License for more details.
- *
- *  You should have received a copy of the GNU Affero General Public License
- *  along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-import org.glyptodon.guacamole.net.basic.xml.TagHandler;
-import org.glyptodon.guacamole.protocol.GuacamoleConfiguration;
-import org.xml.sax.Attributes;
-import org.xml.sax.SAXException;
-
-/**
- * TagHandler for the "protocol" element.
- *
- * @author Mike Jumper
- */
-public class ProtocolTagHandler implements TagHandler {
-
-    /**
-     * The GuacamoleConfiguration which will be populated with data from
-     * the tag handled by this tag handler.
-     */
-    private GuacamoleConfiguration config;
-
-    /**
-     * Creates a new handler for a "protocol" tag having the given
-     * attributes.
-     *
-     * @param config The GuacamoleConfiguration to update with the data parsed
-     *               from the "protocol" tag.
-     * @throws SAXException If the attributes given are not valid.
-     */
-    public ProtocolTagHandler(GuacamoleConfiguration config) throws SAXException {
-        this.config = config;
-    }
-
-    @Override
-    public void init(Attributes attributes) throws SAXException {
-        // Do nothing
-    }
-
-    @Override
-    public TagHandler childElement(String localName) throws SAXException {
-        throw new SAXException("The 'protocol' tag can contain no elements.");
-    }
-
-    @Override
-    public void complete(String textContent) throws SAXException {
-        config.setProtocol(textContent);
-    }
-
-}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/xml/user_mapping/UserMappingTagHandler.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/xml/user_mapping/UserMappingTagHandler.java
deleted file mode 100644
index 8160a58..0000000
--- a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/xml/user_mapping/UserMappingTagHandler.java
+++ /dev/null
@@ -1,74 +0,0 @@
-package org.glyptodon.guacamole.net.basic.xml.user_mapping;
-
-/*
- *  Guacamole - Clientless Remote Desktop
- *  Copyright (C) 2010  Michael Jumper
- *
- *  This program is free software: you can redistribute it and/or modify
- *  it under the terms of the GNU Affero General Public License as published by
- *  the Free Software Foundation, either version 3 of the License, or
- *  (at your option) any later version.
- *
- *  This program is distributed in the hope that it will be useful,
- *  but WITHOUT ANY WARRANTY; without even the implied warranty of
- *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- *  GNU Affero General Public License for more details.
- *
- *  You should have received a copy of the GNU Affero General Public License
- *  along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-import org.glyptodon.guacamole.net.basic.auth.UserMapping;
-import org.glyptodon.guacamole.net.basic.xml.TagHandler;
-import org.xml.sax.Attributes;
-import org.xml.sax.SAXException;
-
-/**
- * TagHandler for the "user-mapping" element.
- *
- * @author Mike Jumper
- */
-public class UserMappingTagHandler implements TagHandler {
-
-    /**
-     * The UserMapping which will contain all data parsed by this tag handler.
-     */
-    private UserMapping user_mapping = new UserMapping();
-
-    @Override
-    public void init(Attributes attributes) throws SAXException {
-        // Do nothing
-    }
-
-    @Override
-    public TagHandler childElement(String localName) throws SAXException {
-
-        // Start parsing of authorize tags, add to list of all authorizations
-        if (localName.equals("authorize"))
-            return new AuthorizeTagHandler(user_mapping);
-
-        return null;
-
-    }
-
-    @Override
-    public void complete(String textContent) throws SAXException {
-        // Do nothing
-    }
-
-    /**
-     * Returns a user mapping containing all authorizations and configurations
-     * parsed so far. This user mapping will be backed by the data being parsed,
-     * thus any additional authorizations or configurations will be available
-     * in the object returned by this function even after this function has
-     * returned, once the data corresponding to those authorizations or
-     * configurations has been parsed.
-     *
-     * @return A user mapping containing all authorizations and configurations
-     *         parsed so far.
-     */
-    public UserMapping asUserMapping() {
-        return user_mapping;
-    }
-
-}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/xml/user_mapping/package-info.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/xml/user_mapping/package-info.java
deleted file mode 100644
index ae85ac7..0000000
--- a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/xml/user_mapping/package-info.java
+++ /dev/null
@@ -1,6 +0,0 @@
-
-/**
- * Classes related to parsing the user-mapping.xml file.
- */
-package org.glyptodon.guacamole.net.basic.xml.user_mapping;
-
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/xml/usermapping/AuthorizeTagHandler.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/xml/usermapping/AuthorizeTagHandler.java
new file mode 100644
index 0000000..0b7788a
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/xml/usermapping/AuthorizeTagHandler.java
@@ -0,0 +1,151 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.basic.xml.usermapping;
+
+import org.glyptodon.guacamole.net.basic.auth.Authorization;
+import org.glyptodon.guacamole.net.basic.auth.UserMapping;
+import org.glyptodon.guacamole.xml.TagHandler;
+import org.glyptodon.guacamole.protocol.GuacamoleConfiguration;
+import org.xml.sax.Attributes;
+import org.xml.sax.SAXException;
+
+/**
+ * TagHandler for the "authorize" element.
+ *
+ * @author Mike Jumper
+ */
+public class AuthorizeTagHandler implements TagHandler {
+
+    /**
+     * The Authorization corresponding to the "authorize" tag being handled
+     * by this tag handler. The data of this Authorization will be populated
+     * as the tag is parsed.
+     */
+    private Authorization authorization = new Authorization();
+
+    /**
+     * The default GuacamoleConfiguration to use if "param" or "protocol"
+     * tags occur outside a "connection" tag.
+     */
+    private GuacamoleConfiguration default_config = null;
+
+    /**
+     * The UserMapping this authorization belongs to.
+     */
+    private UserMapping parent;
+
+    /**
+     * Creates a new AuthorizeTagHandler that parses an Authorization owned
+     * by the given UserMapping.
+     *
+     * @param parent The UserMapping that owns the Authorization this handler
+     *               will parse.
+     */
+    public AuthorizeTagHandler(UserMapping parent) {
+        this.parent = parent;
+    }
+
+    @Override
+    public void init(Attributes attributes) throws SAXException {
+
+        // Init username and password
+        authorization.setUsername(attributes.getValue("username"));
+        authorization.setPassword(attributes.getValue("password"));
+
+        // Get encoding
+        String encoding = attributes.getValue("encoding");
+        if (encoding != null) {
+
+            // If "md5", use MD5 encoding
+            if (encoding.equals("md5"))
+                authorization.setEncoding(Authorization.Encoding.MD5);
+
+            // If "plain", use plain text
+            else if (encoding.equals("plain"))
+                authorization.setEncoding(Authorization.Encoding.PLAIN_TEXT);
+
+            // Otherwise, bad encoding
+            else
+                throw new SAXException(
+                        "Invalid encoding: '" + encoding + "'");
+
+        }
+
+        parent.addAuthorization(this.asAuthorization());
+
+    }
+
+    @Override
+    public TagHandler childElement(String localName) throws SAXException {
+
+        // "connection" tag
+        if (localName.equals("connection"))
+            return new ConnectionTagHandler(authorization);
+
+        // "param" tag
+        if (localName.equals("param")) {
+
+            // Create default config if it doesn't exist
+            if (default_config == null) {
+                default_config = new GuacamoleConfiguration();
+                authorization.addConfiguration("DEFAULT", default_config);
+            }
+
+            return new ParamTagHandler(default_config);
+        }
+
+        // "protocol" tag
+        if (localName.equals("protocol")) {
+
+            // Create default config if it doesn't exist
+            if (default_config == null) {
+                default_config = new GuacamoleConfiguration();
+                authorization.addConfiguration("DEFAULT", default_config);
+            }
+
+            return new ProtocolTagHandler(default_config);
+        }
+
+        return null;
+
+    }
+
+    @Override
+    public void complete(String textContent) throws SAXException {
+        // Do nothing
+    }
+
+    /**
+     * Returns an Authorization backed by the data of this authorize tag
+     * handler. This Authorization is guaranteed to at least have the username,
+     * password, and encoding available. Any associated configurations will be
+     * added dynamically as the authorize tag is parsed.
+     *
+     * @return An Authorization backed by the data of this authorize tag
+     *         handler.
+     */
+    public Authorization asAuthorization() {
+        return authorization;
+    }
+
+}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/xml/usermapping/ConnectionTagHandler.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/xml/usermapping/ConnectionTagHandler.java
new file mode 100644
index 0000000..2f2884a
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/xml/usermapping/ConnectionTagHandler.java
@@ -0,0 +1,110 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.basic.xml.usermapping;
+
+import org.glyptodon.guacamole.net.basic.auth.Authorization;
+import org.glyptodon.guacamole.xml.TagHandler;
+import org.glyptodon.guacamole.protocol.GuacamoleConfiguration;
+import org.xml.sax.Attributes;
+import org.xml.sax.SAXException;
+
+/**
+ * TagHandler for the "connection" element.
+ *
+ * @author Mike Jumper
+ */
+public class ConnectionTagHandler implements TagHandler {
+
+    /**
+     * The GuacamoleConfiguration backing this tag handler.
+     */
+    private GuacamoleConfiguration config = new GuacamoleConfiguration();
+
+    /**
+     * The name associated with the connection being parsed.
+     */
+    private String name;
+
+    /**
+     * The Authorization this connection belongs to.
+     */
+    private Authorization parent;
+
+    /**
+     * Creates a new ConnectionTagHandler that parses a Connection owned by
+     * the given Authorization.
+     *
+     * @param parent The Authorization that will own this Connection once
+     *               parsed.
+     */
+    public ConnectionTagHandler(Authorization parent) {
+        this.parent = parent;
+    }
+
+    @Override
+    public void init(Attributes attributes) throws SAXException {
+        name = attributes.getValue("name");
+        parent.addConfiguration(name, this.asGuacamoleConfiguration());
+    }
+
+    @Override
+    public TagHandler childElement(String localName) throws SAXException {
+
+        if (localName.equals("param"))
+            return new ParamTagHandler(config);
+
+        if (localName.equals("protocol"))
+            return new ProtocolTagHandler(config);
+
+        return null;
+
+    }
+
+    @Override
+    public void complete(String textContent) throws SAXException {
+        // Do nothing
+    }
+
+    /**
+     * Returns a GuacamoleConfiguration whose contents are populated from data
+     * within this connection element and child elements. This
+     * GuacamoleConfiguration will continue to be modified as the user mapping
+     * is parsed.
+     *
+     * @return A GuacamoleConfiguration whose contents are populated from data
+     *         within this connection element.
+     */
+    public GuacamoleConfiguration asGuacamoleConfiguration() {
+        return config;
+    }
+
+    /**
+     * Returns the name associated with this connection.
+     *
+     * @return The name associated with this connection.
+     */
+    public String getName() {
+        return name;
+    }
+
+}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/xml/usermapping/ParamTagHandler.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/xml/usermapping/ParamTagHandler.java
new file mode 100644
index 0000000..f821491
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/xml/usermapping/ParamTagHandler.java
@@ -0,0 +1,74 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.basic.xml.usermapping;
+
+import org.glyptodon.guacamole.xml.TagHandler;
+import org.glyptodon.guacamole.protocol.GuacamoleConfiguration;
+import org.xml.sax.Attributes;
+import org.xml.sax.SAXException;
+
+/**
+ * TagHandler for the "param" element.
+ *
+ * @author Mike Jumper
+ */
+public class ParamTagHandler implements TagHandler {
+
+    /**
+     * The GuacamoleConfiguration which will be populated with data from
+     * the tag handled by this tag handler.
+     */
+    private GuacamoleConfiguration config;
+
+    /**
+     * The name of the parameter.
+     */
+    private String name;
+
+    /**
+     * Creates a new handler for an "param" tag having the given
+     * attributes.
+     *
+     * @param config The GuacamoleConfiguration to update with the data parsed
+     *               from the "protocol" tag.
+     */
+    public ParamTagHandler(GuacamoleConfiguration config) {
+        this.config = config;
+    }
+
+    @Override
+    public void init(Attributes attributes) throws SAXException {
+        this.name = attributes.getValue("name");
+    }
+
+    @Override
+    public TagHandler childElement(String localName) throws SAXException {
+        throw new SAXException("The 'param' tag can contain no elements.");
+    }
+
+    @Override
+    public void complete(String textContent) throws SAXException {
+        config.setParameter(name, textContent);
+    }
+
+}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/xml/usermapping/ProtocolTagHandler.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/xml/usermapping/ProtocolTagHandler.java
new file mode 100644
index 0000000..c50a26b
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/xml/usermapping/ProtocolTagHandler.java
@@ -0,0 +1,70 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.basic.xml.usermapping;
+
+import org.glyptodon.guacamole.xml.TagHandler;
+import org.glyptodon.guacamole.protocol.GuacamoleConfiguration;
+import org.xml.sax.Attributes;
+import org.xml.sax.SAXException;
+
+/**
+ * TagHandler for the "protocol" element.
+ *
+ * @author Mike Jumper
+ */
+public class ProtocolTagHandler implements TagHandler {
+
+    /**
+     * The GuacamoleConfiguration which will be populated with data from
+     * the tag handled by this tag handler.
+     */
+    private GuacamoleConfiguration config;
+
+    /**
+     * Creates a new handler for a "protocol" tag having the given
+     * attributes.
+     *
+     * @param config The GuacamoleConfiguration to update with the data parsed
+     *               from the "protocol" tag.
+     * @throws SAXException If the attributes given are not valid.
+     */
+    public ProtocolTagHandler(GuacamoleConfiguration config) throws SAXException {
+        this.config = config;
+    }
+
+    @Override
+    public void init(Attributes attributes) throws SAXException {
+        // Do nothing
+    }
+
+    @Override
+    public TagHandler childElement(String localName) throws SAXException {
+        throw new SAXException("The 'protocol' tag can contain no elements.");
+    }
+
+    @Override
+    public void complete(String textContent) throws SAXException {
+        config.setProtocol(textContent);
+    }
+
+}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/xml/usermapping/UserMappingTagHandler.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/xml/usermapping/UserMappingTagHandler.java
new file mode 100644
index 0000000..23c3447
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/xml/usermapping/UserMappingTagHandler.java
@@ -0,0 +1,78 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.net.basic.xml.usermapping;
+
+import org.glyptodon.guacamole.net.basic.auth.UserMapping;
+import org.glyptodon.guacamole.xml.TagHandler;
+import org.xml.sax.Attributes;
+import org.xml.sax.SAXException;
+
+/**
+ * TagHandler for the "user-mapping" element.
+ *
+ * @author Mike Jumper
+ */
+public class UserMappingTagHandler implements TagHandler {
+
+    /**
+     * The UserMapping which will contain all data parsed by this tag handler.
+     */
+    private UserMapping user_mapping = new UserMapping();
+
+    @Override
+    public void init(Attributes attributes) throws SAXException {
+        // Do nothing
+    }
+
+    @Override
+    public TagHandler childElement(String localName) throws SAXException {
+
+        // Start parsing of authorize tags, add to list of all authorizations
+        if (localName.equals("authorize"))
+            return new AuthorizeTagHandler(user_mapping);
+
+        return null;
+
+    }
+
+    @Override
+    public void complete(String textContent) throws SAXException {
+        // Do nothing
+    }
+
+    /**
+     * Returns a user mapping containing all authorizations and configurations
+     * parsed so far. This user mapping will be backed by the data being parsed,
+     * thus any additional authorizations or configurations will be available
+     * in the object returned by this function even after this function has
+     * returned, once the data corresponding to those authorizations or
+     * configurations has been parsed.
+     *
+     * @return A user mapping containing all authorizations and configurations
+     *         parsed so far.
+     */
+    public UserMapping asUserMapping() {
+        return user_mapping;
+    }
+
+}
diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/xml/usermapping/package-info.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/xml/usermapping/package-info.java
new file mode 100644
index 0000000..08ec7bd
--- /dev/null
+++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/xml/usermapping/package-info.java
@@ -0,0 +1,27 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Classes related to parsing the user-mapping.xml file.
+ */
+package org.glyptodon.guacamole.net.basic.xml.usermapping;
+
diff --git a/guacamole/src/main/resources/logback.xml b/guacamole/src/main/resources/logback.xml
new file mode 100644
index 0000000..343554f
--- /dev/null
+++ b/guacamole/src/main/resources/logback.xml
@@ -0,0 +1,37 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+   Copyright (C) 2014 Glyptodon LLC
+
+   Permission is hereby granted, free of charge, to any person obtaining a copy
+   of this software and associated documentation files (the "Software"), to deal
+   in the Software without restriction, including without limitation the rights
+   to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+   copies of the Software, and to permit persons to whom the Software is
+   furnished to do so, subject to the following conditions:
+
+   The above copyright notice and this permission notice shall be included in
+   all copies or substantial portions of the Software.
+
+   THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+   IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+   FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+   AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+   LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+   OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+   THE SOFTWARE.
+-->
+<configuration>
+
+    <!-- Default appender -->
+    <appender name="GUAC-DEFAULT" class="ch.qos.logback.core.ConsoleAppender">
+        <encoder>
+            <pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
+        </encoder>
+    </appender>
+
+    <!-- Log at INFO level -->
+    <root level="info">
+        <appender-ref ref="GUAC-DEFAULT" />
+    </root>
+
+</configuration>
\ No newline at end of file
diff --git a/guacamole/src/main/resources/net/sourceforge/guacamole/net/protocols/rdp.xml b/guacamole/src/main/resources/net/sourceforge/guacamole/net/protocols/rdp.xml
deleted file mode 100644
index dee0273..0000000
--- a/guacamole/src/main/resources/net/sourceforge/guacamole/net/protocols/rdp.xml
+++ /dev/null
@@ -1,44 +0,0 @@
-<protocol name="rdp" title="RDP">
-
-    <param name="hostname"        type="text"     title="Hostname"/>
-    <param name="port"            type="numeric"  title="Port"/>
-
-    <param name="username"        type="text"     title="Username"/>
-    <param name="password"        type="password" title="Password"/>
-    <param name="domain"          type="text"     title="Domain"/>
-    <param name="initial-program" type="text"     title="Initial program"/>
-
-    <param name="width"           type="numeric"  title="Display width"/>
-    <param name="height"          type="numeric"  title="Display height"/>
-    <param name="color-depth"     type="enum" title="Color depth">
-        <option value="8">256 color</option>
-        <option value="16">Low color (16-bit)</option>
-        <option value="24">True color (24-bit)</option>
-        <option value="32">True color (32-bit)</option>
-    </param>
-
-    <param name="server-layout" type="enum" title="Keyboard layout">
-        <option value="">(default)</option>
-        <option value="en-us-qwerty">US English (Qwerty)</option>
-        <option value="fr-fr-azerty">French (Azerty)</option>
-        <option value="de-de-qwertz">German (Qwertz)</option>
-        <option value="failsafe">Unicode</option>
-    </param>
-
-    <param name="console"         type="boolean" title="Administrator console"    value="true"/>
-    <param name="console-audio"   type="boolean" title="Support audio in console" value="true"/>
-    <param name="disable-audio"   type="boolean" title="Disable audio"            value="true"/>
-    <param name="enable-printing" type="boolean" title="Enable printing"          value="true"/>
-
-    <param name="security" type="enum" title="Security mode">
-        <option value="">(default)</option>
-        <option value="rdp">RDP encryption</option>
-        <option value="tls">TLS encryption</option>
-        <option value="nla">NLA (Network Level Authentication)</option>
-        <option value="any">Any</option>
-    </param>
-    
-    <param name="disable-auth" type="boolean" title="Disable authentication" value="true"/>
-    <param name="ignore-cert" type="boolean" title="Ignore server certificate" value="true"/>
-    
-</protocol>
diff --git a/guacamole/src/main/resources/net/sourceforge/guacamole/net/protocols/ssh.xml b/guacamole/src/main/resources/net/sourceforge/guacamole/net/protocols/ssh.xml
deleted file mode 100644
index afef1a9..0000000
--- a/guacamole/src/main/resources/net/sourceforge/guacamole/net/protocols/ssh.xml
+++ /dev/null
@@ -1,28 +0,0 @@
-<protocol name="ssh" title="SSH">
-
-    <param name="hostname" title="Hostname" type="text"/>
-    <param name="port"     title="Port"     type="numeric"/>
-
-    <param name="username" title="Username" type="text"/>
-    <param name="password" title="Password" type="password"/>
-
-    <param name="font-name" title="Font name" type="text"/>
-    <param name="font-size" title="Font size" type="enum">
-        <option value=""></option>
-        <option value="8">8</option>
-        <option value="9">9</option>
-        <option value="10">10</option>
-        <option value="11">11</option>
-        <option value="12">12</option>
-        <option value="14">14</option>
-        <option value="18">18</option>
-        <option value="24">24</option>
-        <option value="30">30</option>
-        <option value="36">36</option>
-        <option value="48">48</option>
-        <option value="60">60</option>
-        <option value="72">72</option>
-        <option value="96">96</option>
-    </param>
-
-</protocol>
diff --git a/guacamole/src/main/resources/net/sourceforge/guacamole/net/protocols/vnc.xml b/guacamole/src/main/resources/net/sourceforge/guacamole/net/protocols/vnc.xml
deleted file mode 100644
index 3f1ca82..0000000
--- a/guacamole/src/main/resources/net/sourceforge/guacamole/net/protocols/vnc.xml
+++ /dev/null
@@ -1,23 +0,0 @@
-<protocol name="vnc" title="VNC">
-
-    <param name="hostname"    type="text"     title="Hostname"/>
-    <param name="port"        type="numeric"  title="Port"/>
-    <param name="password"    type="password" title="Password"/>
-
-    <param name="read-only"     type="boolean" title="Read-only"                value="true"/>
-    <param name="swap-red-blue" type="boolean" title="Swap red/blue components" value="true"/>
-
-    <param name="color-depth" type="enum" title="Color depth">
-        <option value="8">256 color</option>
-        <option value="16">Low color (16-bit)</option>
-        <option value="24">True color (24-bit)</option>
-        <option value="32">True color (32-bit)</option>
-    </param>
-
-    <param name="dest-host" type="text"    title="Repeater destination host"/>
-    <param name="dest-port" type="numeric" title="Repeater destination port"/>
-
-    <param name="enable-audio" type="boolean" title="Enable audio" value="true"/>
-    <param name="audio-servername" type="text" title="Audio server name"/>
-
-</protocol>
diff --git a/guacamole/src/main/webapp/WEB-INF/web.xml b/guacamole/src/main/webapp/WEB-INF/web.xml
index 728d5e6..490bdb3 100644
--- a/guacamole/src/main/webapp/WEB-INF/web.xml
+++ b/guacamole/src/main/webapp/WEB-INF/web.xml
@@ -1,247 +1,51 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <!--
-    Guacamole - Clientless Remote Desktop
-    Copyright (C) 2010  Michael Jumper
-
-    This program is free software: you can redistribute it and/or modify
-    it under the terms of the GNU Affero General Public License as published by
-    the Free Software Foundation, either version 3 of the License, or
-    (at your option) any later version.
-
-    This program is distributed in the hope that it will be useful,
-    but WITHOUT ANY WARRANTY; without even the implied warranty of
-    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-    GNU Affero General Public License for more details.
-
-    You should have received a copy of the GNU Affero General Public License
-    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+   Copyright (C) 2013 Glyptodon LLC
+
+   Permission is hereby granted, free of charge, to any person obtaining a copy
+   of this software and associated documentation files (the "Software"), to deal
+   in the Software without restriction, including without limitation the rights
+   to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+   copies of the Software, and to permit persons to whom the Software is
+   furnished to do so, subject to the following conditions:
+
+   The above copyright notice and this permission notice shall be included in
+   all copies or substantial portions of the Software.
+
+   THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+   IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+   FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+   AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+   LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+   OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+   THE SOFTWARE.
 -->
-<web-app version="2.5" xmlns="http://java.sun.com/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_2_5.xsd">
+<web-app version="2.5"
+         xmlns="http://java.sun.com/xml/ns/javaee"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://java.sun.com/xml/ns/javaee
+                             http://java.sun.com/xml/ns/javaee/web-app_2_5.xsd">
 
     <!-- Basic config -->
     <welcome-file-list>
-        <welcome-file>index.xhtml</welcome-file>
+        <welcome-file>index.html</welcome-file>
     </welcome-file-list>
-    <session-config>
-        <session-timeout>
-			30
-        </session-timeout>
-    </session-config>
 
-    <!-- Automatically detect and load WebSocket support -->
+    <!-- Guice -->
+    <filter>
+        <filter-name>guiceFilter</filter-name>
+        <filter-class>com.google.inject.servlet.GuiceFilter</filter-class>
+    </filter>
+    <filter-mapping>
+        <filter-name>guiceFilter</filter-name>
+        <url-pattern>/*</url-pattern>
+    </filter-mapping>
+
     <listener>
-        <listener-class>org.glyptodon.guacamole.net.basic.WebSocketSupportLoader</listener-class>
+        <listener-class>org.glyptodon.guacamole.net.basic.BasicServletContextListener</listener-class>
     </listener>
-    
-    <!-- Basic Login Servlet -->
-    <servlet>
-        <description>Login servlet.</description>
-        <servlet-name>Login</servlet-name>
-        <servlet-class>org.glyptodon.guacamole.net.basic.BasicLogin</servlet-class>
-    </servlet>
-    <servlet-mapping>
-        <servlet-name>Login</servlet-name>
-        <url-pattern>/login</url-pattern>
-    </servlet-mapping>
-
-    <!-- Basic Logout Servlet -->
-    <servlet>
-        <description>Logout servlet.</description>
-        <servlet-name>Logout</servlet-name>
-        <servlet-class>org.glyptodon.guacamole.net.basic.BasicLogout</servlet-class>
-    </servlet>
-    <servlet-mapping>
-        <servlet-name>Logout</servlet-name>
-        <url-pattern>/logout</url-pattern>
-    </servlet-mapping>
-
-    <!-- Connection Creation Servlet -->
-    <servlet>
-        <description>Connection creation servlet.</description>
-        <servlet-name>ConnectionCreate</servlet-name>
-        <servlet-class>org.glyptodon.guacamole.net.basic.crud.connections.Create</servlet-class>
-    </servlet>
-    <servlet-mapping>
-        <servlet-name>ConnectionCreate</servlet-name>
-        <url-pattern>/connections/create</url-pattern>
-    </servlet-mapping>
-
-    <!-- Connection List Servlet -->
-    <servlet>
-        <description>Connection list servlet.</description>
-        <servlet-name>Connections</servlet-name>
-        <servlet-class>org.glyptodon.guacamole.net.basic.crud.connections.List</servlet-class>
-    </servlet>
-    <servlet-mapping>
-        <servlet-name>Connections</servlet-name>
-        <url-pattern>/connections</url-pattern>
-    </servlet-mapping>
-
-    <!-- Connection Update Servlet -->
-    <servlet>
-        <description>Connection update servlet.</description>
-        <servlet-name>ConnectionUpdate</servlet-name>
-        <servlet-class>org.glyptodon.guacamole.net.basic.crud.connections.Update</servlet-class>
-    </servlet>
-    <servlet-mapping>
-        <servlet-name>ConnectionUpdate</servlet-name>
-        <url-pattern>/connections/update</url-pattern>
-    </servlet-mapping>
-
-    <!-- Connection Move Servlet -->
-    <servlet>
-        <description>Connection move servlet.</description>
-        <servlet-name>ConnectionMove</servlet-name>
-        <servlet-class>org.glyptodon.guacamole.net.basic.crud.connections.Move</servlet-class>
-    </servlet>
-    <servlet-mapping>
-        <servlet-name>ConnectionMove</servlet-name>
-        <url-pattern>/connections/move</url-pattern>
-    </servlet-mapping>
-
-    <!-- Connection Deletion Servlet -->
-    <servlet>
-        <description>Connection deletion servlet.</description>
-        <servlet-name>ConnectionDelete</servlet-name>
-        <servlet-class>org.glyptodon.guacamole.net.basic.crud.connections.Delete</servlet-class>
-    </servlet>
-    <servlet-mapping>
-        <servlet-name>ConnectionDelete</servlet-name>
-        <url-pattern>/connections/delete</url-pattern>
-    </servlet-mapping>
-    
-    <!-- Connection Group Creation Servlet -->
-    <servlet>
-        <description>ConnectionGroup creation servlet.</description>
-        <servlet-name>ConnectionGroupCreate</servlet-name>
-        <servlet-class>org.glyptodon.guacamole.net.basic.crud.connectiongroups.Create</servlet-class>
-    </servlet>
-    <servlet-mapping>
-        <servlet-name>ConnectionGroupCreate</servlet-name>
-        <url-pattern>/connectiongroups/create</url-pattern>
-    </servlet-mapping>
-
-    <!-- Connection Group List Servlet -->
-    <servlet>
-        <description>ConnectionGroup list servlet.</description>
-        <servlet-name>ConnectionGroups</servlet-name>
-        <servlet-class>org.glyptodon.guacamole.net.basic.crud.connectiongroups.List</servlet-class>
-    </servlet>
-    <servlet-mapping>
-        <servlet-name>ConnectionGroups</servlet-name>
-        <url-pattern>/connectiongroups</url-pattern>
-    </servlet-mapping>
-
-    <!-- Connection Group Update Servlet -->
-    <servlet>
-        <description>ConnectionGroup update servlet.</description>
-        <servlet-name>ConnectionGroupUpdate</servlet-name>
-        <servlet-class>org.glyptodon.guacamole.net.basic.crud.connectiongroups.Update</servlet-class>
-    </servlet>
-    <servlet-mapping>
-        <servlet-name>ConnectionGroupUpdate</servlet-name>
-        <url-pattern>/connectiongroups/update</url-pattern>
-    </servlet-mapping>
-
-    <!-- Connection Group Move Servlet -->
-    <servlet>
-        <description>ConnectionGroup move servlet.</description>
-        <servlet-name>ConnectionGroupMove</servlet-name>
-        <servlet-class>org.glyptodon.guacamole.net.basic.crud.connectiongroups.Move</servlet-class>
-    </servlet>
-    <servlet-mapping>
-        <servlet-name>ConnectionGroupMove</servlet-name>
-        <url-pattern>/connectiongroups/move</url-pattern>
-    </servlet-mapping>
-
-    <!-- Connection Group Deletion Servlet -->
-    <servlet>
-        <description>ConnectionGroup deletion servlet.</description>
-        <servlet-name>ConnectionGroupDelete</servlet-name>
-        <servlet-class>org.glyptodon.guacamole.net.basic.crud.connectiongroups.Delete</servlet-class>
-    </servlet>
-    <servlet-mapping>
-        <servlet-name>ConnectionGroupDelete</servlet-name>
-        <url-pattern>/connectiongroups/delete</url-pattern>
-    </servlet-mapping>
-
-    <!-- User Creation Servlet -->
-    <servlet>
-        <description>User creation servlet.</description>
-        <servlet-name>UserCreate</servlet-name>
-        <servlet-class>org.glyptodon.guacamole.net.basic.crud.users.Create</servlet-class>
-    </servlet>
-    <servlet-mapping>
-        <servlet-name>UserCreate</servlet-name>
-        <url-pattern>/users/create</url-pattern>
-    </servlet-mapping>
-
-    <!-- User List Servlet -->
-    <servlet>
-        <description>User list servlet.</description>
-        <servlet-name>Users</servlet-name>
-        <servlet-class>org.glyptodon.guacamole.net.basic.crud.users.List</servlet-class>
-    </servlet>
-    <servlet-mapping>
-        <servlet-name>Users</servlet-name>
-        <url-pattern>/users</url-pattern>
-    </servlet-mapping>
-
-    <!-- User Update Servlet -->
-    <servlet>
-        <description>User update servlet.</description>
-        <servlet-name>UserUpdate</servlet-name>
-        <servlet-class>org.glyptodon.guacamole.net.basic.crud.users.Update</servlet-class>
-    </servlet>
-    <servlet-mapping>
-        <servlet-name>UserUpdate</servlet-name>
-        <url-pattern>/users/update</url-pattern>
-    </servlet-mapping>
-
-    <!-- User Deletion Servlet -->
-    <servlet>
-        <description>User deletion servlet.</description>
-        <servlet-name>UserDelete</servlet-name>
-        <servlet-class>org.glyptodon.guacamole.net.basic.crud.users.Delete</servlet-class>
-    </servlet>
-    <servlet-mapping>
-        <servlet-name>UserDelete</servlet-name>
-        <url-pattern>/users/delete</url-pattern>
-    </servlet-mapping>
-
-    <!-- Permission List Servlet -->
-    <servlet>
-        <description>Permission list servlet.</description>
-        <servlet-name>Permissions</servlet-name>
-        <servlet-class>org.glyptodon.guacamole.net.basic.crud.permissions.List</servlet-class>
-    </servlet>
-    <servlet-mapping>
-        <servlet-name>Permissions</servlet-name>
-        <url-pattern>/permissions</url-pattern>
-    </servlet-mapping>
-
-    <!-- Protocol List Servlet -->
-    <servlet>
-        <description>Protocol list servlet.</description>
-        <servlet-name>Protocols</servlet-name>
-        <servlet-class>org.glyptodon.guacamole.net.basic.crud.protocols.List</servlet-class>
-    </servlet>
-    <servlet-mapping>
-        <servlet-name>Protocols</servlet-name>
-        <url-pattern>/protocols</url-pattern>
-    </servlet-mapping>
-
-    <!-- Guacamole Tunnel Servlet -->
-    <servlet>
-        <description>Tunnel servlet.</description>
-        <servlet-name>Tunnel</servlet-name>
-        <servlet-class>org.glyptodon.guacamole.net.basic.BasicGuacamoleTunnelServlet</servlet-class>
-    </servlet>
-    <servlet-mapping>
-        <servlet-name>Tunnel</servlet-name>
-        <url-pattern>/tunnel</url-pattern>
-    </servlet-mapping>
 
+    <!-- Audio file mimetype mappings -->
     <mime-mapping>
         <extension>mp3</extension>
         <mime-type>audio/mpeg</mime-type>
diff --git a/guacamole/src/main/webapp/admin.xhtml b/guacamole/src/main/webapp/admin.xhtml
deleted file mode 100644
index ec98b12..0000000
--- a/guacamole/src/main/webapp/admin.xhtml
+++ /dev/null
@@ -1,99 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<!DOCTYPE html>
-
-<!--
-    Guacamole - Clientless Remote Desktop
-    Copyright (C) 2010  Michael Jumper
-
-    This program is free software: you can redistribute it and/or modify
-    it under the terms of the GNU Affero General Public License as published by
-    the Free Software Foundation, either version 3 of the License, or
-    (at your option) any later version.
-
-    This program is distributed in the hope that it will be useful,
-    but WITHOUT ANY WARRANTY; without even the implied warranty of
-    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-    GNU Affero General Public License for more details.
-
-    You should have received a copy of the GNU Affero General Public License
-    along with this program.  If not, see <http://www.gnu.org/licenses/>.
--->
-
-<html xmlns="http://www.w3.org/1999/xhtml">
-
-    <head>
-        <link rel="icon" type="image/png" href="images/guacamole-logo-64.png"/>
-        <link rel="apple-touch-icon" type="image/png" href="images/guacamole-logo-144.png"/>
-        <link rel="stylesheet" type="text/css" href="styles/ui.css"/>
-        <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, target-densitydpi=medium-dpi"/>
-        <title>Guacamole ${project.version}</title>
-    </head>
-
-    <body>
-
-        <div id="logout-panel">
-            <button id="back">Back</button>
-            <button id="logout">Logout</button>
-        </div>
-           
-        <h2>Administration</h2>
-        <div class="settings section">
-
-            <h3 class="require-manage-users">Users</h3>
-            <div class="require-manage-users" id="users">
-
-                <p>
-                    Click or tap on a user below to manage that user. Depending
-                    on your access level, users can be added and deleted, and their
-                    passwords can be changed.
-                </p>
-               
-                <div id="user-add-form">
-                    <div class="icon user add"/><input type="text" class="name" id="username" placeholder="Add user"/><button id="add-user">Add</button>
-                </div>
-                
-                <div id="user-list">
-                </div>
-                    
-                <div id="user-list-buttons">
-                </div>
-                    
-            </div>
-
-            <h3 class="require-manage-connections">Connections</h3>
-            <div class="require-manage-connections" id="connections">
-
-                <p>
-                    Click or tap on a connection below to manage that connection.
-                    Depending on your access level, connections can be added and
-                    deleted, and their properties (protocol, hostname, port, etc.)
-                    can be changed.
-                </p>
-
-                <div id="connection-add-form">
-                    <button id="add-connection">New Connection</button><button id="add-connection-group">New Group</button>
-                </div>
-                
-                <div id="connection-list">
-                </div>
-                    
-                <div id="connection-list-buttons">
-                </div>
-                    
-            </div>
-
-        </div>
-
-        <div id="version-dialog">
-            Guacamole ${project.version}
-        </div>
-
-        <script type="text/javascript" src="scripts/session.js"></script>
-        <script type="text/javascript" src="scripts/guac-ui.js"></script>
-        <script type="text/javascript" src="scripts/service.js"></script>
-        <script type="text/javascript" src="scripts/history.js"></script>
-        <script type="text/javascript" src="scripts/admin-ui.js"></script>
-
-    </body>
-
-</html>
diff --git a/guacamole/src/main/webapp/agpl-3.0-standalone.html b/guacamole/src/main/webapp/agpl-3.0-standalone.html
deleted file mode 100644
index bf47c6c..0000000
--- a/guacamole/src/main/webapp/agpl-3.0-standalone.html
+++ /dev/null
@@ -1,688 +0,0 @@
-<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">
-
-<html><head>
- <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
- <title>GNU Affero General Public License - GNU Project - Free Software Foundation (FSF)</title>
-</head>
- <link rel="alternate" type="application/rdf+xml"
-       href="http://www.gnu.org/licenses/agpl-3.0.rdf" /> 
-<body>
-<h3 style="text-align: center;">GNU AFFERO GENERAL PUBLIC LICENSE</h3>
-<p style="text-align: center;">Version 3, 19 November 2007</p>
-
-<p>Copyright © 2007 Free Software Foundation,
-Inc. <<a href="http://fsf.org/">http://fsf.org/</a>>
- <br />
- Everyone is permitted to copy and distribute verbatim copies
- of this license document, but changing it is not allowed.</p>
-
-<h3><a name="preamble"></a>Preamble</h3>
-
-<p>The GNU Affero General Public License is a free, copyleft license
-for software and other kinds of works, specifically designed to ensure
-cooperation with the community in the case of network server software.</p>
-
-<p>The licenses for most software and other practical works are
-designed to take away your freedom to share and change the works.  By
-contrast, our General Public Licenses are intended to guarantee your
-freedom to share and change all versions of a program--to make sure it
-remains free software for all its users.</p>
-
-<p>When we speak of free software, we are referring to freedom, not
-price.  Our General Public Licenses are designed to make sure that you
-have the freedom to distribute copies of free software (and charge for
-them if you wish), that you receive source code or can get it if you
-want it, that you can change the software or use pieces of it in new
-free programs, and that you know you can do these things.</p>
-
-<p>Developers that use our General Public Licenses protect your rights
-with two steps: (1) assert copyright on the software, and (2) offer
-you this License which gives you legal permission to copy, distribute
-and/or modify the software.</p>
-
-<p>A secondary benefit of defending all users' freedom is that
-improvements made in alternate versions of the program, if they
-receive widespread use, become available for other developers to
-incorporate.  Many developers of free software are heartened and
-encouraged by the resulting cooperation.  However, in the case of
-software used on network servers, this result may fail to come about.
-The GNU General Public License permits making a modified version and
-letting the public access it on a server without ever releasing its
-source code to the public.</p>
-
-<p>The GNU Affero General Public License is designed specifically to
-ensure that, in such cases, the modified source code becomes available
-to the community.  It requires the operator of a network server to
-provide the source code of the modified version running there to the
-users of that server.  Therefore, public use of a modified version, on
-a publicly accessible server, gives the public access to the source
-code of the modified version.</p>
-
-<p>An older license, called the Affero General Public License and
-published by Affero, was designed to accomplish similar goals.  This is
-a different license, not a version of the Affero GPL, but Affero has
-released a new version of the Affero GPL which permits relicensing under
-this license.</p>
-
-<p>The precise terms and conditions for copying, distribution and
-modification follow.</p>
-
-<h3><a name="terms"></a>TERMS AND CONDITIONS</h3>
-
-<h4><a name="section0"></a>0. Definitions.</h4>
-
-<p>"This License" refers to version 3 of the GNU Affero General Public
-License.</p>
-
-<p>"Copyright" also means copyright-like laws that apply to other kinds
-of works, such as semiconductor masks.</p>
-
-<p>"The Program" refers to any copyrightable work licensed under this
-License.  Each licensee is addressed as "you".  "Licensees" and
-"recipients" may be individuals or organizations.</p>
-
-<p>To "modify" a work means to copy from or adapt all or part of the work
-in a fashion requiring copyright permission, other than the making of an
-exact copy.  The resulting work is called a "modified version" of the
-earlier work or a work "based on" the earlier work.</p>
-
-<p>A "covered work" means either the unmodified Program or a work based
-on the Program.</p>
-
-<p>To "propagate" a work means to do anything with it that, without
-permission, would make you directly or secondarily liable for
-infringement under applicable copyright law, except executing it on a
-computer or modifying a private copy.  Propagation includes copying,
-distribution (with or without modification), making available to the
-public, and in some countries other activities as well.</p>
-
-<p>To "convey" a work means any kind of propagation that enables other
-parties to make or receive copies.  Mere interaction with a user through
-a computer network, with no transfer of a copy, is not conveying.</p>
-
-<p>An interactive user interface displays "Appropriate Legal Notices"
-to the extent that it includes a convenient and prominently visible
-feature that (1) displays an appropriate copyright notice, and (2)
-tells the user that there is no warranty for the work (except to the
-extent that warranties are provided), that licensees may convey the
-work under this License, and how to view a copy of this License.  If
-the interface presents a list of user commands or options, such as a
-menu, a prominent item in the list meets this criterion.</p>
-
-<h4><a name="section1"></a>1. Source Code.</h4>
-
-<p>The "source code" for a work means the preferred form of the work
-for making modifications to it.  "Object code" means any non-source
-form of a work.</p>
-
-<p>A "Standard Interface" means an interface that either is an official
-standard defined by a recognized standards body, or, in the case of
-interfaces specified for a particular programming language, one that
-is widely used among developers working in that language.</p>
-
-<p>The "System Libraries" of an executable work include anything, other
-than the work as a whole, that (a) is included in the normal form of
-packaging a Major Component, but which is not part of that Major
-Component, and (b) serves only to enable use of the work with that
-Major Component, or to implement a Standard Interface for which an
-implementation is available to the public in source code form.  A
-"Major Component", in this context, means a major essential component
-(kernel, window system, and so on) of the specific operating system
-(if any) on which the executable work runs, or a compiler used to
-produce the work, or an object code interpreter used to run it.</p>
-
-<p>The "Corresponding Source" for a work in object code form means all
-the source code needed to generate, install, and (for an executable
-work) run the object code and to modify the work, including scripts to
-control those activities.  However, it does not include the work's
-System Libraries, or general-purpose tools or generally available free
-programs which are used unmodified in performing those activities but
-which are not part of the work.  For example, Corresponding Source
-includes interface definition files associated with source files for
-the work, and the source code for shared libraries and dynamically
-linked subprograms that the work is specifically designed to require,
-such as by intimate data communication or control flow between those
-subprograms and other parts of the work.</p>
-
-<p>The Corresponding Source need not include anything that users
-can regenerate automatically from other parts of the Corresponding
-Source.</p>
-
-<p>The Corresponding Source for a work in source code form is that
-same work.</p>
-
-<h4><a name="section2"></a>2. Basic Permissions.</h4>
-
-<p>All rights granted under this License are granted for the term of
-copyright on the Program, and are irrevocable provided the stated
-conditions are met.  This License explicitly affirms your unlimited
-permission to run the unmodified Program.  The output from running a
-covered work is covered by this License only if the output, given its
-content, constitutes a covered work.  This License acknowledges your
-rights of fair use or other equivalent, as provided by copyright law.</p>
-
-<p>You may make, run and propagate covered works that you do not
-convey, without conditions so long as your license otherwise remains
-in force.  You may convey covered works to others for the sole purpose
-of having them make modifications exclusively for you, or provide you
-with facilities for running those works, provided that you comply with
-the terms of this License in conveying all material for which you do
-not control copyright.  Those thus making or running the covered works
-for you must do so exclusively on your behalf, under your direction
-and control, on terms that prohibit them from making any copies of
-your copyrighted material outside their relationship with you.</p>
-
-<p>Conveying under any other circumstances is permitted solely under
-the conditions stated below.  Sublicensing is not allowed; section 10
-makes it unnecessary.</p>
-
-<h4><a name="section3"></a>3. Protecting Users' Legal Rights From Anti-Circumvention Law.</h4>
-
-<p>No covered work shall be deemed part of an effective technological
-measure under any applicable law fulfilling obligations under article
-11 of the WIPO copyright treaty adopted on 20 December 1996, or
-similar laws prohibiting or restricting circumvention of such
-measures.</p>
-
-<p>When you convey a covered work, you waive any legal power to forbid
-circumvention of technological measures to the extent such circumvention
-is effected by exercising rights under this License with respect to
-the covered work, and you disclaim any intention to limit operation or
-modification of the work as a means of enforcing, against the work's
-users, your or third parties' legal rights to forbid circumvention of
-technological measures.</p>
-
-<h4><a name="section4"></a>4. Conveying Verbatim Copies.</h4>
-
-<p>You may convey verbatim copies of the Program's source code as you
-receive it, in any medium, provided that you conspicuously and
-appropriately publish on each copy an appropriate copyright notice;
-keep intact all notices stating that this License and any
-non-permissive terms added in accord with section 7 apply to the code;
-keep intact all notices of the absence of any warranty; and give all
-recipients a copy of this License along with the Program.</p>
-
-<p>You may charge any price or no price for each copy that you convey,
-and you may offer support or warranty protection for a fee.</p>
-
-<h4><a name="section5"></a>5. Conveying Modified Source Versions.</h4>
-
-<p>You may convey a work based on the Program, or the modifications to
-produce it from the Program, in the form of source code under the
-terms of section 4, provided that you also meet all of these conditions:</p>
-
-<ul>
-
-<li>a) The work must carry prominent notices stating that you modified
-    it, and giving a relevant date.</li>
-
-<li>b) The work must carry prominent notices stating that it is
-    released under this License and any conditions added under section
-    7.  This requirement modifies the requirement in section 4 to
-    "keep intact all notices".</li>
-
-<li>c) You must license the entire work, as a whole, under this
-    License to anyone who comes into possession of a copy.  This
-    License will therefore apply, along with any applicable section 7
-    additional terms, to the whole of the work, and all its parts,
-    regardless of how they are packaged.  This License gives no
-    permission to license the work in any other way, but it does not
-    invalidate such permission if you have separately received it.</li>
-
-<li>d) If the work has interactive user interfaces, each must display
-    Appropriate Legal Notices; however, if the Program has interactive
-    interfaces that do not display Appropriate Legal Notices, your
-    work need not make them do so.</li>
-
-</ul>
-
-<p>A compilation of a covered work with other separate and independent
-works, which are not by their nature extensions of the covered work,
-and which are not combined with it such as to form a larger program,
-in or on a volume of a storage or distribution medium, is called an
-"aggregate" if the compilation and its resulting copyright are not
-used to limit the access or legal rights of the compilation's users
-beyond what the individual works permit.  Inclusion of a covered work
-in an aggregate does not cause this License to apply to the other
-parts of the aggregate.</p>
-
-<h4><a name="section6"></a>6. Conveying Non-Source Forms.</h4>
-
-<p>You may convey a covered work in object code form under the terms
-of sections 4 and 5, provided that you also convey the
-machine-readable Corresponding Source under the terms of this License,
-in one of these ways:</p>
-
-<ul>
-
-<li>a) Convey the object code in, or embodied in, a physical product
-    (including a physical distribution medium), accompanied by the
-    Corresponding Source fixed on a durable physical medium
-    customarily used for software interchange.</li>
-
-<li>b) Convey the object code in, or embodied in, a physical product
-    (including a physical distribution medium), accompanied by a
-    written offer, valid for at least three years and valid for as
-    long as you offer spare parts or customer support for that product
-    model, to give anyone who possesses the object code either (1) a
-    copy of the Corresponding Source for all the software in the
-    product that is covered by this License, on a durable physical
-    medium customarily used for software interchange, for a price no
-    more than your reasonable cost of physically performing this
-    conveying of source, or (2) access to copy the
-    Corresponding Source from a network server at no charge.</li>
-
-<li>c) Convey individual copies of the object code with a copy of the
-    written offer to provide the Corresponding Source.  This
-    alternative is allowed only occasionally and noncommercially, and
-    only if you received the object code with such an offer, in accord
-    with subsection 6b.</li>
-
-<li>d) Convey the object code by offering access from a designated
-    place (gratis or for a charge), and offer equivalent access to the
-    Corresponding Source in the same way through the same place at no
-    further charge.  You need not require recipients to copy the
-    Corresponding Source along with the object code.  If the place to
-    copy the object code is a network server, the Corresponding Source
-    may be on a different server (operated by you or a third party)
-    that supports equivalent copying facilities, provided you maintain
-    clear directions next to the object code saying where to find the
-    Corresponding Source.  Regardless of what server hosts the
-    Corresponding Source, you remain obligated to ensure that it is
-    available for as long as needed to satisfy these requirements.</li>
-
-<li>e) Convey the object code using peer-to-peer transmission, provided
-    you inform other peers where the object code and Corresponding
-    Source of the work are being offered to the general public at no
-    charge under subsection 6d.</li>
-
-</ul>
-
-<p>A separable portion of the object code, whose source code is excluded
-from the Corresponding Source as a System Library, need not be
-included in conveying the object code work.</p>
-
-<p>A "User Product" is either (1) a "consumer product", which means any
-tangible personal property which is normally used for personal, family,
-or household purposes, or (2) anything designed or sold for incorporation
-into a dwelling.  In determining whether a product is a consumer product,
-doubtful cases shall be resolved in favor of coverage.  For a particular
-product received by a particular user, "normally used" refers to a
-typical or common use of that class of product, regardless of the status
-of the particular user or of the way in which the particular user
-actually uses, or expects or is expected to use, the product.  A product
-is a consumer product regardless of whether the product has substantial
-commercial, industrial or non-consumer uses, unless such uses represent
-the only significant mode of use of the product.</p>
-
-<p>"Installation Information" for a User Product means any methods,
-procedures, authorization keys, or other information required to install
-and execute modified versions of a covered work in that User Product from
-a modified version of its Corresponding Source.  The information must
-suffice to ensure that the continued functioning of the modified object
-code is in no case prevented or interfered with solely because
-modification has been made.</p>
-
-<p>If you convey an object code work under this section in, or with, or
-specifically for use in, a User Product, and the conveying occurs as
-part of a transaction in which the right of possession and use of the
-User Product is transferred to the recipient in perpetuity or for a
-fixed term (regardless of how the transaction is characterized), the
-Corresponding Source conveyed under this section must be accompanied
-by the Installation Information.  But this requirement does not apply
-if neither you nor any third party retains the ability to install
-modified object code on the User Product (for example, the work has
-been installed in ROM).</p>
-
-<p>The requirement to provide Installation Information does not include a
-requirement to continue to provide support service, warranty, or updates
-for a work that has been modified or installed by the recipient, or for
-the User Product in which it has been modified or installed.  Access to a
-network may be denied when the modification itself materially and
-adversely affects the operation of the network or violates the rules and
-protocols for communication across the network.</p>
-
-<p>Corresponding Source conveyed, and Installation Information provided,
-in accord with this section must be in a format that is publicly
-documented (and with an implementation available to the public in
-source code form), and must require no special password or key for
-unpacking, reading or copying.</p>
-
-<h4><a name="section7"></a>7. Additional Terms.</h4>
-
-<p>"Additional permissions" are terms that supplement the terms of this
-License by making exceptions from one or more of its conditions.
-Additional permissions that are applicable to the entire Program shall
-be treated as though they were included in this License, to the extent
-that they are valid under applicable law.  If additional permissions
-apply only to part of the Program, that part may be used separately
-under those permissions, but the entire Program remains governed by
-this License without regard to the additional permissions.</p>
-
-<p>When you convey a copy of a covered work, you may at your option
-remove any additional permissions from that copy, or from any part of
-it.  (Additional permissions may be written to require their own
-removal in certain cases when you modify the work.)  You may place
-additional permissions on material, added by you to a covered work,
-for which you have or can give appropriate copyright permission.</p>
-
-<p>Notwithstanding any other provision of this License, for material you
-add to a covered work, you may (if authorized by the copyright holders of
-that material) supplement the terms of this License with terms:</p>
-
-<ul>
-
-<li>a) Disclaiming warranty or limiting liability differently from the
-    terms of sections 15 and 16 of this License; or</li>
-
-<li>b) Requiring preservation of specified reasonable legal notices or
-    author attributions in that material or in the Appropriate Legal
-    Notices displayed by works containing it; or</li>
-
-<li>c) Prohibiting misrepresentation of the origin of that material, or
-    requiring that modified versions of such material be marked in
-    reasonable ways as different from the original version; or</li>
-
-<li>d) Limiting the use for publicity purposes of names of licensors or
-    authors of the material; or</li>
-
-<li>e) Declining to grant rights under trademark law for use of some
-    trade names, trademarks, or service marks; or</li>
-
-<li>f) Requiring indemnification of licensors and authors of that
-    material by anyone who conveys the material (or modified versions of
-    it) with contractual assumptions of liability to the recipient, for
-    any liability that these contractual assumptions directly impose on
-    those licensors and authors.</li>
-
-</ul>
-
-<p>All other non-permissive additional terms are considered "further
-restrictions" within the meaning of section 10.  If the Program as you
-received it, or any part of it, contains a notice stating that it is
-governed by this License along with a term that is a further restriction,
-you may remove that term.  If a license document contains a further
-restriction but permits relicensing or conveying under this License, you
-may add to a covered work material governed by the terms of that license
-document, provided that the further restriction does not survive such
-relicensing or conveying.</p>
-
-<p>If you add terms to a covered work in accord with this section, you
-must place, in the relevant source files, a statement of the
-additional terms that apply to those files, or a notice indicating
-where to find the applicable terms.</p>
-
-<p>Additional terms, permissive or non-permissive, may be stated in the
-form of a separately written license, or stated as exceptions;
-the above requirements apply either way.</p>
-
-<h4><a name="section8"></a>8. Termination.</h4>
-
-<p>You may not propagate or modify a covered work except as expressly
-provided under this License.  Any attempt otherwise to propagate or
-modify it is void, and will automatically terminate your rights under
-this License (including any patent licenses granted under the third
-paragraph of section 11).</p>
-
-<p>However, if you cease all violation of this License, then your
-license from a particular copyright holder is reinstated (a)
-provisionally, unless and until the copyright holder explicitly and
-finally terminates your license, and (b) permanently, if the copyright
-holder fails to notify you of the violation by some reasonable means
-prior to 60 days after the cessation.</p>
-
-<p>Moreover, your license from a particular copyright holder is
-reinstated permanently if the copyright holder notifies you of the
-violation by some reasonable means, this is the first time you have
-received notice of violation of this License (for any work) from that
-copyright holder, and you cure the violation prior to 30 days after
-your receipt of the notice.</p>
-
-<p>Termination of your rights under this section does not terminate the
-licenses of parties who have received copies or rights from you under
-this License.  If your rights have been terminated and not permanently
-reinstated, you do not qualify to receive new licenses for the same
-material under section 10.</p>
-
-<h4><a name="section9"></a>9. Acceptance Not Required for Having Copies.</h4>
-
-<p>You are not required to accept this License in order to receive or
-run a copy of the Program.  Ancillary propagation of a covered work
-occurring solely as a consequence of using peer-to-peer transmission
-to receive a copy likewise does not require acceptance.  However,
-nothing other than this License grants you permission to propagate or
-modify any covered work.  These actions infringe copyright if you do
-not accept this License.  Therefore, by modifying or propagating a
-covered work, you indicate your acceptance of this License to do so.</p>
-
-<h4><a name="section10"></a>10. Automatic Licensing of Downstream Recipients.</h4>
-
-<p>Each time you convey a covered work, the recipient automatically
-receives a license from the original licensors, to run, modify and
-propagate that work, subject to this License.  You are not responsible
-for enforcing compliance by third parties with this License.</p>
-
-<p>An "entity transaction" is a transaction transferring control of an
-organization, or substantially all assets of one, or subdividing an
-organization, or merging organizations.  If propagation of a covered
-work results from an entity transaction, each party to that
-transaction who receives a copy of the work also receives whatever
-licenses to the work the party's predecessor in interest had or could
-give under the previous paragraph, plus a right to possession of the
-Corresponding Source of the work from the predecessor in interest, if
-the predecessor has it or can get it with reasonable efforts.</p>
-
-<p>You may not impose any further restrictions on the exercise of the
-rights granted or affirmed under this License.  For example, you may
-not impose a license fee, royalty, or other charge for exercise of
-rights granted under this License, and you may not initiate litigation
-(including a cross-claim or counterclaim in a lawsuit) alleging that
-any patent claim is infringed by making, using, selling, offering for
-sale, or importing the Program or any portion of it.</p>
-
-<h4><a name="section11"></a>11. Patents.</h4>
-
-<p>A "contributor" is a copyright holder who authorizes use under this
-License of the Program or a work on which the Program is based.  The
-work thus licensed is called the contributor's "contributor version".</p>
-
-<p>A contributor's "essential patent claims" are all patent claims
-owned or controlled by the contributor, whether already acquired or
-hereafter acquired, that would be infringed by some manner, permitted
-by this License, of making, using, or selling its contributor version,
-but do not include claims that would be infringed only as a
-consequence of further modification of the contributor version.  For
-purposes of this definition, "control" includes the right to grant
-patent sublicenses in a manner consistent with the requirements of
-this License.</p>
-
-<p>Each contributor grants you a non-exclusive, worldwide, royalty-free
-patent license under the contributor's essential patent claims, to
-make, use, sell, offer for sale, import and otherwise run, modify and
-propagate the contents of its contributor version.</p>
-
-<p>In the following three paragraphs, a "patent license" is any express
-agreement or commitment, however denominated, not to enforce a patent
-(such as an express permission to practice a patent or covenant not to
-sue for patent infringement).  To "grant" such a patent license to a
-party means to make such an agreement or commitment not to enforce a
-patent against the party.</p>
-
-<p>If you convey a covered work, knowingly relying on a patent license,
-and the Corresponding Source of the work is not available for anyone
-to copy, free of charge and under the terms of this License, through a
-publicly available network server or other readily accessible means,
-then you must either (1) cause the Corresponding Source to be so
-available, or (2) arrange to deprive yourself of the benefit of the
-patent license for this particular work, or (3) arrange, in a manner
-consistent with the requirements of this License, to extend the patent
-license to downstream recipients.  "Knowingly relying" means you have
-actual knowledge that, but for the patent license, your conveying the
-covered work in a country, or your recipient's use of the covered work
-in a country, would infringe one or more identifiable patents in that
-country that you have reason to believe are valid.</p>
-
-<p>If, pursuant to or in connection with a single transaction or
-arrangement, you convey, or propagate by procuring conveyance of, a
-covered work, and grant a patent license to some of the parties
-receiving the covered work authorizing them to use, propagate, modify
-or convey a specific copy of the covered work, then the patent license
-you grant is automatically extended to all recipients of the covered
-work and works based on it.</p>
-
-<p>A patent license is "discriminatory" if it does not include within
-the scope of its coverage, prohibits the exercise of, or is
-conditioned on the non-exercise of one or more of the rights that are
-specifically granted under this License.  You may not convey a covered
-work if you are a party to an arrangement with a third party that is
-in the business of distributing software, under which you make payment
-to the third party based on the extent of your activity of conveying
-the work, and under which the third party grants, to any of the
-parties who would receive the covered work from you, a discriminatory
-patent license (a) in connection with copies of the covered work
-conveyed by you (or copies made from those copies), or (b) primarily
-for and in connection with specific products or compilations that
-contain the covered work, unless you entered into that arrangement,
-or that patent license was granted, prior to 28 March 2007.</p>
-
-<p>Nothing in this License shall be construed as excluding or limiting
-any implied license or other defenses to infringement that may
-otherwise be available to you under applicable patent law.</p>
-
-<h4><a name="section12"></a>12. No Surrender of Others' Freedom.</h4>
-
-<p>If conditions are imposed on you (whether by court order, agreement or
-otherwise) that contradict the conditions of this License, they do not
-excuse you from the conditions of this License.  If you cannot convey a
-covered work so as to satisfy simultaneously your obligations under this
-License and any other pertinent obligations, then as a consequence you may
-not convey it at all.  For example, if you agree to terms that obligate you
-to collect a royalty for further conveying from those to whom you convey
-the Program, the only way you could satisfy both those terms and this
-License would be to refrain entirely from conveying the Program.</p>
-
-<h4><a name="section13"></a>13. Remote Network Interaction; Use with the GNU General Public License.</h4>
-
-<p>Notwithstanding any other provision of this License, if you modify the
-Program, your modified version must prominently offer all users
-interacting with it remotely through a computer network (if your version
-supports such interaction) an opportunity to receive the Corresponding
-Source of your version by providing access to the Corresponding Source
-from a network server at no charge, through some standard or customary
-means of facilitating copying of software.  This Corresponding Source
-shall include the Corresponding Source for any work covered by version 3
-of the GNU General Public License that is incorporated pursuant to the
-following paragraph.</p>
-
-<p>Notwithstanding any other provision of this License, you have permission
-to link or combine any covered work with a work licensed under version 3
-of the GNU General Public License into a single combined work, and to
-convey the resulting work.  The terms of this License will continue to
-apply to the part which is the covered work, but the work with which it is
-combined will remain governed by version 3 of the GNU General Public
-License.</p>
-
-<h4><a name="section14"></a>14. Revised Versions of this License.</h4>
-
-<p>The Free Software Foundation may publish revised and/or new versions of
-the GNU Affero General Public License from time to time.  Such new
-versions will be similar in spirit to the present version, but may differ
-in detail to address new problems or concerns.</p>
-
-<p>Each version is given a distinguishing version number.  If the
-Program specifies that a certain numbered version of the GNU Affero
-General Public License "or any later version" applies to it, you have
-the option of following the terms and conditions either of that
-numbered version or of any later version published by the Free
-Software Foundation.  If the Program does not specify a version number
-of the GNU Affero General Public License, you may choose any version
-ever published by the Free Software Foundation.</p>
-
-<p>If the Program specifies that a proxy can decide which future
-versions of the GNU Affero General Public License can be used, that
-proxy's public statement of acceptance of a version permanently
-authorizes you to choose that version for the Program.</p>
-
-<p>Later license versions may give you additional or different
-permissions.  However, no additional obligations are imposed on any
-author or copyright holder as a result of your choosing to follow a
-later version.</p>
-
-<h4><a name="section15"></a>15. Disclaimer of Warranty.</h4>
-
-<p>THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
-APPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
-HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
-OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
-THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
-PURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
-IS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
-ALL NECESSARY SERVICING, REPAIR OR CORRECTION.</p>
-
-<h4><a name="section16"></a>16. Limitation of Liability.</h4>
-
-<p>IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
-WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
-THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
-GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
-USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
-DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
-PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
-EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
-SUCH DAMAGES.</p>
-
-<h4><a name="section17"></a>17. Interpretation of Sections 15 and 16.</h4>
-
-<p>If the disclaimer of warranty and limitation of liability provided
-above cannot be given local legal effect according to their terms,
-reviewing courts shall apply local law that most closely approximates
-an absolute waiver of all civil liability in connection with the
-Program, unless a warranty or assumption of liability accompanies a
-copy of the Program in return for a fee.</p>
-
-<p>END OF TERMS AND CONDITIONS</p>
-
-<h3><a name="howto"></a>How to Apply These Terms to Your New Programs</h3>
-
-<p>If you develop a new program, and you want it to be of the greatest
-possible use to the public, the best way to achieve this is to make it
-free software which everyone can redistribute and change under these terms.</p>
-
-<p>To do so, attach the following notices to the program.  It is safest
-to attach them to the start of each source file to most effectively
-state the exclusion of warranty; and each file should have at least
-the "copyright" line and a pointer to where the full notice is found.</p>
-
-<pre>    <one line to give the program's name and a brief idea of what it does.>
-    Copyright (C) <year>  <name of author>
-
-    This program is free software: you can redistribute it and/or modify
-    it under the terms of the GNU Affero General Public License as
-    published by the Free Software Foundation, either version 3 of the
-    License, or (at your option) any later version.
-
-    This program is distributed in the hope that it will be useful,
-    but WITHOUT ANY WARRANTY; without even the implied warranty of
-    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-    GNU Affero General Public License for more details.
-
-    You should have received a copy of the GNU Affero General Public License
-    along with this program.  If not, see <http://www.gnu.org/licenses/>.
-</pre>
-
-<p>Also add information on how to contact you by electronic and paper mail.</p>
-
-<p>If your software can interact with users remotely through a computer
-network, you should also make sure that it provides a way for users to
-get its source.  For example, if your program is a web application, its
-interface could display a "Source" link that leads users to an archive
-of the code.  There are many ways you could offer source, and different
-solutions will be better for different programs; see section 13 for the
-specific requirements.</p>
-
-<p>You should also get your employer (if you work as a programmer) or school,
-if any, to sign a "copyright disclaimer" for the program, if necessary.
-For more information on this, and how to apply and follow the GNU AGPL, see
-<<a href="http://www.gnu.org/licenses/">http://www.gnu.org/licenses/</a>>.</p>
-
-</body></html>
diff --git a/guacamole/src/main/webapp/app/auth/authModule.js b/guacamole/src/main/webapp/app/auth/authModule.js
new file mode 100644
index 0000000..f3f5657
--- /dev/null
+++ b/guacamole/src/main/webapp/app/auth/authModule.js
@@ -0,0 +1,26 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * The module for authentication and management of tokens.
+ */
+angular.module('auth', ['ngCookies']);
diff --git a/guacamole/src/main/webapp/app/auth/service/authenticationService.js b/guacamole/src/main/webapp/app/auth/service/authenticationService.js
new file mode 100644
index 0000000..51042a1
--- /dev/null
+++ b/guacamole/src/main/webapp/app/auth/service/authenticationService.js
@@ -0,0 +1,343 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * A service for authenticating a user against the REST API.
+ *
+ * This service broadcasts two events on $rootScope depending on the result of
+ * authentication operations: 'guacLogin' if authentication was successful and
+ * a new token was created, and 'guacLogout' if an existing token is being
+ * destroyed or replaced. Both events will be passed the related token as their
+ * sole parameter.
+ *
+ * If a login attempt results in an existing token being replaced, 'guacLogout'
+ * will be broadcast first for the token being replaced, followed by
+ * 'guacLogin' for the new token.
+ * 
+ * Failed logins may also result in guacInsufficientCredentials or
+ * guacInvalidCredentials events, if the provided credentials were rejected for
+ * being insufficient or invalid respectively. Both events will be provided
+ * the set of parameters originally given to authenticate() and the error that
+ * rejected the credentials. The Error object provided will contain set of
+ * expected credentials returned by the REST endpoint. This set of credentials
+ * will be in the form of a Field array.
+ */
+angular.module('auth').factory('authenticationService', ['$injector',
+        function authenticationService($injector) {
+
+    // Required types
+    var Error = $injector.get('Error');
+
+    // Required services
+    var $cookieStore = $injector.get('$cookieStore');
+    var $http        = $injector.get('$http');
+    var $q           = $injector.get('$q');
+    var $rootScope   = $injector.get('$rootScope');
+
+    var service = {};
+
+    /**
+     * The unique identifier of the local cookie which stores the user's
+     * current authentication token and username.
+     *
+     * @type String
+     */
+    var AUTH_COOKIE_ID = "GUAC_AUTH";
+
+    /**
+     * Makes a request to authenticate a user using the token REST API endpoint
+     * and given arbitrary parameters, returning a promise that succeeds only
+     * if the authentication operation was successful. The resulting
+     * authentication data can be retrieved later via getCurrentToken() or
+     * getCurrentUsername().
+     * 
+     * The provided parameters can be virtually any object, as each property
+     * will be sent as an HTTP parameter in the authentication request.
+     * Standard parameters include "username" for the user's username,
+     * "password" for the user's associated password, and "token" for the
+     * auth token to check/update.
+     * 
+     * If a token is provided, it will be reused if possible.
+     * 
+     * @param {Object} parameters 
+     *     Arbitrary parameters to authenticate with.
+     *
+     * @returns {Promise}
+     *     A promise which succeeds only if the login operation was successful.
+     */
+    service.authenticate = function authenticate(parameters) {
+
+        var authenticationProcess = $q.defer();
+
+        /**
+         * Stores the given authentication data within the browser and marks
+         * the authentication process as completed.
+         *
+         * @param {Object} data
+         *     The authentication data returned by the token REST endpoint.
+         */
+        var completeAuthentication = function completeAuthentication(data) {
+
+            // Store auth data
+            $cookieStore.put(AUTH_COOKIE_ID, {
+                'authToken'            : data.authToken,
+                'username'             : data.username,
+                'dataSource'           : data.dataSource,
+                'availableDataSources' : data.availableDataSources
+            });
+
+            // Process is complete
+            authenticationProcess.resolve();
+
+        };
+
+        // Attempt authentication
+        $http({
+            method: 'POST',
+            url: 'api/tokens',
+            headers: {
+                'Content-Type': 'application/x-www-form-urlencoded'
+            },
+            data: $.param(parameters),
+        })
+
+        // If authentication succeeds, handle received auth data
+        .success(function authenticationSuccessful(data) {
+
+            var currentToken = service.getCurrentToken();
+
+            // If a new token was received, ensure the old token is invalidated,
+            // if any, and notify listeners of the new token
+            if (data.authToken !== currentToken) {
+
+                // If an old token existed, explicitly logout first
+                if (currentToken) {
+                    service.logout()
+                    ['finally'](function logoutComplete() {
+                        completeAuthentication(data);
+                        $rootScope.$broadcast('guacLogin', data.authToken);
+                    });
+                }
+
+                // Otherwise, simply complete authentication and notify of login
+                else {
+                    completeAuthentication(data);
+                    $rootScope.$broadcast('guacLogin', data.authToken);
+                }
+
+            }
+
+            // Otherwise, just finish the auth process
+            else
+                completeAuthentication(data);
+
+        })
+
+        // If authentication fails, propogate failure to returned promise
+        .error(function authenticationFailed(error) {
+
+            // Ensure error object exists, even if the error response is not
+            // coming from the authentication REST endpoint
+            error = new Error(error);
+
+            // Request credentials if provided credentials were invalid
+            if (error.type === Error.Type.INVALID_CREDENTIALS)
+                $rootScope.$broadcast('guacInvalidCredentials', parameters, error);
+
+            // Request more credentials if provided credentials were not enough 
+            else if (error.type === Error.Type.INSUFFICIENT_CREDENTIALS)
+                $rootScope.$broadcast('guacInsufficientCredentials', parameters, error);
+
+            authenticationProcess.reject(error);
+        });
+
+        return authenticationProcess.promise;
+
+    };
+
+    /**
+     * Makes a request to update the current auth token, if any, using the
+     * token REST API endpoint. If the optional parameters object is provided,
+     * its properties will be included as parameters in the update request.
+     * This function returns a promise that succeeds only if the authentication
+     * operation was successful. The resulting authentication data can be
+     * retrieved later via getCurrentToken() or getCurrentUsername().
+     * 
+     * If there is no current auth token, this function behaves identically to
+     * authenticate(), and makes a general authentication request.
+     * 
+     * @param {Object} [parameters]
+     *     Arbitrary parameters to authenticate with, if any.
+     *
+     * @returns {Promise}
+     *     A promise which succeeds only if the login operation was successful.
+     */
+    service.updateCurrentToken = function updateCurrentToken(parameters) {
+
+        // HTTP parameters for the authentication request
+        var httpParameters = {};
+
+        // Add token parameter if current token is known
+        var token = service.getCurrentToken();
+        if (token)
+            httpParameters.token = service.getCurrentToken();
+
+        // Add any additional parameters
+        if (parameters)
+            angular.extend(httpParameters, parameters);
+
+        // Make the request
+        return service.authenticate(httpParameters);
+
+    };
+
+    /**
+     * Makes a request to authenticate a user using the token REST API endpoint
+     * with a username and password, ignoring any currently-stored token, 
+     * returning a promise that succeeds only if the login operation was
+     * successful. The resulting authentication data can be retrieved later
+     * via getCurrentToken() or getCurrentUsername().
+     * 
+     * @param {String} username
+     *     The username to log in with.
+     *
+     * @param {String} password
+     *     The password to log in with.
+     *
+     * @returns {Promise}
+     *     A promise which succeeds only if the login operation was successful.
+     */
+    service.login = function login(username, password) {
+        return service.authenticate({
+            username: username,
+            password: password
+        });
+    };
+
+    /**
+     * Makes a request to logout a user using the login REST API endpoint, 
+     * returning a promise succeeds only if the logout operation was
+     * successful.
+     * 
+     * @returns {Promise}
+     *     A promise which succeeds only if the logout operation was
+     *     successful.
+     */
+    service.logout = function logout() {
+        
+        // Clear authentication data
+        var token = service.getCurrentToken();
+        $cookieStore.remove(AUTH_COOKIE_ID);
+
+        // Notify listeners that a token is being destroyed
+        $rootScope.$broadcast('guacLogout', token);
+
+        // Delete old token
+        return $http({
+            method: 'DELETE',
+            url: 'api/tokens/' + token
+        });
+
+    };
+
+    /**
+     * Returns the username of the current user. If the current user is not
+     * logged in, this value may not be valid.
+     *
+     * @returns {String}
+     *     The username of the current user, or null if no authentication data
+     *     is present.
+     */
+    service.getCurrentUsername = function getCurrentUsername() {
+
+        // Return username, if available
+        var authData = $cookieStore.get(AUTH_COOKIE_ID);
+        if (authData)
+            return authData.username;
+
+        // No auth data present
+        return null;
+
+    };
+
+    /**
+     * Returns the auth token associated with the current user. If the current
+     * user is not logged in, this token may not be valid.
+     *
+     * @returns {String}
+     *     The auth token associated with the current user, or null if no
+     *     authentication data is present.
+     */
+    service.getCurrentToken = function getCurrentToken() {
+
+        // Return auth token, if available
+        var authData = $cookieStore.get(AUTH_COOKIE_ID);
+        if (authData)
+            return authData.authToken;
+
+        // No auth data present
+        return null;
+
+    };
+
+    /**
+     * Returns the identifier of the data source that authenticated the current
+     * user. If the current user is not logged in, this value may not be valid.
+     *
+     * @returns {String}
+     *     The identifier of the data source that authenticated the current
+     *     user, or null if no authentication data is present.
+     */
+    service.getDataSource = function getDataSource() {
+
+        // Return data source, if available
+        var authData = $cookieStore.get(AUTH_COOKIE_ID);
+        if (authData)
+            return authData.dataSource;
+
+        // No auth data present
+        return null;
+
+    };
+
+    /**
+     * Returns the identifiers of all data sources available to the current
+     * user. If the current user is not logged in, this value may not be valid.
+     *
+     * @returns {String[]}
+     *     The identifiers of all data sources availble to the current user,
+     *     or an empty array if no authentication data is present.
+     */
+    service.getAvailableDataSources = function getAvailableDataSources() {
+
+        // Return data sources, if available
+        var authData = $cookieStore.get(AUTH_COOKIE_ID);
+        if (authData)
+            return authData.availableDataSources;
+
+        // No auth data present
+        return [];
+
+    };
+
+    return service;
+}]);
diff --git a/guacamole/src/main/webapp/app/client/clientModule.js b/guacamole/src/main/webapp/app/client/clientModule.js
new file mode 100644
index 0000000..2c127d8
--- /dev/null
+++ b/guacamole/src/main/webapp/app/client/clientModule.js
@@ -0,0 +1,36 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * The module for code used to connect to a connection or balancing group.
+ */
+angular.module('client', [
+    'auth',
+    'element',
+    'history',
+    'navigation',
+    'notification',
+    'osk',
+    'rest',
+    'textInput',
+    'touch'
+]);
diff --git a/guacamole/src/main/webapp/app/client/controllers/clientController.js b/guacamole/src/main/webapp/app/client/controllers/clientController.js
new file mode 100644
index 0000000..fd445d1
--- /dev/null
+++ b/guacamole/src/main/webapp/app/client/controllers/clientController.js
@@ -0,0 +1,734 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * The controller for the page used to connect to a connection or balancing group.
+ */
+angular.module('client').controller('clientController', ['$scope', '$routeParams', '$injector',
+        function clientController($scope, $routeParams, $injector) {
+
+    // Required types
+    var ManagedClient      = $injector.get('ManagedClient');
+    var ManagedClientState = $injector.get('ManagedClientState');
+    var ManagedFilesystem  = $injector.get('ManagedFilesystem');
+    var ScrollState        = $injector.get('ScrollState');
+
+    // Required services
+    var $location             = $injector.get('$location');
+    var authenticationService = $injector.get('authenticationService');
+    var guacClientManager     = $injector.get('guacClientManager');
+    var guacNotification      = $injector.get('guacNotification');
+    var preferenceService     = $injector.get('preferenceService');
+    var userPageService       = $injector.get('userPageService');
+
+    /**
+     * The minimum number of pixels a drag gesture must move to result in the
+     * menu being shown or hidden.
+     *
+     * @type Number
+     */
+    var MENU_DRAG_DELTA = 64;
+
+    /**
+     * The maximum X location of the start of a drag gesture for that gesture
+     * to potentially show the menu.
+     *
+     * @type Number
+     */
+    var MENU_DRAG_MARGIN = 64;
+
+    /**
+     * When showing or hiding the menu via a drag gesture, the maximum number
+     * of pixels the touch can move vertically and still affect the menu.
+     * 
+     * @type Number
+     */
+    var MENU_DRAG_VERTICAL_TOLERANCE = 10;
+
+    /*
+     * In order to open the guacamole menu, we need to hit ctrl-alt-shift. There are
+     * several possible keysysms for each key.
+     */
+    var SHIFT_KEYS  = {0xFFE1 : true, 0xFFE2 : true},
+        ALT_KEYS    = {0xFFE9 : true, 0xFFEA : true, 0xFE03 : true,
+                       0xFFE7 : true, 0xFFE8 : true},
+        CTRL_KEYS   = {0xFFE3 : true, 0xFFE4 : true},
+        MENU_KEYS   = angular.extend({}, SHIFT_KEYS, ALT_KEYS, CTRL_KEYS);
+
+    /**
+     * All client error codes handled and passed off for translation. Any error
+     * code not present in this list will be represented by the "DEFAULT"
+     * translation.
+     */
+    var CLIENT_ERRORS = {
+        0x0201: true,
+        0x0202: true,
+        0x0203: true,
+        0x0205: true,
+        0x0301: true,
+        0x0303: true,
+        0x0308: true,
+        0x031D: true
+    };
+
+    /**
+     * All error codes for which automatic reconnection is appropriate when a
+     * client error occurs.
+     */
+    var CLIENT_AUTO_RECONNECT = {
+        0x0200: true,
+        0x0202: true,
+        0x0203: true,
+        0x0301: true,
+        0x0308: true
+    };
+ 
+    /**
+     * All tunnel error codes handled and passed off for translation. Any error
+     * code not present in this list will be represented by the "DEFAULT"
+     * translation.
+     */
+    var TUNNEL_ERRORS = {
+        0x0201: true,
+        0x0202: true,
+        0x0203: true,
+        0x0204: true,
+        0x0205: true,
+        0x0301: true,
+        0x0303: true,
+        0x0308: true,
+        0x031D: true
+    };
+ 
+    /**
+     * All error codes for which automatic reconnection is appropriate when a
+     * tunnel error occurs.
+     */
+    var TUNNEL_AUTO_RECONNECT = {
+        0x0200: true,
+        0x0202: true,
+        0x0203: true,
+        0x0308: true
+    };
+
+    /**
+     * Action which logs out from Guacamole entirely.
+     */
+    var LOGOUT_ACTION = {
+        name      : "CLIENT.ACTION_LOGOUT",
+        className : "logout button",
+        callback  : function logoutCallback() {
+            authenticationService.logout()['finally'](function logoutComplete() {
+                $location.url('/');
+            });
+        }
+    };
+
+    /**
+     * Action which returns the user to the home screen. If the home page has
+     * not yet been determined, this will be null.
+     */
+    var NAVIGATE_HOME_ACTION = null;
+
+    // Assign home page action once user's home page has been determined
+    userPageService.getHomePage()
+    .then(function homePageRetrieved(homePage) {
+
+        // Define home action only if different from current location
+        if ($location.path() !== homePage.url) {
+            NAVIGATE_HOME_ACTION = {
+                name      : "CLIENT.ACTION_NAVIGATE_HOME",
+                className : "home button",
+                callback  : function navigateHomeCallback() {
+                    $location.url(homePage.url);
+                }
+            };
+        }
+
+    });
+
+    /**
+     * Action which replaces the current client with a newly-connected client.
+     */
+    var RECONNECT_ACTION = {
+        name      : "CLIENT.ACTION_RECONNECT",
+        className : "reconnect button",
+        callback  : function reconnectCallback() {
+            $scope.client = guacClientManager.replaceManagedClient($routeParams.id, $routeParams.params);
+            guacNotification.showStatus(false);
+        }
+    };
+
+    /**
+     * The reconnect countdown to display if an error or status warrants an
+     * automatic, timed reconnect.
+     */
+    var RECONNECT_COUNTDOWN = {
+        text: "CLIENT.TEXT_RECONNECT_COUNTDOWN",
+        callback: RECONNECT_ACTION.callback,
+        remaining: 15
+    };
+
+    /**
+     * Menu-specific properties.
+     */
+    $scope.menu = {
+
+        /**
+         * Whether the menu is currently shown.
+         *
+         * @type Boolean
+         */
+        shown : false,
+
+        /**
+         * Whether the Guacamole display should be scaled to fit the browser
+         * window.
+         *
+         * @type Boolean
+         */
+        autoFit : true,
+
+        /**
+         * The currently selected input method. This may be any of the values
+         * defined within preferenceService.inputMethods.
+         *
+         * @type String
+         */
+        inputMethod : preferenceService.preferences.inputMethod,
+
+        /**
+         * The current scroll state of the menu.
+         *
+         * @type ScrollState
+         */
+        scrollState : new ScrollState()
+
+    };
+
+    // Convenience method for closing the menu
+    $scope.closeMenu = function closeMenu() {
+        $scope.menu.shown = false;
+    };
+
+    // Update the model when clipboard data received from client
+    $scope.$on('guacClientClipboard', function clientClipboardListener(event, client, mimetype, clipboardData) {
+       $scope.clipboardData = clipboardData; 
+    });
+
+    /**
+     * The client which should be attached to the client UI.
+     *
+     * @type ManagedClient
+     */
+    $scope.client = guacClientManager.getManagedClient($routeParams.id, $routeParams.params);
+
+    var keysCurrentlyPressed = {};
+
+    /*
+     * Check to see if all currently pressed keys are in the set of menu keys.
+     */  
+    function checkMenuModeActive() {
+        for(var keysym in keysCurrentlyPressed) {
+            if(!MENU_KEYS[keysym]) {
+                return false;
+            }
+        }
+        
+        return true;
+    }
+
+    // Hide menu when the user swipes from the right
+    $scope.menuDrag = function menuDrag(inProgress, startX, startY, currentX, currentY, deltaX, deltaY) {
+
+        // Hide menu if swipe gesture is detected
+        if (Math.abs(currentY - startY)  <  MENU_DRAG_VERTICAL_TOLERANCE
+                  && startX   - currentX >= MENU_DRAG_DELTA)
+            $scope.menu.shown = false;
+
+        // Scroll menu by default
+        else {
+            $scope.menu.scrollState.left -= deltaX;
+            $scope.menu.scrollState.top -= deltaY;
+        }
+
+        return false;
+
+    };
+
+    // Update menu or client based on dragging gestures
+    $scope.clientDrag = function clientDrag(inProgress, startX, startY, currentX, currentY, deltaX, deltaY) {
+
+        // Show menu if the user swipes from the left
+        if (startX <= MENU_DRAG_MARGIN) {
+
+            if (Math.abs(currentY - startY) <  MENU_DRAG_VERTICAL_TOLERANCE
+                      && currentX - startX  >= MENU_DRAG_DELTA)
+                $scope.menu.shown = true;
+
+        }
+
+        // Scroll display if absolute mouse is in use
+        else if ($scope.client.clientProperties.emulateAbsoluteMouse) {
+            $scope.client.clientProperties.scrollLeft -= deltaX;
+            $scope.client.clientProperties.scrollTop -= deltaY;
+        }
+
+        return false;
+
+    };
+
+    /**
+     * If a pinch gesture is in progress, the scale of the client display when
+     * the pinch gesture began.
+     *
+     * @type Number
+     */
+    var initialScale = null;
+
+    /**
+     * If a pinch gesture is in progress, the X coordinate of the point on the
+     * client display that was centered within the pinch at the time the
+     * gesture began.
+     * 
+     * @type Number
+     */
+    var initialCenterX = 0;
+
+    /**
+     * If a pinch gesture is in progress, the Y coordinate of the point on the
+     * client display that was centered within the pinch at the time the
+     * gesture began.
+     * 
+     * @type Number
+     */
+    var initialCenterY = 0;
+
+    // Zoom and pan client via pinch gestures
+    $scope.clientPinch = function clientPinch(inProgress, startLength, currentLength, centerX, centerY) {
+
+        // Do not handle pinch gestures while relative mouse is in use
+        if (!$scope.client.clientProperties.emulateAbsoluteMouse)
+            return false;
+
+        // Stop gesture if not in progress
+        if (!inProgress) {
+            initialScale = null;
+            return false;
+        }
+
+        // Set initial scale if gesture has just started
+        if (!initialScale) {
+            initialScale   = $scope.client.clientProperties.scale;
+            initialCenterX = (centerX + $scope.client.clientProperties.scrollLeft) / initialScale;
+            initialCenterY = (centerY + $scope.client.clientProperties.scrollTop)  / initialScale;
+        }
+
+        // Determine new scale absolutely
+        var currentScale = initialScale * currentLength / startLength;
+
+        // Fix scale within limits - scroll will be miscalculated otherwise
+        currentScale = Math.max(currentScale, $scope.client.clientProperties.minScale);
+        currentScale = Math.min(currentScale, $scope.client.clientProperties.maxScale);
+
+        // Update scale based on pinch distance
+        $scope.menu.autoFit = false;
+        $scope.client.clientProperties.autoFit = false;
+        $scope.client.clientProperties.scale = currentScale;
+
+        // Scroll display to keep original pinch location centered within current pinch
+        $scope.client.clientProperties.scrollLeft = initialCenterX * currentScale - centerX;
+        $scope.client.clientProperties.scrollTop  = initialCenterY * currentScale - centerY;
+
+        return false;
+
+    };
+
+    // Show/hide UI elements depending on input method
+    $scope.$watch('menu.inputMethod', function setInputMethod(inputMethod) {
+
+        // Show input methods only if selected
+        $scope.showOSK       = (inputMethod === 'osk');
+        $scope.showTextInput = (inputMethod === 'text');
+
+    });
+
+    $scope.$watch('menu.shown', function menuVisibilityChanged(menuShown, menuShownPreviousState) {
+        
+        // Send clipboard data if menu is hidden
+        if (!menuShown && menuShownPreviousState)
+            $scope.$broadcast('guacClipboard', 'text/plain', $scope.client.clipboardData); 
+        
+        // Disable client keyboard if the menu is shown
+        $scope.client.clientProperties.keyboardEnabled = !menuShown;
+
+    });
+    
+    $scope.$on('guacKeydown', function keydownListener(event, keysym, keyboard) {
+        keysCurrentlyPressed[keysym] = true;   
+        
+        /* 
+         * If only menu keys are pressed, and we have one keysym from each group,
+         * and one of the keys is being released, show the menu. 
+         */
+        if(checkMenuModeActive()) {
+            var currentKeysPressedKeys = Object.keys(keysCurrentlyPressed);
+            
+            // Check that there is a key pressed for each of the required key classes
+            if(!_.isEmpty(_.pick(SHIFT_KEYS, currentKeysPressedKeys)) &&
+               !_.isEmpty(_.pick(ALT_KEYS, currentKeysPressedKeys)) &&
+               !_.isEmpty(_.pick(CTRL_KEYS, currentKeysPressedKeys))
+            ) {
+        
+                // Don't send this key event through to the client
+                event.preventDefault();
+                
+                // Reset the keys pressed
+                keysCurrentlyPressed = {};
+                keyboard.reset();
+                
+                // Toggle the menu
+                $scope.$apply(function() {
+                    $scope.menu.shown = !$scope.menu.shown;
+                });
+            }
+        }
+    });
+
+    // Listen for broadcasted keyup events and fire the appropriate listeners
+    $scope.$on('guacKeyup', function keyupListener(event, keysym, keyboard) {
+        delete keysCurrentlyPressed[keysym];
+    });
+
+    // Update page title when client name is received
+    $scope.$watch('client.name', function clientNameChanged(name) {
+        $scope.page.title = name;
+    });
+
+    /**
+     * Displays a notification at the end of a Guacamole connection, whether
+     * that connection is ending normally or due to an error. As the end of
+     * a Guacamole connection may be due to changes in authentication status,
+     * this will also implicitly peform a re-authentication attempt to check
+     * for such changes, possibly resulting in auth-related events like
+     * guacInvalidCredentials.
+     *
+     * @param {Notification|Boolean|Object} status
+     *     The status notification to show, as would be accepted by
+     *     guacNotification.showStatus().
+     */
+    var notifyConnectionClosed = function notifyConnectionClosed(status) {
+
+        // Re-authenticate to verify auth status at end of connection
+        authenticationService.updateCurrentToken($location.search())
+
+        // Show the requested status once the authentication check has finished
+        ['finally'](function authenticationCheckComplete() {
+            guacNotification.showStatus(status);
+        });
+
+    };
+
+    // Show status dialog when connection status changes
+    $scope.$watch('client.clientState.connectionState', function clientStateChanged(connectionState) {
+
+        // Hide any existing status
+        guacNotification.showStatus(false);
+
+        // Do not display status if status not known
+        if (!connectionState)
+            return;
+
+        // Build array of available actions
+        var actions;
+        if (NAVIGATE_HOME_ACTION)
+            actions = [ NAVIGATE_HOME_ACTION, RECONNECT_ACTION, LOGOUT_ACTION ];
+        else
+            actions = [ RECONNECT_ACTION, LOGOUT_ACTION ];
+
+        // Get any associated status code
+        var status = $scope.client.clientState.statusCode;
+
+        // Connecting 
+        if (connectionState === ManagedClientState.ConnectionState.CONNECTING
+         || connectionState === ManagedClientState.ConnectionState.WAITING) {
+            guacNotification.showStatus({
+                title: "CLIENT.DIALOG_HEADER_CONNECTING",
+                text: "CLIENT.TEXT_CLIENT_STATUS_" + connectionState.toUpperCase()
+            });
+        }
+
+        // Client error
+        else if (connectionState === ManagedClientState.ConnectionState.CLIENT_ERROR) {
+
+            // Determine translation name of error
+            var errorName = (status in CLIENT_ERRORS) ? status.toString(16).toUpperCase() : "DEFAULT";
+
+            // Determine whether the reconnect countdown applies
+            var countdown = (status in CLIENT_AUTO_RECONNECT) ? RECONNECT_COUNTDOWN : null;
+
+            // Show error status
+            notifyConnectionClosed({
+                className : "error",
+                title     : "CLIENT.DIALOG_HEADER_CONNECTION_ERROR",
+                text      : "CLIENT.ERROR_CLIENT_" + errorName,
+                countdown : countdown,
+                actions   : actions
+            });
+
+        }
+
+        // Tunnel error
+        else if (connectionState === ManagedClientState.ConnectionState.TUNNEL_ERROR) {
+
+            // Determine translation name of error
+            var errorName = (status in TUNNEL_ERRORS) ? status.toString(16).toUpperCase() : "DEFAULT";
+
+            // Determine whether the reconnect countdown applies
+            var countdown = (status in TUNNEL_AUTO_RECONNECT) ? RECONNECT_COUNTDOWN : null;
+
+            // Show error status
+            notifyConnectionClosed({
+                className : "error",
+                title     : "CLIENT.DIALOG_HEADER_CONNECTION_ERROR",
+                text      : "CLIENT.ERROR_TUNNEL_" + errorName,
+                countdown : countdown,
+                actions   : actions
+            });
+
+        }
+
+        // Disconnected
+        else if (connectionState === ManagedClientState.ConnectionState.DISCONNECTED) {
+            notifyConnectionClosed({
+                title   : "CLIENT.DIALOG_HEADER_DISCONNECTED",
+                text    : "CLIENT.TEXT_CLIENT_STATUS_" + connectionState.toUpperCase(),
+                actions : actions
+            });
+        }
+
+        // Hide status for all other states
+        else
+            guacNotification.showStatus(false);
+
+    });
+
+    $scope.formattedScale = function formattedScale() {
+        return Math.round($scope.client.clientProperties.scale * 100);
+    };
+    
+    $scope.zoomIn = function zoomIn() {
+        $scope.menu.autoFit = false;
+        $scope.client.clientProperties.autoFit = false;
+        $scope.client.clientProperties.scale += 0.1;
+    };
+    
+    $scope.zoomOut = function zoomOut() {
+        $scope.client.clientProperties.autoFit = false;
+        $scope.client.clientProperties.scale -= 0.1;
+    };
+    
+    $scope.changeAutoFit = function changeAutoFit() {
+        if ($scope.menu.autoFit && $scope.client.clientProperties.minScale) {
+            $scope.client.clientProperties.autoFit = true;
+        }
+        else {
+            $scope.client.clientProperties.autoFit = false;
+            $scope.client.clientProperties.scale = 1; 
+        }
+    };
+    
+    $scope.autoFitDisabled = function() {
+        return $scope.client.clientProperties.minZoom >= 1;
+    };
+
+    /**
+     * Immediately disconnects the currently-connected client, if any.
+     */
+    $scope.disconnect = function disconnect() {
+
+        // Disconnect if client is available
+        if ($scope.client)
+            $scope.client.client.disconnect();
+
+        // Hide menu
+        $scope.menu.shown = false;
+
+    };
+
+    /**
+     * Action which immediately disconnects the currently-connected client, if
+     * any.
+     */
+    var DISCONNECT_MENU_ACTION = {
+        name      : 'CLIENT.ACTION_DISCONNECT',
+        className : 'danger disconnect',
+        callback  : $scope.disconnect
+    };
+
+    // Set client-specific menu actions
+    $scope.clientMenuActions = [ DISCONNECT_MENU_ACTION ];
+
+    /**
+     * The currently-visible filesystem within the filesystem menu, if the
+     * filesystem menu is open. If no filesystem is currently visible, this
+     * will be null.
+     *
+     * @type ManagedFilesystem
+     */
+    $scope.filesystemMenuContents = null;
+
+    /**
+     * Hides the filesystem menu.
+     */
+    $scope.hideFilesystemMenu = function hideFilesystemMenu() {
+        $scope.filesystemMenuContents = null;
+    };
+
+    /**
+     * Shows the filesystem menu, displaying the contents of the given
+     * filesystem within it.
+     *
+     * @param {ManagedFilesystem} filesystem
+     *     The filesystem to show within the filesystem menu.
+     */
+    $scope.showFilesystemMenu = function showFilesystemMenu(filesystem) {
+        $scope.filesystemMenuContents = filesystem;
+    };
+
+    /**
+     * Returns whether the filesystem menu should be visible.
+     *
+     * @returns {Boolean}
+     *     true if the filesystem menu is shown, false otherwise.
+     */
+    $scope.isFilesystemMenuShown = function isFilesystemMenuShown() {
+        return !!$scope.filesystemMenuContents && $scope.menu.shown;
+    };
+
+    // Automatically refresh display when filesystem menu is shown
+    $scope.$watch('isFilesystemMenuShown()', function refreshFilesystem() {
+
+        // Refresh filesystem, if defined
+        var filesystem = $scope.filesystemMenuContents;
+        if (filesystem)
+            ManagedFilesystem.refresh(filesystem, filesystem.currentDirectory);
+
+    });
+
+    /**
+     * Returns the full path to the given file as an ordered array of parent
+     * directories.
+     *
+     * @param {ManagedFilesystem.File} file
+     *     The file whose full path should be retrieved.
+     *
+     * @returns {ManagedFilesystem.File[]}
+     *     An array of directories which make up the hierarchy containing the
+     *     given file, in order of increasing depth.
+     */
+    $scope.getPath = function getPath(file) {
+
+        var path = [];
+
+        // Add all files to path in ascending order of depth
+        while (file && file.parent) {
+            path.unshift(file);
+            file = file.parent;
+        }
+
+        return path;
+
+    };
+
+    /**
+     * Changes the current directory of the given filesystem to the given
+     * directory.
+     *
+     * @param {ManagedFilesystem} filesystem
+     *     The filesystem whose current directory should be changed.
+     *
+     * @param {ManagedFilesystem.File} file
+     *     The directory to change to.
+     */
+    $scope.changeDirectory = function changeDirectory(filesystem, file) {
+        ManagedFilesystem.changeDirectory(filesystem, file);
+    };
+
+    /**
+     * Begins a file upload through the attached Guacamole client for
+     * each file in the given FileList.
+     *
+     * @param {FileList} files
+     *     The files to upload.
+     */
+    $scope.uploadFiles = function uploadFiles(files) {
+
+        // Ignore file uploads if no attached client
+        if (!$scope.client)
+            return;
+
+        // Upload each file
+        for (var i = 0; i < files.length; i++)
+            ManagedClient.uploadFile($scope.client, files[i], $scope.filesystemMenuContents);
+
+    };
+
+    /**
+     * Determines whether the attached client has associated file transfers,
+     * regardless of those file transfers' state.
+     *
+     * @returns {Boolean}
+     *     true if there are any file transfers associated with the
+     *     attached client, false otherise.
+     */
+    $scope.hasTransfers = function hasTransfers() {
+
+        // There are no file transfers if there is no client
+        if (!$scope.client)
+            return false;
+
+        return !!($scope.client.uploads.length || $scope.client.downloads.length);
+
+    };
+
+    // Clean up when view destroyed
+    $scope.$on('$destroy', function clientViewDestroyed() {
+
+        // Remove client from client manager if no longer connected
+        var managedClient = $scope.client;
+        if (managedClient) {
+
+            // Get current connection state
+            var connectionState = managedClient.clientState.connectionState;
+
+            // If disconnected, remove from management
+            if (connectionState === ManagedClientState.ConnectionState.DISCONNECTED
+             || connectionState === ManagedClientState.ConnectionState.TUNNEL_ERROR
+             || connectionState === ManagedClientState.ConnectionState.CLIENT_ERROR)
+                guacClientManager.removeManagedClient(managedClient.id);
+
+        }
+
+    });
+
+}]);
diff --git a/guacamole/src/main/webapp/app/client/directives/guacClient.js b/guacamole/src/main/webapp/app/client/directives/guacClient.js
new file mode 100644
index 0000000..595be8d
--- /dev/null
+++ b/guacamole/src/main/webapp/app/client/directives/guacClient.js
@@ -0,0 +1,450 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * A directive for the guacamole client.
+ */
+angular.module('client').directive('guacClient', [function guacClient() {
+
+    return {
+        // Element only
+        restrict: 'E',
+        replace: true,
+        scope: {
+
+            /**
+             * The client to display within this guacClient directive.
+             * 
+             * @type ManagedClient
+             */
+            client : '='
+            
+        },
+        templateUrl: 'app/client/templates/guacClient.html',
+        controller: ['$scope', '$injector', '$element', function guacClientController($scope, $injector, $element) {
+   
+            // Required types
+            var ManagedClient = $injector.get('ManagedClient');
+                
+            // Required services
+            var $window = $injector.get('$window');
+                
+            /**
+             * Whether the local, hardware mouse cursor is in use.
+             * 
+             * @type Boolean
+             */
+            var localCursor = false;
+
+            /**
+             * The current Guacamole client instance.
+             * 
+             * @type Guacamole.Client 
+             */
+            var client = null;
+
+            /**
+             * The display of the current Guacamole client instance.
+             * 
+             * @type Guacamole.Display
+             */
+            var display = null;
+
+            /**
+             * The element associated with the display of the current
+             * Guacamole client instance.
+             *
+             * @type Element
+             */
+            var displayElement = null;
+
+            /**
+             * The element which must contain the Guacamole display element.
+             *
+             * @type Element
+             */
+            var displayContainer = $element.find('.display')[0];
+
+            /**
+             * The main containing element for the entire directive.
+             * 
+             * @type Element
+             */
+            var main = $element[0];
+
+            /**
+             * The element which functions as a detector for size changes.
+             * 
+             * @type Element
+             */
+            var resizeSensor = $element.find('.resize-sensor')[0];
+
+            /**
+             * Guacamole mouse event object, wrapped around the main client
+             * display.
+             *
+             * @type Guacamole.Mouse
+             */
+            var mouse = new Guacamole.Mouse(displayContainer);
+
+            /**
+             * Guacamole absolute mouse emulation object, wrapped around the
+             * main client display.
+             *
+             * @type Guacamole.Mouse.Touchscreen
+             */
+            var touchScreen = new Guacamole.Mouse.Touchscreen(displayContainer);
+
+            /**
+             * Guacamole relative mouse emulation object, wrapped around the
+             * main client display.
+             *
+             * @type Guacamole.Mouse.Touchpad
+             */
+            var touchPad = new Guacamole.Mouse.Touchpad(displayContainer);
+
+            /**
+             * Updates the scale of the attached Guacamole.Client based on current window
+             * size and "auto-fit" setting.
+             */
+            var updateDisplayScale = function updateDisplayScale() {
+
+                if (!display) return;
+
+                // Calculate scale to fit screen
+                $scope.client.clientProperties.minScale = Math.min(
+                    main.offsetWidth  / Math.max(display.getWidth(),  1),
+                    main.offsetHeight / Math.max(display.getHeight(), 1)
+                );
+
+                // Calculate appropriate maximum zoom level
+                $scope.client.clientProperties.maxScale = Math.max($scope.client.clientProperties.minScale, 3);
+
+                // Clamp zoom level, maintain auto-fit
+                if (display.getScale() < $scope.client.clientProperties.minScale || $scope.client.clientProperties.autoFit)
+                    $scope.client.clientProperties.scale = $scope.client.clientProperties.minScale;
+
+                else if (display.getScale() > $scope.client.clientProperties.maxScale)
+                    $scope.client.clientProperties.scale = $scope.client.clientProperties.maxScale;
+
+            };
+
+            /**
+             * Scrolls the client view such that the mouse cursor is visible.
+             *
+             * @param {Guacamole.Mouse.State} mouseState The current mouse
+             *                                           state.
+             */
+            var scrollToMouse = function scrollToMouse(mouseState) {
+
+                // Determine mouse position within view
+                var mouse_view_x = mouseState.x + displayContainer.offsetLeft - main.scrollLeft;
+                var mouse_view_y = mouseState.y + displayContainer.offsetTop  - main.scrollTop;
+
+                // Determine viewport dimensions
+                var view_width  = main.offsetWidth;
+                var view_height = main.offsetHeight;
+
+                // Determine scroll amounts based on mouse position relative to document
+
+                var scroll_amount_x;
+                if (mouse_view_x > view_width)
+                    scroll_amount_x = mouse_view_x - view_width;
+                else if (mouse_view_x < 0)
+                    scroll_amount_x = mouse_view_x;
+                else
+                    scroll_amount_x = 0;
+
+                var scroll_amount_y;
+                if (mouse_view_y > view_height)
+                    scroll_amount_y = mouse_view_y - view_height;
+                else if (mouse_view_y < 0)
+                    scroll_amount_y = mouse_view_y;
+                else
+                    scroll_amount_y = 0;
+
+                // Scroll (if necessary) to keep mouse on screen.
+                main.scrollLeft += scroll_amount_x;
+                main.scrollTop  += scroll_amount_y;
+
+            };
+
+            /**
+             * Sends the given mouse state to the current client.
+             *
+             * @param {Guacamole.Mouse.State} mouseState The mouse state to
+             *                                           send.
+             */
+            var sendScaledMouseState = function sendScaledMouseState(mouseState) {
+
+                // Scale event by current scale
+                var scaledState = new Guacamole.Mouse.State(
+                        mouseState.x / display.getScale(),
+                        mouseState.y / display.getScale(),
+                        mouseState.left,
+                        mouseState.middle,
+                        mouseState.right,
+                        mouseState.up,
+                        mouseState.down);
+
+                // Send mouse event
+                client.sendMouseState(scaledState);
+
+            };
+
+            // Attach any given managed client
+            $scope.$watch('client', function attachManagedClient(managedClient) {
+
+                // Remove any existing display
+                displayContainer.innerHTML = "";
+
+                // Only proceed if a client is given 
+                if (!managedClient)
+                    return;
+
+                // Get Guacamole client instance
+                client = managedClient.client;
+
+                // Attach possibly new display
+                display = client.getDisplay();
+                display.scale($scope.client.clientProperties.scale);
+
+                // Add display element
+                displayElement = display.getElement();
+                displayContainer.appendChild(displayElement);
+
+                // Do nothing when the display element is clicked on
+                display.getElement().onclick = function(e) {
+                    e.preventDefault();
+                    return false;
+                };
+
+            });
+
+            // Update actual view scrollLeft when scroll properties change
+            $scope.$watch('client.clientProperties.scrollLeft', function scrollLeftChanged(scrollLeft) {
+                main.scrollLeft = scrollLeft;
+                $scope.client.clientProperties.scrollLeft = main.scrollLeft;
+            });
+
+            // Update actual view scrollTop when scroll properties change
+            $scope.$watch('client.clientProperties.scrollTop', function scrollTopChanged(scrollTop) {
+                main.scrollTop = scrollTop;
+                $scope.client.clientProperties.scrollTop = main.scrollTop;
+            });
+
+            // Update scale when display is resized
+            $scope.$watch('client.managedDisplay.size', function setDisplaySize() {
+                $scope.$evalAsync(updateDisplayScale);
+            });
+
+            // Keep local cursor up-to-date
+            $scope.$watch('client.managedDisplay.cursor', function setCursor(cursor) {
+                if (cursor)
+                    localCursor = mouse.setCursor(cursor.canvas, cursor.x, cursor.y);
+            });
+
+            // Swap mouse emulation modes depending on absolute mode flag
+            $scope.$watch('client.clientProperties.emulateAbsoluteMouse', function(emulateAbsoluteMouse) {
+
+                if (!client || !display) return;
+
+                var handleMouseState = function handleMouseState(mouseState) {
+
+                    // Ensure software cursor is shown
+                    display.showCursor(true);
+
+                    // Send mouse state, ensure cursor is visible
+                    scrollToMouse(mouseState);
+                    sendScaledMouseState(mouseState);
+
+                };
+
+                var newMode, oldMode;
+
+                // Switch to touchscreen if absolute
+                if (emulateAbsoluteMouse) {
+                    newMode = touchScreen;
+                    oldMode = touchPad;
+                }
+
+                // Switch to touchpad if not absolute (relative)
+                else {
+                    newMode = touchPad;
+                    oldMode = touchScreen;
+                }
+
+                // Set applicable mouse emulation object, unset the old one
+                if (newMode) {
+
+                    if (oldMode) {
+                        oldMode.onmousedown = oldMode.onmouseup = oldMode.onmousemove = null;
+                        newMode.currentState.x = oldMode.currentState.x;
+                        newMode.currentState.y = oldMode.currentState.y;
+                    }
+
+                    newMode.onmousedown = newMode.onmouseup = newMode.onmousemove = handleMouseState;
+
+                }
+
+            });
+
+            // Adjust scale if modified externally
+            $scope.$watch('client.clientProperties.scale', function changeScale(scale) {
+
+                // Fix scale within limits
+                scale = Math.max(scale, $scope.client.clientProperties.minScale);
+                scale = Math.min(scale, $scope.client.clientProperties.maxScale);
+
+                // If at minimum zoom level, hide scroll bars
+                if (scale === $scope.client.clientProperties.minScale)
+                    main.style.overflow = "hidden";
+
+                // If not at minimum zoom level, show scroll bars
+                else
+                    main.style.overflow = "auto";
+
+                // Apply scale if client attached
+                if (display)
+                    display.scale(scale);
+                
+                if (scale !== $scope.client.clientProperties.scale)
+                    $scope.client.clientProperties.scale = scale;
+
+            });
+            
+            // If autofit is set, the scale should be set to the minimum scale, filling the screen
+            $scope.$watch('client.clientProperties.autoFit', function changeAutoFit(autoFit) {
+                if(autoFit)
+                    $scope.client.clientProperties.scale = $scope.client.clientProperties.minScale;
+            });
+            
+            // If the element is resized, attempt to resize client
+            $scope.mainElementResized = function mainElementResized() {
+
+                // Send new display size, if changed
+                if (client && display) {
+
+                    var pixelDensity = $window.devicePixelRatio || 1;
+                    var width  = main.offsetWidth  * pixelDensity;
+                    var height = main.offsetHeight * pixelDensity;
+
+                    if (display.getWidth() !== width || display.getHeight() !== height)
+                        client.sendSize(width, height);
+
+                }
+
+                $scope.$evalAsync(updateDisplayScale);
+
+            };
+
+            // Watch for changes to mouse emulation mode
+            // Send all received mouse events to the client
+            mouse.onmousedown =
+            mouse.onmouseup   =
+            mouse.onmousemove = function(mouseState) {
+
+                if (!client || !display)
+                    return;
+
+                // Send mouse state, show cursor if necessary
+                display.showCursor(!localCursor);
+                sendScaledMouseState(mouseState);
+
+            };
+
+            // Hide software cursor when mouse leaves display
+            mouse.onmouseout = function() {
+                if (!display) return;
+                display.showCursor(false);
+            };
+
+            // Update remote clipboard if local clipboard changes
+            $scope.$on('guacClipboard', function onClipboard(event, mimetype, data) {
+                if (client)
+                    client.setClipboard(data);
+            });
+
+            // Translate local keydown events to remote keydown events if keyboard is enabled
+            $scope.$on('guacKeydown', function keydownListener(event, keysym, keyboard) {
+                if ($scope.client.clientProperties.keyboardEnabled && !event.defaultPrevented) {
+                    client.sendKeyEvent(1, keysym);
+                    event.preventDefault();
+                }
+            });
+            
+            // Translate local keyup events to remote keyup events if keyboard is enabled
+            $scope.$on('guacKeyup', function keyupListener(event, keysym, keyboard) {
+                if ($scope.client.clientProperties.keyboardEnabled && !event.defaultPrevented) {
+                    client.sendKeyEvent(0, keysym);
+                    event.preventDefault();
+                }   
+            });
+
+            // Universally handle all synthetic keydown events
+            $scope.$on('guacSyntheticKeydown', function syntheticKeydownListener(event, keysym) {
+                client.sendKeyEvent(1, keysym);
+            });
+            
+            // Universally handle all synthetic keyup events
+            $scope.$on('guacSyntheticKeyup', function syntheticKeyupListener(event, keysym) {
+                client.sendKeyEvent(0, keysym);
+            });
+            
+            /**
+             * Ignores the given event.
+             * 
+             * @param {Event} e The event to ignore.
+             */
+            function ignoreEvent(e) {
+               e.preventDefault();
+               e.stopPropagation();
+            }
+
+            // Handle and ignore dragenter/dragover
+            displayContainer.addEventListener("dragenter", ignoreEvent, false);
+            displayContainer.addEventListener("dragover",  ignoreEvent, false);
+
+            // File drop event handler
+            displayContainer.addEventListener("drop", function(e) {
+
+                e.preventDefault();
+                e.stopPropagation();
+
+                // Ignore file drops if no attached client
+                if (!$scope.client)
+                    return;
+
+                // Upload each file 
+                var files = e.dataTransfer.files;
+                for (var i=0; i<files.length; i++)
+                    ManagedClient.uploadFile($scope.client, files[i]);
+
+            }, false);
+
+            /*
+             * END CLIENT DIRECTIVE                                           
+             */
+                
+        }]
+    };
+}]);
\ No newline at end of file
diff --git a/guacamole/src/main/webapp/app/client/directives/guacFileBrowser.js b/guacamole/src/main/webapp/app/client/directives/guacFileBrowser.js
new file mode 100644
index 0000000..c68b607
--- /dev/null
+++ b/guacamole/src/main/webapp/app/client/directives/guacFileBrowser.js
@@ -0,0 +1,292 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * A directive which displays the contents of a filesystem received through the
+ * Guacamole client.
+ */
+angular.module('client').directive('guacFileBrowser', [function guacFileBrowser() {
+
+    return {
+        restrict: 'E',
+        replace: true,
+        scope: {
+
+            /**
+             * The client whose file transfers should be managed by this
+             * directive.
+             *
+             * @type ManagedClient
+             */
+            client : '=',
+
+            /**
+             * @type ManagedFilesystem
+             */
+            filesystem : '='
+
+        },
+
+        templateUrl: 'app/client/templates/guacFileBrowser.html',
+        controller: ['$scope', '$element', '$injector', function guacFileBrowserController($scope, $element, $injector) {
+
+            // Required types
+            var ManagedFilesystem = $injector.get('ManagedFilesystem');
+
+            // Required services
+            var $interpolate     = $injector.get('$interpolate');
+            var $templateRequest = $injector.get('$templateRequest');
+
+            /**
+             * The jQuery-wrapped element representing the contents of the
+             * current directory within the file browser.
+             *
+             * @type Element[]
+             */
+            var currentDirectoryContents = $element.find('.current-directory-contents');
+
+            /**
+             * Statically-cached template HTML used to render each file within
+             * a directory. Once available, this will be used through
+             * createFileElement() to generate the DOM elements which make up
+             * a directory listing.
+             *
+             * @type String
+             */
+            var fileTemplate = null;
+
+            /**
+             * Returns whether the given file is a normal file.
+             *
+             * @param {ManagedFilesystem.File} file
+             *     The file to test.
+             *
+             * @returns {Boolean}
+             *     true if the given file is a normal file, false otherwise.
+             */
+            $scope.isNormalFile = function isNormalFile(file) {
+                return file.type === ManagedFilesystem.File.Type.NORMAL;
+            };
+
+            /**
+             * Returns whether the given file is a directory.
+             *
+             * @param {ManagedFilesystem.File} file
+             *     The file to test.
+             *
+             * @returns {Boolean}
+             *     true if the given file is a directory, false otherwise.
+             */
+            $scope.isDirectory = function isDirectory(file) {
+                return file.type === ManagedFilesystem.File.Type.DIRECTORY;
+            };
+
+            /**
+             * Changes the currently-displayed directory to the given
+             * directory.
+             *
+             * @param {ManagedFilesystem.File} file
+             *     The directory to change to.
+             */
+            $scope.changeDirectory = function changeDirectory(file) {
+                ManagedFilesystem.changeDirectory($scope.filesystem, file);
+            };
+
+            /**
+             * Initiates a download of the given file. The progress of the
+             * download can be observed through guacFileTransferManager.
+             *
+             * @param {ManagedFilesystem.File} file
+             *     The file to download.
+             */
+            $scope.downloadFile = function downloadFile(file) {
+                ManagedFilesystem.downloadFile($scope.client, $scope.filesystem, file.streamName);
+            };
+
+            /**
+             * Recursively interpolates all text nodes within the DOM tree of
+             * the given element. All other node types, attributes, etc. will
+             * be left uninterpolated.
+             *
+             * @param {Element} element
+             *     The element at the root of the DOM tree to be interpolated.
+             *
+             * @param {Object} context
+             *     The evaluation context to use when evaluating expressions
+             *     embedded in text nodes within the provided element.
+             */
+            var interpolateElement = function interpolateElement(element, context) {
+
+                // Interpolate the contents of text nodes directly
+                if (element.nodeType === Node.TEXT_NODE)
+                    element.nodeValue = $interpolate(element.nodeValue)(context);
+
+                // Recursively interpolate the contents of all descendant text
+                // nodes
+                if (element.hasChildNodes()) {
+                    var children = element.childNodes;
+                    for (var i = 0; i < children.length; i++)
+                        interpolateElement(children[i], context);
+                }
+
+            };
+
+            /**
+             * Creates a new element representing the given file and properly
+             * handling user events, bypassing the overhead incurred through
+             * use of ngRepeat and related techniques.
+             *
+             * Note that this function depends on the availability of the
+             * statically-cached fileTemplate.
+             *
+             * @param {ManagedFilesystem.File} file
+             *     The file to generate an element for.
+             *
+             * @returns {Element[]}
+             *     A jQuery-wrapped array containing a single DOM element
+             *     representing the given file.
+             */
+            var createFileElement = function createFileElement(file) {
+
+                // Create from internal template
+                var element = angular.element(fileTemplate);
+                interpolateElement(element[0], file);
+
+                // Double-clicking on unknown file types will do nothing
+                var fileAction = function doNothing() {};
+
+                // Change current directory when directories are clicked
+                if ($scope.isDirectory(file)) {
+                    element.addClass('directory');
+                    fileAction = function changeDirectory() {
+                        $scope.changeDirectory(file);
+                    };
+                }
+
+                // Initiate downloads when normal files are clicked
+                else if ($scope.isNormalFile(file)) {
+                    element.addClass('normal-file');
+                    fileAction = function downloadFile() {
+                        $scope.downloadFile(file);
+                    };
+                }
+
+                // Mark file as focused upon click
+                element.on('click', function handleFileClick() {
+
+                    // Fire file-specific action if already focused
+                    if (element.hasClass('focused')) {
+                        fileAction();
+                        element.removeClass('focused');
+                    }
+
+                    // Otherwise mark as focused
+                    else {
+                        element.parent().children().removeClass('focused');
+                        element.addClass('focused');
+                    }
+
+                });
+
+                // Prevent text selection during navigation
+                element.on('selectstart', function avoidSelect(e) {
+                    e.preventDefault();
+                    e.stopPropagation();
+                });
+
+                return element;
+
+            };
+
+            /**
+             * Sorts the given map of files, returning an array of those files
+             * grouped by file type (directories first, followed by non-
+             * directories) and sorted lexicographically.
+             *
+             * @param {Object.<String, ManagedFilesystem.File>} files
+             *     The map of files to sort.
+             *
+             * @returns {ManagedFilesystem.File[]}
+             *     An array of all files in the given map, sorted
+             *     lexicographically with directories first, followed by non-
+             *     directories.
+             */
+            var sortFiles = function sortFiles(files) {
+
+                // Get all given files as an array
+                var unsortedFiles = [];
+                for (var name in files)
+                    unsortedFiles.push(files[name]);
+
+                // Sort files - directories first, followed by all other files
+                // sorted by name
+                return unsortedFiles.sort(function fileComparator(a, b) {
+
+                    // Directories come before non-directories
+                    if ($scope.isDirectory(a) && !$scope.isDirectory(b))
+                        return -1;
+
+                    // Non-directories come after directories
+                    if (!$scope.isDirectory(a) && $scope.isDirectory(b))
+                        return 1;
+
+                    // All other combinations are sorted by name
+                    return a.name.localeCompare(b.name);
+
+                });
+
+            };
+
+            // Watch directory contents once file template is available
+            $templateRequest('app/client/templates/file.html').then(function fileTemplateRetrieved(html) {
+
+                // Store file template statically
+                fileTemplate = html;
+
+                // Update the contents of the file browser whenever the current directory (or its contents) changes
+                $scope.$watch('filesystem.currentDirectory.files', function currentDirectoryChanged(files) {
+
+                    // Clear current content
+                    currentDirectoryContents.html('');
+
+                    // Display all files within current directory, sorted
+                    angular.forEach(sortFiles(files), function displayFile(file) {
+                        currentDirectoryContents.append(createFileElement(file));
+                    });
+
+                });
+
+            }); // end retrieve file template
+
+            // Refresh file browser when any upload completes
+            $scope.$on('guacUploadComplete', function uploadComplete(event, filename) {
+
+                // Refresh filesystem, if it exists
+                if ($scope.filesystem)
+                    ManagedFilesystem.refresh($scope.filesystem, $scope.filesystem.currentDirectory);
+
+            });
+
+        }]
+
+    };
+}]);
diff --git a/guacamole/src/main/webapp/app/client/directives/guacFileTransfer.js b/guacamole/src/main/webapp/app/client/directives/guacFileTransfer.js
new file mode 100644
index 0000000..0c23203
--- /dev/null
+++ b/guacamole/src/main/webapp/app/client/directives/guacFileTransfer.js
@@ -0,0 +1,237 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Directive which displays an active file transfer, providing links for
+ * downloads, if applicable.
+ */
+angular.module('client').directive('guacFileTransfer', [function guacFileTransfer() {
+
+    return {
+        restrict: 'E',
+        replace: true,
+        scope: {
+
+            /**
+             * The file transfer to display.
+             * 
+             * @type ManagedFileUpload|ManagedFileDownload
+             */
+            transfer : '='
+
+        },
+
+        templateUrl: 'app/client/templates/guacFileTransfer.html',
+        controller: ['$scope', '$injector', function guacFileTransferController($scope, $injector) {
+
+            // Required types
+            var ManagedFileTransferState = $injector.get('ManagedFileTransferState');
+
+            /**
+             * All upload error codes handled and passed off for translation.
+             * Any error code not present in this list will be represented by
+             * the "DEFAULT" translation.
+             */
+            var UPLOAD_ERRORS = {
+                0x0100: true,
+                0x0201: true,
+                0x0202: true,
+                0x0203: true,
+                0x0204: true,
+                0x0205: true,
+                0x0301: true,
+                0x0303: true,
+                0x0308: true,
+                0x031D: true
+            };
+
+            /**
+             * Returns the unit string that is most appropriate for the
+             * number of bytes transferred thus far - either 'gb', 'mb', 'kb',
+             * or 'b'.
+             *
+             * @returns {String}
+             *     The unit string that is most appropriate for the number of
+             *     bytes transferred thus far.
+             */
+            $scope.getProgressUnit = function getProgressUnit() {
+
+                var bytes = $scope.transfer.progress;
+
+                // Gigabytes
+                if (bytes > 1000000000)
+                    return 'gb';
+
+                // Megabytes
+                if (bytes > 1000000)
+                    return 'mb';
+
+                // Kilobytes
+                if (bytes > 1000)
+                    return 'kb';
+
+                // Bytes
+                return 'b';
+
+            };
+
+            /**
+             * Returns the amount of data transferred thus far, in the units
+             * returned by getProgressUnit().
+             *
+             * @returns {Number}
+             *     The amount of data transferred thus far, in the units
+             *     returned by getProgressUnit().
+             */
+            $scope.getProgressValue = function getProgressValue() {
+
+                var bytes = $scope.transfer.progress;
+                if (!bytes)
+                    return bytes;
+
+                // Convert bytes to necessary units
+                switch ($scope.getProgressUnit()) {
+
+                    // Gigabytes
+                    case 'gb':
+                        return (bytes / 1000000000).toFixed(1);
+
+                    // Megabytes
+                    case 'mb':
+                        return (bytes / 1000000).toFixed(1);
+
+                    // Kilobytes
+                    case 'kb':
+                        return (bytes / 1000).toFixed(1);
+
+                    // Bytes
+                    case 'b':
+                    default:
+                        return bytes;
+
+                }
+
+            };
+
+            /**
+             * Returns the percentage of bytes transferred thus far, if the
+             * overall length of the file is known.
+             *
+             * @returns {Number}
+             *     The percentage of bytes transferred thus far, if the
+             *     overall length of the file is known.
+             */
+            $scope.getPercentDone = function getPercentDone() {
+                return $scope.transfer.progress / $scope.transfer.length * 100;
+            };
+
+            /**
+             * Determines whether the associated file transfer is in progress.
+             *
+             * @returns {Boolean}
+             *     true if the file transfer is in progress, false othherwise.
+             */
+            $scope.isInProgress = function isInProgress() {
+
+                // Not in progress if there is no transfer
+                if (!$scope.transfer)
+                    return false;
+
+                // Determine in-progress status based on stream state
+                switch ($scope.transfer.transferState.streamState) {
+
+                    // IDLE or OPEN file transfers are active
+                    case ManagedFileTransferState.StreamState.IDLE:
+                    case ManagedFileTransferState.StreamState.OPEN:
+                        return true;
+
+                    // All others are not active
+                    default:
+                        return false;
+
+                }
+
+            };
+
+            /**
+             * Returns whether the file associated with this file transfer can
+             * be saved locally via a call to save().
+             *
+             * @returns {Boolean}
+             *     true if a call to save() will result in the file being
+             *     saved, false otherwise.
+             */
+            $scope.isSavable = function isSavable() {
+                return !!$scope.transfer.blob;
+            };
+
+            /**
+             * Saves the downloaded file, if any. If this transfer is an upload
+             * or the download is not yet complete, this function has no
+             * effect.
+             */
+            $scope.save = function save() {
+
+                // Ignore if no blob exists
+                if (!$scope.transfer.blob)
+                    return;
+
+                // Save file
+                saveAs($scope.transfer.blob, $scope.transfer.filename); 
+
+            };
+
+            /**
+             * Returns whether an error has occurred. If an error has occurred,
+             * the transfer is no longer active, and the text of the error can
+             * be read from getErrorText().
+             *
+             * @returns {Boolean}
+             *     true if an error has occurred during transfer, false
+             *     otherwise.
+             */
+            $scope.hasError = function hasError() {
+                return $scope.transfer.transferState.streamState === ManagedFileTransferState.StreamState.ERROR;
+            };
+
+            /**
+             * Returns the text of the current error as a translation string.
+             *
+             * @returns {String}
+             *     The name of the translation string containing the text
+             *     associated with the current error.
+             */
+            $scope.getErrorText = function getErrorText() {
+
+                // Determine translation name of error
+                var status = $scope.transfer.transferState.statusCode;
+                var errorName = (status in UPLOAD_ERRORS) ? status.toString(16).toUpperCase() : "DEFAULT";
+
+                // Return translation string
+                return 'CLIENT.ERROR_UPLOAD_' + errorName;
+
+            };
+
+        }] // end file transfer controller
+
+    };
+}]);
diff --git a/guacamole/src/main/webapp/app/client/directives/guacFileTransferManager.js b/guacamole/src/main/webapp/app/client/directives/guacFileTransferManager.js
new file mode 100644
index 0000000..48cfaf0
--- /dev/null
+++ b/guacamole/src/main/webapp/app/client/directives/guacFileTransferManager.js
@@ -0,0 +1,99 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Directive which displays all active file transfers.
+ */
+angular.module('client').directive('guacFileTransferManager', [function guacFileTransferManager() {
+
+    return {
+        restrict: 'E',
+        replace: true,
+        scope: {
+
+            /**
+             * The client whose file transfers should be managed by this
+             * directive.
+             * 
+             * @type ManagerClient
+             */
+            client : '='
+
+        },
+
+        templateUrl: 'app/client/templates/guacFileTransferManager.html',
+        controller: ['$scope', '$injector', function guacFileTransferManagerController($scope, $injector) {
+
+            // Required types
+            var ManagedFileTransferState = $injector.get('ManagedFileTransferState');
+
+            /**
+             * Determines whether the given file transfer state indicates an
+             * in-progress transfer.
+             *
+             * @param {ManagedFileTransferState} transferState
+             *     The file transfer state to check.
+             *
+             * @returns {Boolean}
+             *     true if the given file transfer state indicates an in-
+             *     progress transfer, false otherwise.
+             */
+            var isInProgress = function isInProgress(transferState) {
+                switch (transferState.streamState) {
+
+                    // IDLE or OPEN file transfers are active
+                    case ManagedFileTransferState.StreamState.IDLE:
+                    case ManagedFileTransferState.StreamState.OPEN:
+                        return true;
+
+                    // All others are not active
+                    default:
+                        return false;
+
+                }
+            };
+
+            /**
+             * Removes all file transfers which are not currently in-progress.
+             */
+            $scope.clearCompletedTransfers = function clearCompletedTransfers() {
+
+                // Nothing to clear if no client attached
+                if (!$scope.client)
+                    return;
+
+                // Remove completed uploads
+                $scope.client.uploads = $scope.client.uploads.filter(function isUploadInProgress(upload) {
+                    return isInProgress(upload.transferState);
+                });
+
+                // Remove completed downloads
+                $scope.client.downloads = $scope.client.downloads.filter(function isDownloadInProgress(download) {
+                    return isInProgress(download.transferState);
+                });
+
+            };
+
+        }]
+
+    };
+}]);
diff --git a/guacamole/src/main/webapp/app/client/directives/guacThumbnail.js b/guacamole/src/main/webapp/app/client/directives/guacThumbnail.js
new file mode 100644
index 0000000..fc1e965
--- /dev/null
+++ b/guacamole/src/main/webapp/app/client/directives/guacThumbnail.js
@@ -0,0 +1,162 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * A directive for displaying a Guacamole client as a non-interactive
+ * thumbnail.
+ */
+angular.module('client').directive('guacThumbnail', [function guacThumbnail() {
+
+    return {
+        // Element only
+        restrict: 'E',
+        replace: true,
+        scope: {
+
+            /**
+             * The client to display within this guacThumbnail directive.
+             * 
+             * @type ManagedClient
+             */
+            client : '='
+            
+        },
+        templateUrl: 'app/client/templates/guacThumbnail.html',
+        controller: ['$scope', '$injector', '$element', function guacThumbnailController($scope, $injector, $element) {
+   
+            // Required services
+            var $window = $injector.get('$window');
+
+            /**
+             * The optimal thumbnail width, in pixels.
+             *
+             * @type Number
+             */
+            var THUMBNAIL_WIDTH = 320;
+
+            /**
+             * The optimal thumbnail height, in pixels.
+             *
+             * @type Number
+             */
+            var THUMBNAIL_HEIGHT = 240;
+                
+            /**
+             * The display of the current Guacamole client instance.
+             * 
+             * @type Guacamole.Display
+             */
+            var display = null;
+
+            /**
+             * The element associated with the display of the current
+             * Guacamole client instance.
+             *
+             * @type Element
+             */
+            var displayElement = null;
+
+            /**
+             * The element which must contain the Guacamole display element.
+             *
+             * @type Element
+             */
+            var displayContainer = $element.find('.display')[0];
+
+            /**
+             * The main containing element for the entire directive.
+             * 
+             * @type Element
+             */
+            var main = $element[0];
+
+            /**
+             * Updates the scale of the attached Guacamole.Client based on current window
+             * size and "auto-fit" setting.
+             */
+            $scope.updateDisplayScale = function updateDisplayScale() {
+
+                if (!display) return;
+
+                // Fit within available area
+                display.scale(Math.min(
+                    main.offsetWidth  / Math.max(display.getWidth(),  1),
+                    main.offsetHeight / Math.max(display.getHeight(), 1)
+                ));
+
+            };
+
+            // Attach any given managed client
+            $scope.$watch('client', function attachManagedClient(managedClient) {
+
+                // Remove any existing display
+                displayContainer.innerHTML = "";
+
+                // Only proceed if a client is given 
+                if (!managedClient)
+                    return;
+
+                // Get Guacamole client instance
+                var client = managedClient.client;
+
+                // Attach possibly new display
+                display = client.getDisplay();
+
+                // Add display element
+                displayElement = display.getElement();
+                displayContainer.appendChild(displayElement);
+
+            });
+
+            // Update scale when display is resized
+            $scope.$watch('client.managedDisplay.size', function setDisplaySize(size) {
+
+                var width;
+                var height;
+
+                // If no display size yet, assume optimal thumbnail size
+                if (!size || size.width === 0 || size.height === 0) {
+                    width  = THUMBNAIL_WIDTH;
+                    height = THUMBNAIL_HEIGHT;
+                }
+
+                // Otherwise, generate size that fits within thumbnail bounds
+                else {
+                    var scale = Math.min(THUMBNAIL_WIDTH / size.width, THUMBNAIL_HEIGHT / size.height, 1);
+                    width  = size.width  * scale;
+                    height = size.height * scale;
+                }
+                
+                // Generate dummy background image
+                var thumbnail = document.createElement("canvas");
+                thumbnail.width  = width;
+                thumbnail.height = height;
+                $scope.thumbnail = thumbnail.toDataURL("image/png");
+
+                // Init display scale
+                $scope.$evalAsync($scope.updateDisplayScale);
+
+            });
+
+        }]
+    };
+}]);
\ No newline at end of file
diff --git a/guacamole/src/main/webapp/app/client/directives/guacViewport.js b/guacamole/src/main/webapp/app/client/directives/guacViewport.js
new file mode 100644
index 0000000..a95bbee
--- /dev/null
+++ b/guacamole/src/main/webapp/app/client/directives/guacViewport.js
@@ -0,0 +1,111 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * A directive which provides a fullscreen environment for its content.
+ */
+angular.module('client').directive('guacViewport', [function guacViewport() {
+
+    return {
+        // Element only
+        restrict: 'E',
+        scope: {},
+        transclude: true,
+        templateUrl: 'app/client/templates/guacViewport.html',
+        controller: ['$scope', '$injector', '$element',
+            function guacViewportController($scope, $injector, $element) {
+
+            // Required services
+            var $window   = $injector.get('$window');
+            var $document = $injector.get('$document');
+
+            /**
+             * The fullscreen container element.
+             *
+             * @type Element
+             */
+            var element = $element.find('.viewport')[0];
+
+            /**
+             * The main document object.
+             *
+             * @type Document
+             */
+            var document = $document[0];
+
+            /**
+             * The current adjusted height of the viewport element, if any.
+             *
+             * @type Number
+             */
+            var currentAdjustedHeight = null;
+
+            /**
+             * Resizes the container element inside the guacViewport such that
+             * it exactly fits within the visible area, even if the browser has
+             * been scrolled.
+             */
+            var fitVisibleArea = function fitVisibleArea() {
+
+                // Pull scroll properties
+                var scrollLeft   = document.body.scrollLeft;
+                var scrollTop    = document.body.scrollTop;
+                var scrollWidth  = document.body.scrollWidth;
+                var scrollHeight = document.body.scrollHeight;
+
+                // Calculate new height
+                var adjustedHeight = scrollHeight - scrollTop;
+
+                // Only update if not in response to our own call to scrollTo()
+                if (scrollLeft !== scrollWidth && scrollTop !== scrollHeight
+                        && currentAdjustedHeight !== adjustedHeight) {
+
+                    // Adjust element to fit exactly within visible area
+                    element.style.height = adjustedHeight + 'px';
+                    currentAdjustedHeight = adjustedHeight;
+
+                    // Scroll to bottom
+                    $window.scrollTo(scrollWidth, scrollHeight);
+
+                }
+
+                // Manually attempt scroll if height has not been adjusted
+                else if (adjustedHeight === 0)
+                    $window.scrollTo(scrollWidth, scrollHeight);
+
+            };
+
+            // Fit container within visible region when window scrolls
+            $window.addEventListener('scroll', fitVisibleArea);
+
+            // Poll every 10ms, in case scroll event does not fire
+            var pollArea = $window.setInterval(fitVisibleArea, 10);
+
+            // Clean up on destruction
+            $scope.$on('$destroy', function destroyViewport() {
+                $window.removeEventListener('scroll', fitVisibleArea);
+                $window.clearInterval(pollArea);
+            });
+
+        }]
+    };
+}]);
diff --git a/guacamole/src/main/webapp/app/client/services/guacAudio.js b/guacamole/src/main/webapp/app/client/services/guacAudio.js
new file mode 100644
index 0000000..bacc628
--- /dev/null
+++ b/guacamole/src/main/webapp/app/client/services/guacAudio.js
@@ -0,0 +1,42 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * A service for checking browser audio support.
+ */
+angular.module('client').factory('guacAudio', [function guacAudio() {
+            
+    /**
+     * Object describing the UI's level of audio support.
+     */
+    return new (function() {
+
+        /**
+         * Array of all supported audio mimetypes.
+         *
+         * @type String[]
+         */
+        this.supported = Guacamole.AudioPlayer.getSupportedTypes();
+
+    })();
+
+}]);
diff --git a/guacamole/src/main/webapp/app/client/services/guacClientManager.js b/guacamole/src/main/webapp/app/client/services/guacClientManager.js
new file mode 100644
index 0000000..3d74bc2
--- /dev/null
+++ b/guacamole/src/main/webapp/app/client/services/guacClientManager.js
@@ -0,0 +1,172 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * A service for managing several active Guacamole clients.
+ */
+angular.module('client').factory('guacClientManager', ['$injector',
+        function guacClientManager($injector) {
+
+    // Required types
+    var ManagedClient = $injector.get('ManagedClient');
+
+    // Required services
+    var $window               = $injector.get('$window');
+    var sessionStorageFactory = $injector.get('sessionStorageFactory');
+
+    var service = {};
+
+    /**
+     * Getter/setter which retrieves or sets the map of all active managed
+     * clients. Each key is the ID of the connection used by that client.
+     *
+     * @type Function
+     */
+    var storedManagedClients = sessionStorageFactory.create({}, function destroyClientStorage() {
+
+        // Disconnect all clients when storage is destroyed
+        service.clear();
+
+    });
+
+    /**
+     * Returns a map of all active managed clients. Each key is the ID of the
+     * connection used by that client.
+     *
+     * @returns {Object.<String, ManagedClient>}
+     *     A map of all active managed clients.
+     */
+    service.getManagedClients = function getManagedClients() {
+        return storedManagedClients();
+    };
+
+    /**
+     * Removes the existing ManagedClient associated with the connection having
+     * the given ID, if any. If no such a ManagedClient already exists, this
+     * function has no effect.
+     *
+     * @param {String} id
+     *     The ID of the connection whose ManagedClient should be removed.
+     * 
+     * @returns {Boolean}
+     *     true if an existing client was removed, false otherwise.
+     */
+    service.removeManagedClient = function replaceManagedClient(id) {
+
+        var managedClients = storedManagedClients();
+
+        // Remove client if it exists
+        if (id in managedClients) {
+
+            // Disconnect and remove
+            managedClients[id].client.disconnect();
+            delete managedClients[id];
+
+            // A client was removed
+            return true;
+
+        }
+
+        // No client was removed
+        return false;
+
+    };
+
+    /**
+     * Creates a new ManagedClient associated with the connection having the
+     * given ID. If such a ManagedClient already exists, it is disconnected and
+     * replaced.
+     *
+     * @param {String} id
+     *     The ID of the connection whose ManagedClient should be retrieved.
+     *     
+     * @param {String} [connectionParameters]
+     *     Any additional HTTP parameters to pass while connecting. This
+     *     parameter only has an effect if a new connection is established as
+     *     a result of this function call.
+     * 
+     * @returns {ManagedClient}
+     *     The ManagedClient associated with the connection having the given
+     *     ID.
+     */
+    service.replaceManagedClient = function replaceManagedClient(id, connectionParameters) {
+
+        // Disconnect any existing client
+        service.removeManagedClient(id);
+
+        // Set new client
+        return storedManagedClients()[id] = ManagedClient.getInstance(id, connectionParameters);
+
+    };
+
+    /**
+     * Returns the ManagedClient associated with the connection having the
+     * given ID. If no such ManagedClient exists, a new ManagedClient is
+     * created.
+     *
+     * @param {String} id
+     *     The ID of the connection whose ManagedClient should be retrieved.
+     *     
+     * @param {String} [connectionParameters]
+     *     Any additional HTTP parameters to pass while connecting. This
+     *     parameter only has an effect if a new connection is established as
+     *     a result of this function call.
+     * 
+     * @returns {ManagedClient}
+     *     The ManagedClient associated with the connection having the given
+     *     ID.
+     */
+    service.getManagedClient = function getManagedClient(id, connectionParameters) {
+
+        var managedClients = storedManagedClients();
+
+        // Create new managed client if it doesn't already exist
+        if (!(id in managedClients))
+            managedClients[id] = ManagedClient.getInstance(id, connectionParameters);
+
+        // Return existing client
+        return managedClients[id];
+
+    };
+
+    /**
+     * Disconnects and removes all currently-connected clients.
+     */
+    service.clear = function clear() {
+
+        var managedClients = storedManagedClients();
+
+        // Disconnect each managed client
+        for (var id in managedClients)
+            managedClients[id].client.disconnect();
+
+        // Clear managed clients
+        storedManagedClients({});
+
+    };
+
+    // Disconnect all clients when window is unloaded
+    $window.addEventListener('unload', service.clear);
+
+    return service;
+
+}]);
diff --git a/guacamole/src/main/webapp/app/client/services/guacImage.js b/guacamole/src/main/webapp/app/client/services/guacImage.js
new file mode 100644
index 0000000..2812653
--- /dev/null
+++ b/guacamole/src/main/webapp/app/client/services/guacImage.js
@@ -0,0 +1,138 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * A service for checking browser image support.
+ */
+angular.module('client').factory('guacImage', ['$injector', function guacImage($injector) {
+
+    // Required services
+    var $q = $injector.get('$q');
+
+    var service = {};
+
+    /**
+     * Map of possibly-supported image mimetypes to corresponding test images
+     * encoded with base64. If the image is correctly decoded, it will be a
+     * single pixel (1x1) image.
+     *
+     * @type Object.<String, String>
+     */
+    var testImages = {
+
+        /**
+         * Test JPEG image, encoded as base64.
+         */
+        'image/jpeg' :
+            '/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoH'
+          + 'BwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRT/2wBDAQME'
+          + 'BAUEBQkFBQkUDQsNFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQU'
+          + 'FBQUFBQUFBQUFBQUFBT/wAARCAABAAEDAREAAhEBAxEB/8QAFAABAAAAAAAAAAA'
+          + 'AAAAAAAAACf/EABQQAQAAAAAAAAAAAAAAAAAAAAD/xAAUAQEAAAAAAAAAAAAAAA'
+          + 'AAAAAA/8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAwDAQACEQMRAD8AVMH/2Q==',
+
+        /**
+         * Test PNG image, encoded as base64.
+         */
+        'image/png' :
+            'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEX///+nxBvI'
+          + 'AAAACklEQVQI12NgAAAAAgAB4iG8MwAAAABJRU5ErkJggg==',
+
+        /**
+         * Test WebP image, encoded as base64.
+         */
+        'image/webp' : 'UklGRhoAAABXRUJQVlA4TA0AAAAvAAAAEAcQERGIiP4HAA=='
+
+    };
+
+    /**
+     * Deferred which tracks the progress and ultimate result of all pending
+     * image format tests.
+     *
+     * @type Deferred
+     */
+    var deferredSupportedMimetypes = $q.defer();
+
+    /**
+     * Array of all promises associated with pending image tests. Each image
+     * test promise MUST be guaranteed to resolve and MUST NOT be rejected.
+     *
+     * @type Promise[]
+     */
+    var pendingTests = [];
+
+    /**
+     * The array of supported image formats. This will be gradually populated
+     * by the various image tests that occur in the background, and will not be
+     * fully populated until all promises within pendingTests are resolved.
+     *
+     * @type String[]
+     */
+    var supported = [];
+
+    /**
+     * Return a promise which resolves with to an array of image mimetypes
+     * supported by the browser, once those mimetypes are known. The returned
+     * promise is guaranteed to resolve successfully.
+     *
+     * @returns {Promise.<String[]>}
+     *     A promise which resolves with an array of image mimetypes supported
+     *     by the browser.
+     */
+    service.getSupportedMimetypes = function getSupportedMimetypes() {
+        return deferredSupportedMimetypes.promise;
+    };
+
+    // Test each possibly-supported image
+    angular.forEach(testImages, function testImageSupport(data, mimetype) {
+
+        // Add promise for current image test
+        var imageTest = $q.defer();
+        pendingTests.push(imageTest.promise);
+
+        // Attempt to load image
+        var image = new Image();
+        image.src = 'data:' + mimetype + ';base64,' + data;
+
+        // Store as supported depending on whether load was successful
+        image.onload = image.onerror = function imageTestComplete() {
+
+            // Image format is supported if successfully decoded
+            if (image.width === 1 && image.height === 1)
+                supported.push(mimetype);
+
+            // Test is complete
+            imageTest.resolve();
+
+        };
+
+    });
+
+    // When all image tests are complete, resolve promise with list of
+    // supported formats
+    $q.all(pendingTests).then(function imageTestsCompleted() {
+        deferredSupportedMimetypes.resolve(supported);
+    });
+
+    return service;
+
+}]);
diff --git a/guacamole/src/main/webapp/app/client/services/guacVideo.js b/guacamole/src/main/webapp/app/client/services/guacVideo.js
new file mode 100644
index 0000000..26f0770
--- /dev/null
+++ b/guacamole/src/main/webapp/app/client/services/guacVideo.js
@@ -0,0 +1,40 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * A service for checking browser video support.
+ */
+angular.module('client').factory('guacVideo', [function guacVideo() {
+           
+    /**
+     * Object describing the UI's level of video support.
+     */
+    return new (function() {
+
+        /**
+         * Array of all supported video mimetypes.
+         */
+        this.supported = Guacamole.VideoPlayer.getSupportedTypes();
+
+    })();
+
+}]);
diff --git a/guacamole/src/main/webapp/app/client/styles/client.css b/guacamole/src/main/webapp/app/client/styles/client.css
new file mode 100644
index 0000000..8dde10d
--- /dev/null
+++ b/guacamole/src/main/webapp/app/client/styles/client.css
@@ -0,0 +1,128 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+body.client {
+    background: black;
+    padding: 0;
+    margin: 0;
+    overflow: hidden;
+}
+
+#preload {
+    visibility: hidden;
+    position: absolute;
+    left: 0;
+    right: 0;
+    width: 0;
+    height: 0;
+    overflow: hidden;
+}
+
+.client-view {
+
+    position: absolute;
+    top: 0;
+    left: 0;
+
+    width: 100%;
+    height: 100%;
+
+    font-size: 0px;
+
+}
+
+.client-view-content {
+
+    /* IE10 */
+    display: -ms-flexbox;
+    -ms-flex-align: stretch;
+    -ms-flex-direction: column;
+    -ms-flex-pack: end;
+
+    /* Ancient Mozilla */
+    display: -moz-box;
+    -moz-box-align: stretch;
+    -moz-box-orient: vertical;
+    -moz-box-pack: end;
+    
+    /* Ancient WebKit */
+    display: -webkit-box;
+    -webkit-box-align: stretch;
+    -webkit-box-orient: vertical;
+    -webkit-box-pack: end;
+
+    /* Old WebKit */
+    display: -webkit-flex;
+    -webkit-align-items: stretch;
+    -webkit-flex-direction: column;
+    -webkit-flex-pack: end;
+
+    /* W3C */
+    display: flex;
+    align-items: stretch;
+    flex-direction: column;
+    flex-pack: end;
+
+    width: 100%;
+    height: 100%;
+
+    font-size: 12pt;
+
+}
+
+.client-view .client-body {
+    -ms-flex: 1 1 auto;
+    -moz-box-flex: 1;
+    -webkit-box-flex: 1;
+    -webkit-flex: 1 1 auto;
+    flex: 1 1 auto;
+    position: relative;
+}
+
+.client-view .client-bottom {
+    -ms-flex: 0 0 auto;
+    -moz-box-flex: 0;
+    -webkit-box-flex: 0;
+    -webkit-flex: 0 0 auto;
+    flex: 0 0 auto;
+}
+
+.client-view .client-body .main {
+
+    position: absolute;
+    left: 0;
+    top: 0;
+    right: 0;
+    bottom: 0;
+
+    width: auto;
+    height: auto;
+
+}
+
+.client .user-menu .options li a.disconnect {
+    background-repeat: no-repeat;
+    background-size: 1em;
+    background-position: 0.75em center;
+    padding-left: 2.5em;
+    background-image: url('images/x.png');
+}
diff --git a/guacamole/src/main/webapp/app/client/styles/display.css b/guacamole/src/main/webapp/app/client/styles/display.css
new file mode 100644
index 0000000..04b8dc3
--- /dev/null
+++ b/guacamole/src/main/webapp/app/client/styles/display.css
@@ -0,0 +1,68 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+.software-cursor {
+    cursor: url('images/mouse/blank.gif'),url('images/mouse/blank.cur'),default;
+    overflow: hidden;
+    cursor: none;
+}
+
+.guac-error .software-cursor {
+    cursor: default;
+}
+
+div.main {
+    overflow: auto;
+    width: 100%;
+    height: 100%;
+    position: relative;
+    font-size: 0px;
+}
+
+div.displayOuter {
+    height: 100%;
+    width: 100%;
+    position: absolute;
+    left: 0;
+    top: 0;
+    display: table;
+}
+
+div.displayMiddle {
+    width: 100%;
+    display: table-cell;
+    vertical-align: middle;
+    text-align: center;
+}
+
+div.display {
+    display: inline-block;
+}
+
+div.display * {
+    position: relative;
+}
+
+div.display > * {
+    margin-left: auto;
+    margin-right: auto;
+}
diff --git a/guacamole/src/main/webapp/app/client/styles/file-browser.css b/guacamole/src/main/webapp/app/client/styles/file-browser.css
new file mode 100644
index 0000000..602f406
--- /dev/null
+++ b/guacamole/src/main/webapp/app/client/styles/file-browser.css
@@ -0,0 +1,52 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/* Hide directory contents by default */
+
+.file-browser .directory > .children {
+    padding-left: 1em;
+    display: none;
+}
+
+.file-browser .list-item .caption {
+    white-space: nowrap;
+    border: 1px solid transparent;
+}
+
+.file-browser .list-item.focused .caption {
+    border: 1px dotted rgba(0, 0, 0, 0.5);
+    background: rgba(204, 221, 170, 0.5);
+}
+
+/* Directory / file icons */
+
+.file-browser .normal-file > .caption .icon {
+    background-image: url('images/file.png');
+}
+
+.file-browser .directory > .caption .icon {
+    background-image: url('images/folder-closed.png');
+}
+
+.file-browser .directory.previous > .caption .icon {
+    background-image: url('images/folder-up.png');
+}
diff --git a/guacamole/src/main/webapp/app/client/styles/file-transfer-dialog.css b/guacamole/src/main/webapp/app/client/styles/file-transfer-dialog.css
new file mode 100644
index 0000000..666ca7f
--- /dev/null
+++ b/guacamole/src/main/webapp/app/client/styles/file-transfer-dialog.css
@@ -0,0 +1,121 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+#file-transfer-dialog {
+
+    position: absolute;
+    right: 0;
+    bottom: 0;
+    z-index: 20;
+
+    font-size: 0.8em;
+
+    width: 4in;
+    max-width: 100%;
+    max-height: 3in;
+
+}
+
+#file-transfer-dialog .transfer-manager {
+
+    /* IE10 */
+    display: -ms-flexbox;
+    -ms-flex-align: stretch;
+    -ms-flex-direction: column;
+
+    /* Ancient Mozilla */
+    display: -moz-box;
+    -moz-box-align: stretch;
+    -moz-box-orient: vertical;
+
+    /* Ancient WebKit */
+    display: -webkit-box;
+    -webkit-box-align: stretch;
+    -webkit-box-orient: vertical;
+
+    /* Old WebKit */
+    display: -webkit-flex;
+    -webkit-align-items: stretch;
+    -webkit-flex-direction: column;
+
+    /* W3C */
+    display: flex;
+    align-items: stretch;
+    flex-direction: column;
+
+    max-width: inherit;
+    max-height: inherit;
+
+    border: 1px solid rgba(0, 0, 0, 0.5);
+    box-shadow: 1px 1px 2px rgba(0, 0, 0, 0.25);
+
+}
+
+#file-transfer-dialog .transfer-manager .header {
+    -ms-flex: 0 0 auto;
+    -moz-box-flex: 0;
+    -webkit-box-flex: 0;
+    -webkit-flex: 0 0 auto;
+    flex: 0 0 auto;
+}
+
+#file-transfer-dialog .transfer-manager .transfer-manager-body {
+
+    -ms-flex: 1 1 auto;
+    -moz-box-flex: 1;
+    -webkit-box-flex: 1;
+    -webkit-flex: 1 1 auto;
+    flex: 1 1 auto;
+
+    overflow: auto;
+
+}
+
+/*
+ * Shrink maximum height if viewport is too small for default 3in dialog.
+ */
+ at media all and (max-height: 3in) {
+
+    #file-transfer-dialog {
+        max-height: 1.5in;
+    }
+
+}
+
+/*
+ * If viewport is too small for even the 1.5in dialog, fit all available space.
+ */
+ at media all and (max-height: 1.5in) {
+
+    #file-transfer-dialog {
+        height: 100%;
+    }
+
+    #file-transfer-dialog .transfer-manager {
+        position: absolute;
+        left:   0.5em;
+        top:    0.5em;
+        right:  0.5em;
+        bottom: 0.5em;
+    }
+
+}
diff --git a/guacamole/src/main/webapp/app/client/styles/filesystem-menu.css b/guacamole/src/main/webapp/app/client/styles/filesystem-menu.css
new file mode 100644
index 0000000..1f51b88
--- /dev/null
+++ b/guacamole/src/main/webapp/app/client/styles/filesystem-menu.css
@@ -0,0 +1,75 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+#filesystem-menu .header h2 {
+    font-size: 1em;
+    font-weight: normal;
+    padding-top: 0;
+    padding-bottom: 0;
+}
+
+#filesystem-menu .header {
+    -ms-flex-align:      center;
+    -moz-box-align:      center;
+    -webkit-box-align:   center;
+    -webkit-align-items: center;
+    align-items:         center;
+}
+
+#filesystem-menu .menu-body {
+    padding: 0.25em;
+}
+
+#filesystem-menu .header.breadcrumbs {
+    display: block;
+    background: rgba(0,0,0,0.0125);
+    border-bottom: 1px solid rgba(0,0,0,0.05);
+    box-shadow: none;
+    margin-top: 0;
+    border-top: none;
+}
+
+#filesystem-menu .header.breadcrumbs .breadcrumb {
+    display: inline-block;
+    padding: 0.5em;
+    font-size: 0.8em;
+    font-weight: bold;
+}
+
+#filesystem-menu .header.breadcrumbs .breadcrumb:hover {
+    background-color: #CDA;
+    cursor: pointer;
+}
+
+#filesystem-menu .header.breadcrumbs .breadcrumb.root {
+    background-size:         1.5em 1.5em;
+    -moz-background-size:    1.5em 1.5em;
+    -webkit-background-size: 1.5em 1.5em;
+    -khtml-background-size:  1.5em 1.5em;
+    background-repeat: no-repeat;
+    background-position: center center;
+    background-image: url('images/drive.png');
+    width: 2em;
+    height: 2em;
+    padding: 0;
+    vertical-align: middle;
+}
\ No newline at end of file
diff --git a/guacamole/src/main/webapp/app/client/styles/guac-menu.css b/guacamole/src/main/webapp/app/client/styles/guac-menu.css
new file mode 100644
index 0000000..11df283
--- /dev/null
+++ b/guacamole/src/main/webapp/app/client/styles/guac-menu.css
@@ -0,0 +1,188 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+#guac-menu .content {
+
+    padding: 0;
+    margin: 0;
+
+    /* IE10 */
+    display: -ms-flexbox;
+    -ms-flex-align: stretch;
+    -ms-flex-direction: column;
+
+    /* Ancient Mozilla */
+    display: -moz-box;
+    -moz-box-align: stretch;
+    -moz-box-orient: vertical;
+
+    /* Ancient WebKit */
+    display: -webkit-box;
+    -webkit-box-align: stretch;
+    -webkit-box-orient: vertical;
+
+    /* Old WebKit */
+    display: -webkit-flex;
+    -webkit-align-items: stretch;
+    -webkit-flex-direction: column;
+
+    /* W3C */
+    display: flex;
+    align-items: stretch;
+    flex-direction: column;
+
+}
+
+#guac-menu .content > * {
+
+    margin: 0;
+
+    -ms-flex: 0 0 auto;
+    -moz-box-flex: 0;
+    -webkit-box-flex: 0;
+    -webkit-flex: 0 0 auto;
+    flex: 0 0 auto;
+
+}
+
+#guac-menu .content > * + * {
+    margin-top: 1em;
+}
+
+#guac-menu #clipboard-settings textarea {
+    width: 100%;
+    border: 1px solid #AAA;
+    -moz-border-radius: 0.25em;
+    -webkit-border-radius: 0.25em;
+    -khtml-border-radius: 0.25em;
+    border-radius: 0.25em;
+    white-space: pre;
+    display: block;
+    font-size: 1em;
+}
+
+#guac-menu #mouse-settings .choice {
+    text-align: center;
+}
+
+#guac-menu #mouse-settings .choice .figure {
+    display: inline-block;
+    vertical-align: middle;
+    width: 75%;
+    max-width: 320px;
+}
+
+#guac-menu #keyboard-settings .caption {
+    font-size: 0.9em;
+    margin-left: 2em;
+    margin-right: 2em;
+}
+
+#guac-menu #mouse-settings .figure .caption {
+    text-align: center;
+    font-size: 0.9em;
+}
+
+#guac-menu #mouse-settings .figure img {
+    display: block;
+    width: 100%;
+    max-width: 320px;
+    margin: 1em auto;
+}
+
+#guac-menu #keyboard-settings .figure {
+    float: right;
+    max-width: 30%;
+    margin: 1em;
+}
+
+#guac-menu #keyboard-settings .figure img {
+    max-width: 100%;
+}
+
+#guac-menu #zoom-settings {
+    text-align: center;
+}
+
+#guac-menu #zoom-out,
+#guac-menu #zoom-in,
+#guac-menu #zoom-state {
+    display: inline-block;
+    vertical-align: middle;
+}
+
+#guac-menu #zoom-out,
+#guac-menu #zoom-in {
+    max-width: 3em;
+    border: 1px solid rgba(0, 0, 0, 0.5);
+    background: rgba(0, 0, 0, 0.1);
+    border-radius: 2em;
+    margin: 0.5em;
+    cursor: pointer;
+}
+
+#guac-menu #zoom-out img,
+#guac-menu #zoom-in img {
+    max-width: 100%;
+    opacity: 0.5;
+}
+
+#guac-menu #zoom-out:hover,
+#guac-menu #zoom-in:hover {
+    border: 1px solid rgba(0, 0, 0, 1);
+    background: #CDA;
+}
+
+#guac-menu #zoom-out:hover img,
+#guac-menu #zoom-in:hover img {
+    opacity: 1;
+}
+
+#guac-menu #zoom-state {
+    font-size: 2em;
+}
+
+#guac-menu #devices .device {
+
+    padding: 1em;
+    border: 1px solid rgba(0, 0, 0, 0.125);
+    background: rgba(0, 0, 0, 0.04);
+
+    padding-left: 3.5em;
+    background-size:         1.5em 1.5em;
+    -moz-background-size:    1.5em 1.5em;
+    -webkit-background-size: 1.5em 1.5em;
+    -khtml-background-size:  1.5em 1.5em;
+
+    background-repeat: no-repeat;
+    background-position: 1em center;
+
+}
+
+#guac-menu #devices .device:hover {
+    cursor: pointer;
+    border-color: black;
+}
+
+#guac-menu #devices .device.filesystem {
+    background-image: url('images/drive.png');
+}
diff --git a/guacamole/src/main/webapp/app/client/styles/keyboard.css b/guacamole/src/main/webapp/app/client/styles/keyboard.css
new file mode 100644
index 0000000..665301f
--- /dev/null
+++ b/guacamole/src/main/webapp/app/client/styles/keyboard.css
@@ -0,0 +1,35 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+.keyboard-container {
+    text-align: center;
+
+    width: 100%;
+    margin: 0;
+    padding: 0;
+
+    border-top: 1px solid black;
+    background: #222;
+    opacity: 0.85;
+
+    z-index: 1;
+}
diff --git a/guacamole/src/main/webapp/app/client/styles/menu.css b/guacamole/src/main/webapp/app/client/styles/menu.css
new file mode 100644
index 0000000..a5bc2c4
--- /dev/null
+++ b/guacamole/src/main/webapp/app/client/styles/menu.css
@@ -0,0 +1,149 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+.menu {
+    overflow: hidden;
+    position: absolute;
+    top: 0;
+    height: 100%;
+    max-width: 100%;
+    width: 480px;
+    background: #EEE;
+    box-shadow: inset -1px 0 2px white, 1px 0 2px black;
+    z-index: 10;
+    -webkit-transition: left 0.125s, opacity 0.125s;
+    -moz-transition: left 0.125s, opacity 0.125s;
+    -ms-transition: left 0.125s, opacity 0.125s;
+    -o-transition: left 0.125s, opacity 0.125s;
+    transition: left 0.125s, opacity 0.125s;
+}
+
+.menu-content {
+
+    /* IE10 */
+    display: -ms-flexbox;
+    -ms-flex-align: stretch;
+    -ms-flex-direction: column;
+
+    /* Ancient Mozilla */
+    display: -moz-box;
+    -moz-box-align: stretch;
+    -moz-box-orient: vertical;
+    
+    /* Ancient WebKit */
+    display: -webkit-box;
+    -webkit-box-align: stretch;
+    -webkit-box-orient: vertical;
+
+    /* Old WebKit */
+    display: -webkit-flex;
+    -webkit-align-items: stretch;
+    -webkit-flex-direction: column;
+
+    /* W3C */
+    display: flex;
+    align-items: stretch;
+    flex-direction: column;
+    
+    width: 100%;
+    height: 100%;
+
+}
+
+.menu-content .header {
+
+    -ms-flex: 0 0 auto;
+    -moz-box-flex: 0;
+    -webkit-box-flex: 0;
+    -webkit-flex: 0 0 auto;
+    flex: 0 0 auto;
+
+    margin-bottom: 0;
+    
+}
+
+.menu-body {
+
+    -ms-flex: 1 1 auto;
+    -moz-box-flex: 1;
+    -webkit-box-flex: 1;
+    -webkit-flex: 1 1 auto;
+    flex: 1 1 auto;
+
+    padding: 1em;
+    overflow: auto;
+
+    /* IE10 */
+    display: -ms-flexbox;
+    -ms-flex-align: stretch;
+    -ms-flex-direction: column;
+
+    /* Ancient Mozilla */
+    display: -moz-box;
+    -moz-box-align: stretch;
+    -moz-box-orient: vertical;
+    
+    /* Ancient WebKit */
+    display: -webkit-box;
+    -webkit-box-align: stretch;
+    -webkit-box-orient: vertical;
+
+    /* Old WebKit */
+    display: -webkit-flex;
+    -webkit-align-items: stretch;
+    -webkit-flex-direction: column;
+
+    /* W3C */
+    display: flex;
+    align-items: stretch;
+    flex-direction: column;
+
+}
+
+.menu-body > * {
+    -ms-flex: 0 0 auto;
+    -moz-box-flex: 0;
+    -webkit-box-flex: 0;
+    -webkit-flex: 0 0 auto;
+    flex: 0 0 auto;
+}
+
+.menu-section h3 {
+    margin: 0;
+    padding: 0;
+    padding-bottom: 1em;
+}
+
+.menu-section ~ .menu-section h3 {
+    padding-top: 1em;
+}
+
+.menu,
+.menu.closed {
+    left: -480px;
+    opacity: 0;
+}
+
+.menu.open {
+    left: 0px;
+    opacity: 1;
+}
diff --git a/guacamole/src/main/webapp/app/client/styles/thumbnail-display.css b/guacamole/src/main/webapp/app/client/styles/thumbnail-display.css
new file mode 100644
index 0000000..8689597
--- /dev/null
+++ b/guacamole/src/main/webapp/app/client/styles/thumbnail-display.css
@@ -0,0 +1,38 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+div.thumbnail-main {
+    overflow: hidden;
+    width: 100%;
+    height: 100%;
+    position: relative;
+    font-size: 0px;
+}
+
+.thumbnail-main img {
+    max-width: 100%;
+}
+
+.thumbnail-main .display {
+    position: absolute;
+    pointer-events: none;
+}
\ No newline at end of file
diff --git a/guacamole/src/main/webapp/app/client/styles/transfer-manager.css b/guacamole/src/main/webapp/app/client/styles/transfer-manager.css
new file mode 100644
index 0000000..3d2eb80
--- /dev/null
+++ b/guacamole/src/main/webapp/app/client/styles/transfer-manager.css
@@ -0,0 +1,46 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+.transfer-manager {
+    background: white;
+}
+
+.transfer-manager .header h2 {
+    font-size: 1em;
+    padding-top: 0;
+    padding-bottom: 0;
+}
+
+.transfer-manager .header {
+    margin: 0;
+    -ms-flex-align:      center;
+    -moz-box-align:      center;
+    -webkit-box-align:   center;
+    -webkit-align-items: center;
+    align-items:         center;
+}
+
+.transfer-manager .transfers {
+    display: table;
+    padding: 0.25em;
+    width: 100%;
+}
diff --git a/guacamole/src/main/webapp/app/client/styles/transfer.css b/guacamole/src/main/webapp/app/client/styles/transfer.css
new file mode 100644
index 0000000..b3b7c42
--- /dev/null
+++ b/guacamole/src/main/webapp/app/client/styles/transfer.css
@@ -0,0 +1,135 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+.transfer {
+    display: table-row;
+}
+
+.transfer .transfer-status {
+    display: table-cell;
+    padding: 0.25em;
+    position: relative;
+}
+
+.transfer .text {
+    display: table-cell;
+    text-align: right;
+    padding: 0.25em
+}
+
+.transfer .filename {
+    white-space: nowrap;
+    text-overflow: ellipsis;
+    overflow: hidden;
+    position: relative;
+    font-family: monospace;
+    font-weight: bold;
+    padding: 0.125em;
+}
+
+ at keyframes transfer-progress {
+    from {background-position: 0px  0px;}
+    to   {background-position: 64px 0px;}
+}
+
+ at -webkit-keyframes transfer-progress {
+    from {background-position: 0px  0px;}
+    to   {background-position: 64px 0px;}
+}
+
+.transfer .progress {
+
+    width: 100%;
+    padding: 0.25em;
+    
+    position: absolute;
+    top: 0;
+    left: 0;
+    bottom: 0;
+    opacity: 0.25;
+    
+}
+
+.transfer.in-progress .progress {
+
+    background-color: #EEE;
+    background-image: url('images/progress.png');
+
+    background-size: 16px 16px;
+    -moz-background-size: 16px 16px;
+    -webkit-background-size: 16px 16px;
+    -khtml-background-size: 16px 16px;
+
+    animation-name: transfer-progress;
+    animation-duration: 2s;
+    animation-timing-function: linear;
+    animation-iteration-count: infinite;
+
+    -webkit-animation-name: transfer-progress;
+    -webkit-animation-duration: 2s;
+    -webkit-animation-timing-function: linear;
+    -webkit-animation-iteration-count: infinite;
+
+}
+
+.transfer .progress .bar {
+    display: none;
+    background: #A3D655;
+    position: absolute;
+    top: 0;
+    left: 0;
+    height: 100%;
+    width: 0;
+}
+
+.transfer.in-progress .progress .bar {
+    display: initial;
+}
+
+.transfer.savable {
+    cursor: pointer;
+}
+
+.transfer.savable .filename {
+    color: blue;
+    text-decoration: underline;
+}
+
+.transfer.error {
+    background: #FDD;
+}
+
+.transfer.error .text,
+.transfer.error .progress .bar {
+    display: none;
+}
+
+.transfer .error-text {
+    display: none;
+}
+
+.transfer.error .error-text {
+    display: block;
+    margin: 0;
+    margin-top: 0.5em;
+    width: 100%;
+}
diff --git a/guacamole/src/main/webapp/app/client/styles/viewport.css b/guacamole/src/main/webapp/app/client/styles/viewport.css
new file mode 100644
index 0000000..03e4743
--- /dev/null
+++ b/guacamole/src/main/webapp/app/client/styles/viewport.css
@@ -0,0 +1,30 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+.viewport {
+    position: absolute;
+    bottom: 0;
+    left: 0;
+    width: 100%;
+    height: 100%;
+    overflow: hidden;
+}
\ No newline at end of file
diff --git a/guacamole/src/main/webapp/app/client/templates/client.html b/guacamole/src/main/webapp/app/client/templates/client.html
new file mode 100644
index 0000000..b2292ed
--- /dev/null
+++ b/guacamole/src/main/webapp/app/client/templates/client.html
@@ -0,0 +1,192 @@
+<!--
+   Copyright (C) 2014 Glyptodon LLC
+
+   Permission is hereby granted, free of charge, to any person obtaining a copy
+   of this software and associated documentation files (the "Software"), to deal
+   in the Software without restriction, including without limitation the rights
+   to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+   copies of the Software, and to permit persons to whom the Software is
+   furnished to do so, subject to the following conditions:
+
+   The above copyright notice and this permission notice shall be included in
+   all copies or substantial portions of the Software.
+
+   THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+   IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+   FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+   AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+   LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+   OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+   THE SOFTWARE.
+-->
+
+<guac-viewport>
+
+    <!-- Client view -->
+    <div class="client-view">
+        <div class="client-view-content">
+
+            <!-- Central portion of view -->
+            <div class="client-body" guac-touch-drag="clientDrag" guac-touch-pinch="clientPinch">
+
+                <!-- Client -->
+                <guac-client client="client"></guac-client>
+
+            </div>
+
+            <!-- Bottom portion of view -->
+            <div class="client-bottom">
+
+                <!-- Text input -->
+                <div class="text-input-container" ng-show="showTextInput">
+                    <guac-text-input needs-focus="showTextInput"></guac-text-input>
+                </div>
+
+                <!-- On-screen keyboard -->
+                <div class="keyboard-container" ng-show="showOSK">
+                    <guac-osk layout="'CLIENT.URL_OSK_LAYOUT' | translate"></guac-osk>
+                </div>
+
+            </div>
+
+        </div>
+    </div>
+
+    <!-- File transfers -->
+    <div id="file-transfer-dialog" ng-show="hasTransfers()">
+        <guac-file-transfer-manager client="client"></guac-file-transfer-manager>
+    </div>
+
+    <!-- Menu -->
+    <div class="menu" ng-class="{open: menu.shown}" id="guac-menu">
+        <div class="menu-content">
+
+            <!-- Stationary header -->
+            <div class="header">
+                <h2>{{client.name}}</h2>
+                <guac-user-menu local-actions="clientMenuActions"></guac-user-menu>
+            </div>
+
+            <!-- Scrollable body -->
+            <div class="menu-body" guac-touch-drag="menuDrag" guac-scroll="menu.scrollState">
+
+                <!-- Clipboard -->
+                <div class="menu-section" id="clipboard-settings">
+                    <h3>{{'CLIENT.SECTION_HEADER_CLIPBOARD' | translate}}</h3>
+                    <div class="content">
+                        <p class="description">{{'CLIENT.HELP_CLIPBOARD' | translate}}</p>
+                        <textarea ng-model="client.clipboardData" rows="10" cols="40" id="clipboard"></textarea>
+                    </div>
+                </div>
+
+                <!-- Devices -->
+                <div class="menu-section" id="devices" ng-show="client.filesystems.length">
+                    <h3>{{'CLIENT.SECTION_HEADER_DEVICES' | translate}}</h3>
+                    <div class="content">
+                        <div class="device filesystem" ng-repeat="filesystem in client.filesystems" ng-click="showFilesystemMenu(filesystem)">
+                            {{filesystem.name}}
+                        </div>
+                    </div>
+                </div>
+
+                <!-- Input method -->
+                <div class="menu-section" id="keyboard-settings">
+                    <h3>{{'CLIENT.SECTION_HEADER_INPUT_METHOD' | translate}}</h3>
+                    <div class="content">
+
+                        <!-- No IME -->
+                        <div class="choice">
+                            <label><input id="ime-none" name="input-method" ng-change="closeMenu()" ng-model="menu.inputMethod" type="radio" value="none"/> {{'CLIENT.NAME_INPUT_METHOD_NONE' | translate}}</label>
+                            <p class="caption"><label for="ime-none">{{'CLIENT.HELP_INPUT_METHOD_NONE' | translate}}</label></p>
+                        </div>
+
+                        <!-- Text input -->
+                        <div class="choice">
+                            <div class="figure"><label for="ime-text"><img src="images/settings/tablet-keys.png" alt=""/></label></div>
+                            <label><input id="ime-text" name="input-method" ng-change="closeMenu()" ng-model="menu.inputMethod" type="radio" value="text"/> {{'CLIENT.NAME_INPUT_METHOD_TEXT' | translate}}</label>
+                            <p class="caption"><label for="ime-text">{{'CLIENT.HELP_INPUT_METHOD_TEXT' | translate}} </label></p>
+                        </div>
+
+                        <!-- Guac OSK -->
+                        <div class="choice">
+                            <label><input id="ime-osk" name="input-method" ng-change="closeMenu()" ng-model="menu.inputMethod" type="radio" value="osk"/> {{'CLIENT.NAME_INPUT_METHOD_OSK' | translate}}</label>
+                            <p class="caption"><label for="ime-osk">{{'CLIENT.HELP_INPUT_METHOD_OSK' | translate}}</label></p>
+                        </div>
+
+                    </div>
+                </div>
+
+                <!-- Mouse mode -->
+                <div class="menu-section" id="mouse-settings">
+                    <h3>{{'CLIENT.SECTION_HEADER_MOUSE_MODE' | translate}}</h3>
+                    <div class="content">
+                        <p class="description">{{'CLIENT.HELP_MOUSE_MODE' | translate}}</p>
+
+                        <!-- Touchscreen -->
+                        <div class="choice">
+                            <input name="mouse-mode" ng-change="closeMenu()" ng-model="client.clientProperties.emulateAbsoluteMouse" type="radio" ng-value="true" checked="checked" id="absolute"/>
+                            <div class="figure">
+                                <label for="absolute"><img src="images/settings/touchscreen.png" alt="{{'CLIENT.NAME_MOUSE_MODE_ABSOLUTE' | translate}}"/></label>
+                                <p class="caption"><label for="absolute">{{'CLIENT.HELP_MOUSE_MODE_ABSOLUTE' | translate}}</label></p>
+                            </div>
+                        </div>
+
+                        <!-- Touchpad -->
+                        <div class="choice">
+                            <input name="mouse-mode" ng-change="closeMenu()" ng-model="client.clientProperties.emulateAbsoluteMouse" type="radio" ng-value="false" id="relative"/>
+                            <div class="figure">
+                                <label for="relative"><img src="images/settings/touchpad.png" alt="{{'CLIENT.NAME_MOUSE_MODE_RELATIVE' | translate}}"/></label>
+                                <p class="caption"><label for="relative">{{'CLIENT.HELP_MOUSE_MODE_RELATIVE' | translate}}</label></p>
+                            </div>
+                        </div>
+
+                    </div>
+                </div>
+
+                <!-- Display options -->
+                <div class="menu-section" id="display-settings">
+                    <h3>{{'CLIENT.SECTION_HEADER_DISPLAY' | translate}}</h3>
+                    <div class="content">
+                        <div id="zoom-settings">
+                            <div ng-click="zoomOut()" id="zoom-out"><img src="images/settings/zoom-out.png" alt="-"/></div>
+                            <div id="zoom-state">{{formattedScale()}}%</div>
+                            <div ng-click="zoomIn()" id="zoom-in"><img src="images/settings/zoom-in.png" alt="+"/></div>
+                        </div>
+                        <div><label><input ng-model="menu.autoFit" ng-change="changeAutoFit()" ng-disabled="autoFitDisabled()" type="checkbox" id="auto-fit"/> {{'CLIENT.TEXT_ZOOM_AUTO_FIT' | translate}}</label></div>
+                    </div>
+                </div>
+
+            </div>
+
+        </div>
+    </div>
+
+    <!-- Filesystem menu -->
+    <div id="filesystem-menu" class="menu" ng-class="{open: isFilesystemMenuShown()}">
+        <div class="menu-content">
+
+            <!-- Stationary header -->
+            <div class="header">
+                <h2>{{filesystemMenuContents.name}}</h2>
+                <button class="upload button" guac-upload="uploadFiles">{{'CLIENT.ACTION_UPLOAD_FILES' | translate}}</button>
+                <button class="back" ng-click="hideFilesystemMenu()">{{'CLIENT.ACTION_NAVIGATE_BACK' | translate}}</button>
+            </div>
+
+            <!-- Breadcrumbs -->
+            <div class="header breadcrumbs"><div
+                    class="breadcrumb root"
+                    ng-click="changeDirectory(filesystemMenuContents, filesystemMenuContents.root)"></div><div
+                        class="breadcrumb"
+                        ng-repeat="file in getPath(filesystemMenuContents.currentDirectory)"
+                        ng-click="changeDirectory(filesystemMenuContents, file)">{{file.name}}</div>
+            </div>
+
+            <!-- Scrollable body -->
+            <div class="menu-body">
+                <guac-file-browser client="client" filesystem="filesystemMenuContents"></guac-file-browser>
+            </div>
+
+        </div>
+    </div>
+
+</guac-viewport>
diff --git a/guacamole/src/main/webapp/app/client/templates/file.html b/guacamole/src/main/webapp/app/client/templates/file.html
new file mode 100644
index 0000000..2bd86ea
--- /dev/null
+++ b/guacamole/src/main/webapp/app/client/templates/file.html
@@ -0,0 +1,30 @@
+<div class="list-item">
+    <!--
+       Copyright (C) 2015 Glyptodon LLC
+
+       Permission is hereby granted, free of charge, to any person obtaining a copy
+       of this software and associated documentation files (the "Software"), to deal
+       in the Software without restriction, including without limitation the rights
+       to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+       copies of the Software, and to permit persons to whom the Software is
+       furnished to do so, subject to the following conditions:
+
+       The above copyright notice and this permission notice shall be included in
+       all copies or substantial portions of the Software.
+
+       THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+       IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+       FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+       AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+       LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+       OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+       THE SOFTWARE.
+    -->
+
+    <!-- Filename and icon -->
+    <div class="caption">
+        <div class="icon"></div>
+        {{::name}}
+    </div>
+
+</div>
diff --git a/guacamole/src/main/webapp/app/client/templates/guacClient.html b/guacamole/src/main/webapp/app/client/templates/guacClient.html
new file mode 100644
index 0000000..0642b17
--- /dev/null
+++ b/guacamole/src/main/webapp/app/client/templates/guacClient.html
@@ -0,0 +1,34 @@
+<div class="main" guac-resize="mainElementResized">
+    <!--
+       Copyright (C) 2014 Glyptodon LLC
+
+       Permission is hereby granted, free of charge, to any person obtaining a copy
+       of this software and associated documentation files (the "Software"), to deal
+       in the Software without restriction, including without limitation the rights
+       to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+       copies of the Software, and to permit persons to whom the Software is
+       furnished to do so, subject to the following conditions:
+
+       The above copyright notice and this permission notice shall be included in
+       all copies or substantial portions of the Software.
+
+       THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+       IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+       FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+       AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+       LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+       OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+       THE SOFTWARE.
+    -->
+
+    <!-- Display -->
+    <div class="displayOuter">
+
+        <div class="displayMiddle">
+            <div class="display software-cursor">
+            </div>
+        </div>
+
+    </div>
+
+</div>
\ No newline at end of file
diff --git a/guacamole/src/main/webapp/app/client/templates/guacFileBrowser.html b/guacamole/src/main/webapp/app/client/templates/guacFileBrowser.html
new file mode 100644
index 0000000..2fd57c7
--- /dev/null
+++ b/guacamole/src/main/webapp/app/client/templates/guacFileBrowser.html
@@ -0,0 +1,27 @@
+<div class="file-browser">
+    <!--
+       Copyright (C) 2015 Glyptodon LLC
+
+       Permission is hereby granted, free of charge, to any person obtaining a copy
+       of this software and associated documentation files (the "Software"), to deal
+       in the Software without restriction, including without limitation the rights
+       to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+       copies of the Software, and to permit persons to whom the Software is
+       furnished to do so, subject to the following conditions:
+
+       The above copyright notice and this permission notice shall be included in
+       all copies or substantial portions of the Software.
+
+       THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+       IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+       FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+       AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+       LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+       OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+       THE SOFTWARE.
+    -->
+
+    <!-- Current directory contents -->
+    <div class="current-directory-contents"></div>
+
+</div>
diff --git a/guacamole/src/main/webapp/app/client/templates/guacFileTransfer.html b/guacamole/src/main/webapp/app/client/templates/guacFileTransfer.html
new file mode 100644
index 0000000..8af545b
--- /dev/null
+++ b/guacamole/src/main/webapp/app/client/templates/guacFileTransfer.html
@@ -0,0 +1,43 @@
+<div class="transfer" ng-class="{'in-progress': isInProgress(), 'savable': isSavable(), 'error': hasError()}" ng-click="save()">
+    <!--
+       Copyright (C) 2014 Glyptodon LLC
+
+       Permission is hereby granted, free of charge, to any person obtaining a copy
+       of this software and associated documentation files (the "Software"), to deal
+       in the Software without restriction, including without limitation the rights
+       to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+       copies of the Software, and to permit persons to whom the Software is
+       furnished to do so, subject to the following conditions:
+
+       The above copyright notice and this permission notice shall be included in
+       all copies or substantial portions of the Software.
+
+       THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+       IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+       FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+       AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+       LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+       OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+       THE SOFTWARE.
+    -->
+
+    <!-- Overall status of transfer -->
+    <div class="transfer-status">
+
+        <!-- Filename and progress bar -->
+        <div class="filename">
+            <div class="progress"><div ng-style="{'width': getPercentDone() + '%'}" class="bar"></div></div>
+            {{transfer.filename}}
+        </div>
+
+        <!-- Error text -->
+        <p class="error-text">{{getErrorText() | translate}}</p>
+
+    </div>
+
+    <!-- Progress/status text -->
+    <div class="text"
+         translate="CLIENT.TEXT_FILE_TRANSFER_PROGRESS"
+         translate-values="{PROGRESS: getProgressValue(), UNIT: getProgressUnit()}"></div>
+
+</div>
diff --git a/guacamole/src/main/webapp/app/client/templates/guacFileTransferManager.html b/guacamole/src/main/webapp/app/client/templates/guacFileTransferManager.html
new file mode 100644
index 0000000..fc2a22f
--- /dev/null
+++ b/guacamole/src/main/webapp/app/client/templates/guacFileTransferManager.html
@@ -0,0 +1,43 @@
+<div class="transfer-manager">
+    <!--
+       Copyright (C) 2014 Glyptodon LLC
+
+       Permission is hereby granted, free of charge, to any person obtaining a copy
+       of this software and associated documentation files (the "Software"), to deal
+       in the Software without restriction, including without limitation the rights
+       to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+       copies of the Software, and to permit persons to whom the Software is
+       furnished to do so, subject to the following conditions:
+
+       The above copyright notice and this permission notice shall be included in
+       all copies or substantial portions of the Software.
+
+       THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+       IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+       FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+       AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+       LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+       OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+       THE SOFTWARE.
+    -->
+
+    <!-- File transfer manager header -->
+    <div class="header">
+        <h2>{{'CLIENT.SECTION_HEADER_FILE_TRANSFERS' | translate}}</h2>
+        <button ng-click="clearCompletedTransfers()">{{'CLIENT.ACTION_CLEAR_COMPLETED_TRANSFERS' | translate}}</button>
+    </div>
+
+    <!-- Sent/received files -->
+    <div class="transfer-manager-body">
+        <div class="transfers">
+            <guac-file-transfer
+                transfer="upload"
+                ng-repeat="upload in client.uploads">
+            </guac-file-transfer><guac-file-transfer
+                transfer="download"
+                ng-repeat="download in client.downloads">
+            </guac-file-transfer>
+        </div>
+    </div>
+
+</div>
diff --git a/guacamole/src/main/webapp/app/client/templates/guacThumbnail.html b/guacamole/src/main/webapp/app/client/templates/guacThumbnail.html
new file mode 100644
index 0000000..1dd1c61
--- /dev/null
+++ b/guacamole/src/main/webapp/app/client/templates/guacThumbnail.html
@@ -0,0 +1,31 @@
+<div class="thumbnail-main" guac-resize="updateDisplayScale">
+    <!--
+       Copyright (C) 2014 Glyptodon LLC
+
+       Permission is hereby granted, free of charge, to any person obtaining a copy
+       of this software and associated documentation files (the "Software"), to deal
+       in the Software without restriction, including without limitation the rights
+       to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+       copies of the Software, and to permit persons to whom the Software is
+       furnished to do so, subject to the following conditions:
+
+       The above copyright notice and this permission notice shall be included in
+       all copies or substantial portions of the Software.
+
+       THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+       IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+       FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+       AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+       LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+       OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+       THE SOFTWARE.
+    -->
+
+    <!-- Display -->
+    <div class="display">
+    </div>
+
+    <!-- Dummy background thumbnail -->
+    <img alt="" ng-src="{{thumbnail}}"/>
+
+</div>
\ No newline at end of file
diff --git a/guacamole/src/main/webapp/app/client/templates/guacViewport.html b/guacamole/src/main/webapp/app/client/templates/guacViewport.html
new file mode 100644
index 0000000..e31e61a
--- /dev/null
+++ b/guacamole/src/main/webapp/app/client/templates/guacViewport.html
@@ -0,0 +1,23 @@
+<div class="viewport" ng-transclude>
+    <!--
+       Copyright (C) 2014 Glyptodon LLC
+
+       Permission is hereby granted, free of charge, to any person obtaining a copy
+       of this software and associated documentation files (the "Software"), to deal
+       in the Software without restriction, including without limitation the rights
+       to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+       copies of the Software, and to permit persons to whom the Software is
+       furnished to do so, subject to the following conditions:
+
+       The above copyright notice and this permission notice shall be included in
+       all copies or substantial portions of the Software.
+
+       THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+       IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+       FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+       AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+       LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+       OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+       THE SOFTWARE.
+    -->
+</div>
\ No newline at end of file
diff --git a/guacamole/src/main/webapp/app/client/types/ClientProperties.js b/guacamole/src/main/webapp/app/client/types/ClientProperties.js
new file mode 100644
index 0000000..e20665b
--- /dev/null
+++ b/guacamole/src/main/webapp/app/client/types/ClientProperties.js
@@ -0,0 +1,109 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * A service for generating new guacClient properties objects.
+ */
+angular.module('client').factory('ClientProperties', ['$injector', function defineClientProperties($injector) {
+
+    // Required services
+    var preferenceService = $injector.get('preferenceService');
+        
+    /**
+     * Object used for interacting with a guacClient directive.
+     * 
+     * @constructor
+     * @param {ClientProperties|Object} [template={}]
+     *     The object whose properties should be copied within the new
+     *     ClientProperties.
+     */
+    var ClientProperties = function ClientProperties(template) {
+
+        // Use empty object by default
+        template = template || {};
+
+        /**
+         * Whether the display should be scaled automatically to fit within the
+         * available space.
+         * 
+         * @type Boolean
+         */
+        this.autoFit = template.autoFit || true;
+
+        /**
+         * The current scale. If autoFit is true, the effect of setting this
+         * value is undefined.
+         * 
+         * @type Number
+         */
+        this.scale = template.scale || 1;
+
+        /**
+         * The minimum scale value.
+         * 
+         * @type Number
+         */
+        this.minScale = template.minScale || 1;
+
+        /**
+         * The maximum scale value.
+         * 
+         * @type Number
+         */
+        this.maxScale = template.maxScale || 3;
+
+        /**
+         * Whether or not the client should listen to keyboard events.
+         * 
+         * @type Boolean
+         */
+        this.keyboardEnabled = template.keyboardEnabled || true;
+        
+        /**
+         * Whether translation of touch to mouse events should emulate an
+         * absolute pointer device, or a relative pointer device.
+         * 
+         * @type Boolean
+         */
+        this.emulateAbsoluteMouse = template.emulateAbsoluteMouse || preferenceService.preferences.emulateAbsoluteMouse;
+
+        /**
+         * The relative Y coordinate of the scroll offset of the display within
+         * the client element.
+         * 
+         * @type Number
+         */
+        this.scrollTop = template.scrollTop || 0;
+
+        /**
+         * The relative X coordinate of the scroll offset of the display within
+         * the client element.
+         * 
+         * @type Number
+         */
+        this.scrollLeft = template.scrollLeft || 0;
+
+    };
+
+    return ClientProperties;
+
+}]);
\ No newline at end of file
diff --git a/guacamole/src/main/webapp/app/client/types/ManagedClient.js b/guacamole/src/main/webapp/app/client/types/ManagedClient.js
new file mode 100644
index 0000000..a518ca8
--- /dev/null
+++ b/guacamole/src/main/webapp/app/client/types/ManagedClient.js
@@ -0,0 +1,496 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Provides the ManagedClient class used by the guacClientManager service.
+ */
+angular.module('client').factory('ManagedClient', ['$rootScope', '$injector',
+    function defineManagedClient($rootScope, $injector) {
+
+    // Required types
+    var ClientProperties     = $injector.get('ClientProperties');
+    var ClientIdentifier     = $injector.get('ClientIdentifier');
+    var ManagedClientState   = $injector.get('ManagedClientState');
+    var ManagedDisplay       = $injector.get('ManagedDisplay');
+    var ManagedFileDownload  = $injector.get('ManagedFileDownload');
+    var ManagedFilesystem    = $injector.get('ManagedFilesystem');
+    var ManagedFileUpload    = $injector.get('ManagedFileUpload');
+
+    // Required services
+    var $document              = $injector.get('$document');
+    var $q                     = $injector.get('$q');
+    var $window                = $injector.get('$window');
+    var authenticationService  = $injector.get('authenticationService');
+    var connectionGroupService = $injector.get('connectionGroupService');
+    var connectionService      = $injector.get('connectionService');
+    var guacAudio              = $injector.get('guacAudio');
+    var guacHistory            = $injector.get('guacHistory');
+    var guacImage              = $injector.get('guacImage');
+    var guacVideo              = $injector.get('guacVideo');
+        
+    /**
+     * Object which serves as a surrogate interface, encapsulating a Guacamole
+     * client while it is active, allowing it to be detached and reattached
+     * from different client views.
+     * 
+     * @constructor
+     * @param {ManagedClient|Object} [template={}]
+     *     The object whose properties should be copied within the new
+     *     ManagedClient.
+     */
+    var ManagedClient = function ManagedClient(template) {
+
+        // Use empty object by default
+        template = template || {};
+
+        /**
+         * The ID of the connection associated with this client.
+         *
+         * @type String
+         */
+        this.id = template.id;
+
+        /**
+         * The actual underlying Guacamole client.
+         *
+         * @type Guacamole.Client
+         */
+        this.client = template.client;
+
+        /**
+         * The tunnel being used by the underlying Guacamole client.
+         *
+         * @type Guacamole.Tunnel
+         */
+        this.tunnel = template.tunnel;
+
+        /**
+         * The display associated with the underlying Guacamole client.
+         * 
+         * @type ManagedDisplay
+         */
+        this.managedDisplay = template.managedDisplay;
+
+        /**
+         * The name returned associated with the connection or connection
+         * group in use.
+         *
+         * @type String
+         */
+        this.name = template.name;
+
+        /**
+         * The current clipboard contents.
+         *
+         * @type String
+         */
+        this.clipboardData = template.clipboardData || '';
+
+        /**
+         * All downloaded files. As files are downloaded, their progress can be
+         * observed through the elements of this array. It is intended that
+         * this array be manipulated externally as needed.
+         *
+         * @type ManagedFileDownload[]
+         */
+        this.downloads = template.downloads || [];
+
+        /**
+         * All uploaded files. As files are uploaded, their progress can be
+         * observed through the elements of this array. It is intended that
+         * this array be manipulated externally as needed.
+         *
+         * @type ManagedFileUpload[]
+         */
+        this.uploads = template.uploads || [];
+
+        /**
+         * All currently-exposed filesystems. When the Guacamole server exposes
+         * a filesystem object, that object will be made available as a
+         * ManagedFilesystem within this array.
+         *
+         * @type ManagedFilesystem[]
+         */
+        this.filesystems = template.filesystems || [];
+
+        /**
+         * The current state of the Guacamole client (idle, connecting,
+         * connected, terminated with error, etc.).
+         * 
+         * @type ManagedClientState
+         */
+        this.clientState = template.clientState || new ManagedClientState();
+
+        /**
+         * Properties associated with the display and behavior of the Guacamole
+         * client.
+         *
+         * @type ClientProperties
+         */
+        this.clientProperties = template.clientProperties || new ClientProperties();
+
+    };
+
+    /**
+     * Returns a promise which resolves with the string of connection
+     * parameters to be passed to the Guacamole client during connection. This
+     * string generally contains the desired connection ID, display resolution,
+     * and supported audio/video/image formats. The returned promise is
+     * guaranteed to resolve successfully.
+     *
+     * @param {ClientIdentifier} identifier
+     *     The identifier representing the connection or group to connect to.
+     *
+     * @param {String} [connectionParameters]
+     *     Any additional HTTP parameters to pass while connecting.
+     * 
+     * @returns {Promise.<String>}
+     *     A promise which resolves with the string of connection parameters to
+     *     be passed to the Guacamole client, once the string is ready.
+     */
+    var getConnectString = function getConnectString(identifier, connectionParameters) {
+
+        var deferred = $q.defer();
+
+        // Calculate optimal width/height for display
+        var pixel_density = $window.devicePixelRatio || 1;
+        var optimal_dpi = pixel_density * 96;
+        var optimal_width = $window.innerWidth * pixel_density;
+        var optimal_height = $window.innerHeight * pixel_density;
+
+        // Build base connect string
+        var connectString =
+              "token="             + encodeURIComponent(authenticationService.getCurrentToken())
+            + "&GUAC_DATA_SOURCE=" + encodeURIComponent(identifier.dataSource)
+            + "&GUAC_ID="          + encodeURIComponent(identifier.id)
+            + "&GUAC_TYPE="        + encodeURIComponent(identifier.type)
+            + "&GUAC_WIDTH="       + Math.floor(optimal_width)
+            + "&GUAC_HEIGHT="      + Math.floor(optimal_height)
+            + "&GUAC_DPI="         + Math.floor(optimal_dpi)
+            + (connectionParameters ? '&' + connectionParameters : '');
+
+        // Add audio mimetypes to connect string
+        guacAudio.supported.forEach(function(mimetype) {
+            connectString += "&GUAC_AUDIO=" + encodeURIComponent(mimetype);
+        });
+
+        // Add video mimetypes to connect string
+        guacVideo.supported.forEach(function(mimetype) {
+            connectString += "&GUAC_VIDEO=" + encodeURIComponent(mimetype);
+        });
+
+        // Add image mimetypes to connect string
+        guacImage.getSupportedMimetypes().then(function supportedMimetypesKnown(mimetypes) {
+
+            // Add each image mimetype
+            angular.forEach(mimetypes, function addImageMimetype(mimetype) {
+                connectString += "&GUAC_IMAGE=" + encodeURIComponent(mimetype);
+            });
+
+            // Connect string is now ready - nothing else is deferred
+            deferred.resolve(connectString);
+
+        });
+
+        return deferred.promise;
+
+    };
+
+    /**
+     * Store the thumbnail of the given managed client within the connection
+     * history under its associated ID. If the client is not connected, this
+     * function has no effect.
+     *
+     * @param {String} managedClient
+     *     The client whose history entry should be updated.
+     */
+    var updateHistoryEntry = function updateHistoryEntry(managedClient) {
+
+        var display = managedClient.client.getDisplay();
+
+        // Update stored thumbnail of previous connection 
+        if (display && display.getWidth() > 0 && display.getHeight() > 0) {
+
+            // Get screenshot
+            var canvas = display.flatten();
+            
+            // Calculate scale of thumbnail (max 320x240, max zoom 100%)
+            var scale = Math.min(320 / canvas.width, 240 / canvas.height, 1);
+            
+            // Create thumbnail canvas
+            var thumbnail = $document[0].createElement("canvas");
+            thumbnail.width  = canvas.width*scale;
+            thumbnail.height = canvas.height*scale;
+            
+            // Scale screenshot to thumbnail
+            var context = thumbnail.getContext("2d");
+            context.drawImage(canvas,
+                0, 0, canvas.width, canvas.height,
+                0, 0, thumbnail.width, thumbnail.height
+            );
+
+            guacHistory.updateThumbnail(managedClient.id, thumbnail.toDataURL("image/png"));
+
+        }
+
+    };
+
+    /**
+     * Creates a new ManagedClient, connecting it to the specified connection
+     * or group.
+     *
+     * @param {String} id
+     *     The ID of the connection or group to connect to. This String must be
+     *     a valid ClientIdentifier string, as would be generated by
+     *     ClientIdentifier.toString().
+     *
+     * @param {String} [connectionParameters]
+     *     Any additional HTTP parameters to pass while connecting.
+     * 
+     * @returns {ManagedClient}
+     *     A new ManagedClient instance which is connected to the connection or
+     *     connection group having the given ID.
+     */
+    ManagedClient.getInstance = function getInstance(id, connectionParameters) {
+
+        var tunnel;
+
+        // If WebSocket available, try to use it.
+        if ($window.WebSocket)
+            tunnel = new Guacamole.ChainedTunnel(
+                new Guacamole.WebSocketTunnel('websocket-tunnel'),
+                new Guacamole.HTTPTunnel('tunnel')
+            );
+        
+        // If no WebSocket, then use HTTP.
+        else
+            tunnel = new Guacamole.HTTPTunnel('tunnel');
+
+        // Get new client instance
+        var client = new Guacamole.Client(tunnel);
+
+        // Associate new managed client with new client and tunnel
+        var managedClient = new ManagedClient({
+            id     : id,
+            client : client,
+            tunnel : tunnel
+        });
+
+        // Fire events for tunnel errors
+        tunnel.onerror = function tunnelError(status) {
+            $rootScope.$apply(function handleTunnelError() {
+                ManagedClientState.setConnectionState(managedClient.clientState,
+                    ManagedClientState.ConnectionState.TUNNEL_ERROR,
+                    status.code);
+            });
+        };
+        
+        // Update connection state as tunnel state changes
+        tunnel.onstatechange = function tunnelStateChanged(state) {
+            $rootScope.$evalAsync(function updateTunnelState() {
+                
+                switch (state) {
+
+                    // Connection is being established
+                    case Guacamole.Tunnel.State.CONNECTING:
+                        ManagedClientState.setConnectionState(managedClient.clientState,
+                            ManagedClientState.ConnectionState.CONNECTING);
+                        break;
+
+                    // Connection has closed
+                    case Guacamole.Tunnel.State.CLOSED:
+                        ManagedClientState.setConnectionState(managedClient.clientState,
+                            ManagedClientState.ConnectionState.DISCONNECTED);
+                        break;
+                    
+                }
+            
+            });
+        };
+
+        // Update connection state as client state changes
+        client.onstatechange = function clientStateChanged(clientState) {
+            $rootScope.$evalAsync(function updateClientState() {
+
+                switch (clientState) {
+
+                    // Idle
+                    case 0:
+                        ManagedClientState.setConnectionState(managedClient.clientState,
+                            ManagedClientState.ConnectionState.IDLE);
+                        break;
+
+                    // Ignore "connecting" state
+                    case 1: // Connecting
+                        break;
+
+                    // Connected + waiting
+                    case 2:
+                        ManagedClientState.setConnectionState(managedClient.clientState,
+                            ManagedClientState.ConnectionState.WAITING);
+                        break;
+
+                    // Connected
+                    case 3:
+                        ManagedClientState.setConnectionState(managedClient.clientState,
+                            ManagedClientState.ConnectionState.CONNECTED);
+                        break;
+
+                    // Update history when disconnecting
+                    case 4: // Disconnecting
+                    case 5: // Disconnected
+                        updateHistoryEntry(managedClient);
+                        break;
+
+                }
+
+            });
+        };
+
+        // Disconnect and update status when the client receives an error
+        client.onerror = function clientError(status) {
+            $rootScope.$apply(function handleClientError() {
+
+                // Disconnect, if connected
+                client.disconnect();
+
+                // Update state
+                ManagedClientState.setConnectionState(managedClient.clientState,
+                    ManagedClientState.ConnectionState.CLIENT_ERROR,
+                    status.code);
+
+            });
+        };
+
+        // Handle any received clipboard data
+        client.onclipboard = function clientClipboardReceived(stream, mimetype) {
+
+            // Only text/plain is supported for now
+            if (mimetype !== "text/plain") {
+                stream.sendAck("Only text/plain supported", Guacamole.Status.Code.UNSUPPORTED);
+                return;
+            }
+
+            var reader = new Guacamole.StringReader(stream);
+            var data = "";
+
+            // Append any received data to buffer
+            reader.ontext = function clipboard_text_received(text) {
+                data += text;
+                stream.sendAck("Received", Guacamole.Status.Code.SUCCESS);
+            };
+
+            // Update state when done
+            reader.onend = function clipboard_text_end() {
+                $rootScope.$apply(function updateClipboard() {
+                    managedClient.clipboardData = data;
+                });
+            };
+
+        };
+
+        // Handle any received files
+        client.onfile = function clientFileReceived(stream, mimetype, filename) {
+            $rootScope.$apply(function startDownload() {
+                managedClient.downloads.push(ManagedFileDownload.getInstance(stream, mimetype, filename));
+            });
+        };
+
+        // Handle any received filesystem objects
+        client.onfilesystem = function fileSystemReceived(object, name) {
+            $rootScope.$apply(function exposeFilesystem() {
+                managedClient.filesystems.push(ManagedFilesystem.getInstance(object, name));
+            });
+        };
+
+        // Manage the client display
+        managedClient.managedDisplay = ManagedDisplay.getInstance(client.getDisplay());
+
+        // Parse connection details from ID
+        var clientIdentifier = ClientIdentifier.fromString(id);
+
+        // Connect the Guacamole client
+        getConnectString(clientIdentifier, connectionParameters)
+        .then(function connectClient(connectString) {
+            client.connect(connectString);
+        });
+
+        // If using a connection, pull connection name
+        if (clientIdentifier.type === ClientIdentifier.Types.CONNECTION) {
+            connectionService.getConnection(clientIdentifier.dataSource, clientIdentifier.id)
+            .success(function connectionRetrieved(connection) {
+                managedClient.name = connection.name;
+            });
+        }
+        
+        // If using a connection group, pull connection name
+        else if (clientIdentifier.type === ClientIdentifier.Types.CONNECTION_GROUP) {
+            connectionGroupService.getConnectionGroup(clientIdentifier.dataSource, clientIdentifier.id)
+            .success(function connectionGroupRetrieved(group) {
+                managedClient.name = group.name;
+            });
+        }
+
+        return managedClient;
+
+    };
+
+    /**
+     * Uploads the given file to the server through the given Guacamole client.
+     * The file transfer can be monitored through the corresponding entry in
+     * the uploads array of the given managedClient.
+     * 
+     * @param {ManagedClient} managedClient
+     *     The ManagedClient through which the file is to be uploaded.
+     * 
+     * @param {File} file
+     *     The file to upload.
+     *
+     * @param {ManagedFilesystem} [filesystem]
+     *     The filesystem to upload the file to, if any. If not specified, the
+     *     file will be sent as a generic Guacamole file stream.
+     *
+     * @param {ManagedFilesystem.File} [directory=filesystem.currentDirectory]
+     *     The directory within the given filesystem to upload the file to. If
+     *     not specified, but a filesystem is given, the current directory of
+     *     that filesystem will be used.
+     */
+    ManagedClient.uploadFile = function uploadFile(managedClient, file, filesystem, directory) {
+
+        // Use generic Guacamole file streams by default
+        var object = null;
+        var streamName = null;
+
+        // If a filesystem is given, determine the destination object and stream
+        if (filesystem) {
+            object = filesystem.object;
+            streamName = (directory || filesystem.currentDirectory).streamName + '/' + file.name;
+        }
+
+        // Start and manage file upload
+        managedClient.uploads.push(ManagedFileUpload.getInstance(managedClient.client, file, object, streamName));
+
+    };
+
+    return ManagedClient;
+
+}]);
\ No newline at end of file
diff --git a/guacamole/src/main/webapp/app/client/types/ManagedClientState.js b/guacamole/src/main/webapp/app/client/types/ManagedClientState.js
new file mode 100644
index 0000000..4ab0922
--- /dev/null
+++ b/guacamole/src/main/webapp/app/client/types/ManagedClientState.js
@@ -0,0 +1,159 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Provides the ManagedClient class used by the guacClientManager service.
+ */
+angular.module('client').factory('ManagedClientState', [function defineManagedClientState() {
+
+    /**
+     * Object which represents the state of a Guacamole client and its tunnel,
+     * including any error conditions.
+     * 
+     * @constructor
+     * @param {ManagedClientState|Object} [template={}]
+     *     The object whose properties should be copied within the new
+     *     ManagedClientState.
+     */
+    var ManagedClientState = function ManagedClientState(template) {
+
+        // Use empty object by default
+        template = template || {};
+
+        /**
+         * The current connection state. Valid values are described by
+         * ManagedClientState.ConnectionState.
+         *
+         * @type String
+         * @default ManagedClientState.ConnectionState.IDLE
+         */
+        this.connectionState = template.connectionState || ManagedClientState.ConnectionState.IDLE;
+
+        /**
+         * The status code of the current error condition, if connectionState
+         * is CLIENT_ERROR or TUNNEL_ERROR. For all other connectionState
+         * values, this will be @link{Guacamole.Status.Code.SUCCESS}.
+         *
+         * @type Number
+         * @default Guacamole.Status.Code.SUCCESS
+         */
+        this.statusCode = template.statusCode || Guacamole.Status.Code.SUCCESS;
+
+    };
+
+    /**
+     * Valid connection state strings. Each state string is associated with a
+     * specific state of a Guacamole connection.
+     */
+    ManagedClientState.ConnectionState = {
+
+        /**
+         * The Guacamole connection has not yet been attempted.
+         * 
+         * @type String
+         */
+        IDLE : "IDLE",
+
+        /**
+         * The Guacamole connection is being established.
+         * 
+         * @type String
+         */
+        CONNECTING : "CONNECTING",
+
+        /**
+         * The Guacamole connection has been successfully established, and the
+         * client is now waiting for receipt of initial graphical data.
+         * 
+         * @type String
+         */
+        WAITING : "WAITING",
+
+        /**
+         * The Guacamole connection has been successfully established, and
+         * initial graphical data has been received.
+         * 
+         * @type String
+         */
+        CONNECTED : "CONNECTED",
+
+        /**
+         * The Guacamole connection has terminated successfully. No errors are
+         * indicated.
+         * 
+         * @type String
+         */
+        DISCONNECTED : "DISCONNECTED",
+
+        /**
+         * The Guacamole connection has terminated due to an error reported by
+         * the client. The associated error code is stored in statusCode.
+         * 
+         * @type String
+         */
+        CLIENT_ERROR : "CLIENT_ERROR",
+
+        /**
+         * The Guacamole connection has terminated due to an error reported by
+         * the tunnel. The associated error code is stored in statusCode.
+         * 
+         * @type String
+         */
+        TUNNEL_ERROR : "TUNNEL_ERROR"
+
+    };
+
+    /**
+     * Sets the current client state and, if given, the associated status code.
+     * If an error is already represented, this function has no effect.
+     *
+     * @param {ManagedClientState} clientState
+     *     The ManagedClientState to update.
+     *
+     * @param {String} connectionState
+     *     The connection state to assign to the given ManagedClientState, as
+     *     listed within ManagedClientState.ConnectionState.
+     * 
+     * @param {Number} [statusCode]
+     *     The status code to assign to the given ManagedClientState, if any,
+     *     as listed within Guacamole.Status.Code. If no status code is
+     *     specified, the status code of the ManagedClientState is not touched.
+     */
+    ManagedClientState.setConnectionState = function(clientState, connectionState, statusCode) {
+
+        // Do not set state after an error is registered
+        if (clientState.connectionState === ManagedClientState.ConnectionState.TUNNEL_ERROR
+         || clientState.connectionState === ManagedClientState.ConnectionState.CLIENT_ERROR)
+            return;
+
+        // Update connection state
+        clientState.connectionState = connectionState;
+
+        // Set status code, if given
+        if (statusCode)
+            clientState.statusCode = statusCode;
+
+    };
+
+    return ManagedClientState;
+
+}]);
\ No newline at end of file
diff --git a/guacamole/src/main/webapp/app/client/types/ManagedDisplay.js b/guacamole/src/main/webapp/app/client/types/ManagedDisplay.js
new file mode 100644
index 0000000..6710402
--- /dev/null
+++ b/guacamole/src/main/webapp/app/client/types/ManagedDisplay.js
@@ -0,0 +1,177 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Provides the ManagedDisplay class used by the guacClientManager service.
+ */
+angular.module('client').factory('ManagedDisplay', ['$rootScope',
+    function defineManagedDisplay($rootScope) {
+
+    /**
+     * Object which serves as a surrogate interface, encapsulating a Guacamole
+     * display while it is active, allowing it to be detached and reattached
+     * from different client views.
+     * 
+     * @constructor
+     * @param {ManagedDisplay|Object} [template={}]
+     *     The object whose properties should be copied within the new
+     *     ManagedDisplay.
+     */
+    var ManagedDisplay = function ManagedDisplay(template) {
+
+        // Use empty object by default
+        template = template || {};
+
+        /**
+         * The underlying Guacamole display.
+         * 
+         * @type Guacamole.Display
+         */
+        this.display = template.display;
+
+        /**
+         * The current size of the Guacamole display.
+         *
+         * @type ManagedDisplay.Dimensions
+         */
+        this.size = new ManagedDisplay.Dimensions(template.size);
+
+        /**
+         * The current mouse cursor, if any.
+         * 
+         * @type ManagedDisplay.Cursor
+         */
+        this.cursor = template.cursor;
+
+    };
+
+    /**
+     * Object which represents the size of the Guacamole display.
+     *
+     * @constructor
+     * @param {ManagedDisplay.Dimensions|Object} template
+     *     The object whose properties should be copied within the new
+     *     ManagedDisplay.Dimensions.
+     */
+    ManagedDisplay.Dimensions = function Dimensions(template) {
+
+        // Use empty object by default
+        template = template || {};
+
+        /**
+         * The current width of the Guacamole display, in pixels.
+         *
+         * @type Number
+         */
+        this.width = template.width || 0;
+
+        /**
+         * The current width of the Guacamole display, in pixels.
+         *
+         * @type Number
+         */
+        this.height = template.height || 0;
+
+    };
+
+    /**
+     * Object which represents a mouse cursor used by the Guacamole display.
+     *
+     * @constructor
+     * @param {ManagedDisplay.Cursor|Object} template
+     *     The object whose properties should be copied within the new
+     *     ManagedDisplay.Cursor.
+     */
+    ManagedDisplay.Cursor = function Cursor(template) {
+
+        // Use empty object by default
+        template = template || {};
+
+        /**
+         * The actual mouse cursor image.
+         * 
+         * @type HTMLCanvasElement
+         */
+        this.canvas = template.canvas;
+
+        /**
+         * The X coordinate of the cursor hotspot.
+         * 
+         * @type Number
+         */
+        this.x = template.x;
+
+        /**
+         * The Y coordinate of the cursor hotspot.
+         * 
+         * @type Number
+         */
+        this.y = template.y;
+
+    };
+
+    /**
+     * Creates a new ManagedDisplay which represents the current state of the
+     * given Guacamole display.
+     * 
+     * @param {Guacamole.Display} display
+     *     The Guacamole display to represent. Changes to this display will
+     *     affect this ManagedDisplay.
+     *
+     * @returns {ManagedDisplay}
+     *     A new ManagedDisplay which represents the current state of the
+     *     given Guacamole display.
+     */
+    ManagedDisplay.getInstance = function getInstance(display) {
+
+        var managedDisplay = new ManagedDisplay({
+            display : display
+        });
+
+        // Store changes to display size
+        display.onresize = function setClientSize() {
+            $rootScope.$apply(function updateClientSize() {
+                managedDisplay.size = new ManagedDisplay.Dimensions({
+                    width  : display.getWidth(),
+                    height : display.getHeight()
+                });
+            });
+        };
+
+        // Store changes to display cursor
+        display.oncursor = function setClientCursor(canvas, x, y) {
+            $rootScope.$apply(function updateClientCursor() {
+                managedDisplay.cursor = new ManagedDisplay.Cursor({
+                    canvas : canvas,
+                    x      : x,
+                    y      : y
+                });
+            });
+        };
+
+        return managedDisplay;
+
+    };
+
+    return ManagedDisplay;
+
+}]);
\ No newline at end of file
diff --git a/guacamole/src/main/webapp/app/client/types/ManagedFileDownload.js b/guacamole/src/main/webapp/app/client/types/ManagedFileDownload.js
new file mode 100644
index 0000000..9c9c39f
--- /dev/null
+++ b/guacamole/src/main/webapp/app/client/types/ManagedFileDownload.js
@@ -0,0 +1,156 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Provides the ManagedFileDownload class used by the guacClientManager service.
+ */
+angular.module('client').factory('ManagedFileDownload', ['$rootScope', '$injector',
+    function defineManagedFileDownload($rootScope, $injector) {
+
+    // Required types
+    var ManagedFileTransferState = $injector.get('ManagedFileTransferState');
+
+    /**
+     * Object which serves as a surrogate interface, encapsulating a Guacamole
+     * file download while it is active, allowing it to be detached and
+     * reattached from different client views.
+     * 
+     * @constructor
+     * @param {ManagedFileDownload|Object} [template={}]
+     *     The object whose properties should be copied within the new
+     *     ManagedFileDownload.
+     */
+    var ManagedFileDownload = function ManagedFileDownload(template) {
+
+        // Use empty object by default
+        template = template || {};
+
+        /**
+         * The current state of the file transfer stream.
+         *
+         * @type ManagedFileTransferState
+         */
+        this.transferState = template.transferState || new ManagedFileTransferState();
+
+        /**
+         * The mimetype of the file being transferred.
+         *
+         * @type String
+         */
+        this.mimetype = template.mimetype;
+
+        /**
+         * The filename of the file being transferred.
+         *
+         * @type String
+         */
+        this.filename = template.filename;
+
+        /**
+         * The number of bytes transferred so far.
+         *
+         * @type Number
+         */
+        this.progress = template.progress;
+
+        /**
+         * A blob containing the complete downloaded file. This is available
+         * only after the download has finished.
+         *
+         * @type Blob
+         */
+        this.blob = template.blob;
+
+    };
+
+    /**
+     * Creates a new ManagedFileDownload which downloads the contents of the
+     * given stream as a file having the given mimetype and filename.
+     *
+     * @param {Guacamole.InputStream} stream
+     *     The stream whose contents should be downloaded as a file.
+     *
+     * @param {String} mimetype
+     *     The mimetype of the stream contents.
+     *
+     * @param {String} filename
+     *     The filename of the file being received over the steram.
+     *
+     * @return {ManagedFileDownload}
+     *     A new ManagedFileDownload object which can be used to track the
+     *     progress of the download.
+     */
+    ManagedFileDownload.getInstance = function getInstance(stream, mimetype, filename) {
+
+        // Init new file download object
+        var managedFileDownload = new ManagedFileDownload({
+            mimetype : mimetype,
+            filename : filename,
+            progress : 0,
+            transferState : new ManagedFileTransferState({
+                streamState : ManagedFileTransferState.StreamState.OPEN
+            })
+        });
+
+        // Begin file download
+        var blob_reader = new Guacamole.BlobReader(stream, mimetype);
+
+        // Update progress as data is received
+        blob_reader.onprogress = function onprogress() {
+
+            // Update progress
+            $rootScope.$apply(function downloadStreamProgress() {
+                managedFileDownload.progress = blob_reader.getLength();
+            });
+
+            // Signal server that data was received
+            stream.sendAck("Received", Guacamole.Status.Code.SUCCESS);
+
+        };
+
+        // Save blob and close stream when complete
+        blob_reader.onend = function onend() {
+            $rootScope.$apply(function downloadStreamEnd() {
+
+                // Save blob
+                managedFileDownload.blob = blob_reader.getBlob();
+
+                // Mark stream as closed
+                ManagedFileTransferState.setStreamState(managedFileDownload.transferState,
+                    ManagedFileTransferState.StreamState.CLOSED);
+
+                // Notify of upload completion
+                $rootScope.$broadcast('guacDownloadComplete', filename);
+
+            });
+        };
+
+        // Signal server that data is ready to be received
+        stream.sendAck("Ready", Guacamole.Status.Code.SUCCESS);
+        
+        return managedFileDownload;
+
+    };
+
+    return ManagedFileDownload;
+
+}]);
\ No newline at end of file
diff --git a/guacamole/src/main/webapp/app/client/types/ManagedFileTransferState.js b/guacamole/src/main/webapp/app/client/types/ManagedFileTransferState.js
new file mode 100644
index 0000000..f394188
--- /dev/null
+++ b/guacamole/src/main/webapp/app/client/types/ManagedFileTransferState.js
@@ -0,0 +1,136 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Provides the ManagedFileTransferState class used by the guacClientManager
+ * service.
+ */
+angular.module('client').factory('ManagedFileTransferState', [function defineManagedFileTransferState() {
+
+    /**
+     * Object which represents the state of a Guacamole stream, including any
+     * error conditions.
+     * 
+     * @constructor
+     * @param {ManagedFileTransferState|Object} [template={}]
+     *     The object whose properties should be copied within the new
+     *     ManagedFileTransferState.
+     */
+    var ManagedFileTransferState = function ManagedFileTransferState(template) {
+
+        // Use empty object by default
+        template = template || {};
+
+        /**
+         * The current stream state. Valid values are described by
+         * ManagedFileTransferState.StreamState.
+         *
+         * @type String
+         * @default ManagedFileTransferState.StreamState.IDLE
+         */
+        this.streamState = template.streamState || ManagedFileTransferState.StreamState.IDLE;
+
+        /**
+         * The status code of the current error condition, if streamState
+         * is ERROR. For all other streamState values, this will be
+         * @link{Guacamole.Status.Code.SUCCESS}.
+         *
+         * @type Number
+         * @default Guacamole.Status.Code.SUCCESS
+         */
+        this.statusCode = template.statusCode || Guacamole.Status.Code.SUCCESS;
+
+    };
+
+    /**
+     * Valid stream state strings. Each state string is associated with a
+     * specific state of a Guacamole stream.
+     */
+    ManagedFileTransferState.StreamState = {
+
+        /**
+         * The stream has not yet been opened.
+         * 
+         * @type String
+         */
+        IDLE : "IDLE",
+
+        /**
+         * The stream has been successfully established. Data can be sent or
+         * received.
+         * 
+         * @type String
+         */
+        OPEN : "OPEN",
+
+        /**
+         * The stream has terminated successfully. No errors are indicated.
+         * 
+         * @type String
+         */
+        CLOSED : "CLOSED",
+
+        /**
+         * The stream has terminated due to an error. The associated error code
+         * is stored in statusCode.
+         *
+         * @type String
+         */
+        ERROR : "ERROR"
+
+    };
+
+    /**
+     * Sets the current transfer state and, if given, the associated status
+     * code. If an error is already represented, this function has no effect.
+     *
+     * @param {ManagedFileTransferState} transferState
+     *     The ManagedFileTransferState to update.
+     *
+     * @param {String} streamState
+     *     The stream state to assign to the given ManagedFileTransferState, as
+     *     listed within ManagedFileTransferState.StreamState.
+     * 
+     * @param {Number} [statusCode]
+     *     The status code to assign to the given ManagedFileTransferState, if
+     *     any, as listed within Guacamole.Status.Code. If no status code is
+     *     specified, the status code of the ManagedFileTransferState is not
+     *     touched.
+     */
+    ManagedFileTransferState.setStreamState = function setStreamState(transferState, streamState, statusCode) {
+
+        // Do not set state after an error is registered
+        if (transferState.streamState === ManagedFileTransferState.StreamState.ERROR)
+            return;
+
+        // Update stream state
+        transferState.streamState = streamState;
+
+        // Set status code, if given
+        if (statusCode)
+            transferState.statusCode = statusCode;
+
+    };
+
+    return ManagedFileTransferState;
+
+}]);
\ No newline at end of file
diff --git a/guacamole/src/main/webapp/app/client/types/ManagedFileUpload.js b/guacamole/src/main/webapp/app/client/types/ManagedFileUpload.js
new file mode 100644
index 0000000..15839a7
--- /dev/null
+++ b/guacamole/src/main/webapp/app/client/types/ManagedFileUpload.js
@@ -0,0 +1,236 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Provides the ManagedFileUpload class used by the guacClientManager service.
+ */
+angular.module('client').factory('ManagedFileUpload', ['$rootScope', '$injector',
+    function defineManagedFileUpload($rootScope, $injector) {
+
+    // Required types
+    var ManagedFileTransferState = $injector.get('ManagedFileTransferState');
+
+    // Required services
+    var $window = $injector.get('$window');
+
+    /**
+     * The maximum number of bytes to include in each blob for the Guacamole
+     * file stream. Note that this, along with instruction opcode and protocol-
+     * related overhead, must not exceed the 8192 byte maximum imposed by the
+     * Guacamole protocol.
+     *
+     * @type Number
+     */
+    var STREAM_BLOB_SIZE = 4096;
+
+    /**
+     * Object which serves as a surrogate interface, encapsulating a Guacamole
+     * file upload while it is active, allowing it to be detached and
+     * reattached from different client views.
+     * 
+     * @constructor
+     * @param {ManagedFileUpload|Object} [template={}]
+     *     The object whose properties should be copied within the new
+     *     ManagedFileUpload.
+     */
+    var ManagedFileUpload = function ManagedFileUpload(template) {
+
+        // Use empty object by default
+        template = template || {};
+
+        /**
+         * The current state of the file transfer stream.
+         *
+         * @type ManagedFileTransferState
+         */
+        this.transferState = template.transferState || new ManagedFileTransferState();
+
+        /**
+         * The mimetype of the file being transferred.
+         *
+         * @type String
+         */
+        this.mimetype = template.mimetype;
+
+        /**
+         * The filename of the file being transferred.
+         *
+         * @type String
+         */
+        this.filename = template.filename;
+
+        /**
+         * The number of bytes transferred so far.
+         *
+         * @type Number
+         */
+        this.progress = template.progress;
+
+        /**
+         * The total number of bytes in the file.
+         *
+         * @type Number
+         */
+        this.length = template.length;
+
+    };
+
+    /**
+     * Converts the given bytes to a base64-encoded string.
+     * 
+     * @param {Uint8Array} bytes A Uint8Array which contains the data to be
+     *                           encoded as base64.
+     * @return {String} The base64-encoded string.
+     */
+    var getBase64 = function getBase64(bytes) {
+
+        var data = "";
+
+        // Produce binary string from bytes in buffer
+        for (var i=0; i<bytes.byteLength; i++)
+            data += String.fromCharCode(bytes[i]);
+
+        // Convert to base64
+        return $window.btoa(data);
+
+    };
+
+    /**
+     * Creates a new ManagedFileUpload which uploads the given file to the
+     * server through the given Guacamole client.
+     * 
+     * @param {Guacamole.Client} client
+     *     The Guacamole client through which the file is to be uploaded.
+     * 
+     * @param {File} file
+     *     The file to upload.
+     *     
+     * @param {Object} [object]
+     *     The object to upload the file to, if any, such as a filesystem
+     *     object.
+     *
+     * @param {String} [streamName]
+     *     The name of the stream to upload the file to. If an object is given,
+     *     this must be specified.
+     *
+     * @return {ManagedFileUpload}
+     *     A new ManagedFileUpload object which can be used to track the
+     *     progress of the upload.
+     */
+    ManagedFileUpload.getInstance = function getInstance(client, file, object, streamName) {
+
+        var managedFileUpload = new ManagedFileUpload();
+
+        // Construct reader for file
+        var reader = new FileReader();
+        reader.onloadend = function fileContentsLoaded() {
+
+            // Open file for writing
+            var stream;
+            if (!object)
+                stream = client.createFileStream(file.type, file.name);
+
+            // If object/streamName specified, upload to that instead of a file
+            // stream
+            else
+                stream = object.createOutputStream(file.type, streamName);
+
+            var valid = true;
+            var bytes = new Uint8Array(reader.result);
+            var offset = 0;
+
+            $rootScope.$apply(function uploadStreamOpen() {
+
+                // Init managed upload
+                managedFileUpload.filename = file.name;
+                managedFileUpload.mimetype = file.type;
+                managedFileUpload.progress = 0;
+                managedFileUpload.length   = bytes.length;
+
+                // Notify that stream is open
+                ManagedFileTransferState.setStreamState(managedFileUpload.transferState,
+                    ManagedFileTransferState.StreamState.OPEN);
+
+            });
+
+            // Invalidate stream on all errors
+            // Continue upload when acknowledged
+            stream.onack = function ackReceived(status) {
+
+                // Handle errors 
+                if (status.isError()) {
+                    valid = false;
+                    $rootScope.$apply(function uploadStreamError() {
+                        ManagedFileTransferState.setStreamState(managedFileUpload.transferState,
+                            ManagedFileTransferState.StreamState.ERROR,
+                            status.code);
+                    });
+                }
+
+                // Abort upload if stream is invalid
+                if (!valid)
+                    return false;
+
+                // Encode packet as base64
+                var slice = bytes.subarray(offset, offset + STREAM_BLOB_SIZE);
+                var base64 = getBase64(slice);
+
+                // Write packet
+                stream.sendBlob(base64);
+
+                // Advance to next packet
+                offset += STREAM_BLOB_SIZE;
+
+                $rootScope.$apply(function uploadStreamProgress() {
+
+                    // If at end, stop upload
+                    if (offset >= bytes.length) {
+                        stream.sendEnd();
+                        managedFileUpload.progress = bytes.length;
+
+                        // Upload complete
+                        ManagedFileTransferState.setStreamState(managedFileUpload.transferState,
+                            ManagedFileTransferState.StreamState.CLOSED);
+
+                        // Notify of upload completion
+                        $rootScope.$broadcast('guacUploadComplete', file.name);
+
+                    }
+
+                    // Otherwise, update progress
+                    else
+                        managedFileUpload.progress = offset;
+
+                });
+
+            }; // end ack handler
+
+        };
+        reader.readAsArrayBuffer(file);
+
+        return managedFileUpload;
+
+    };
+
+    return ManagedFileUpload;
+
+}]);
\ No newline at end of file
diff --git a/guacamole/src/main/webapp/app/client/types/ManagedFilesystem.js b/guacamole/src/main/webapp/app/client/types/ManagedFilesystem.js
new file mode 100644
index 0000000..65205de
--- /dev/null
+++ b/guacamole/src/main/webapp/app/client/types/ManagedFilesystem.js
@@ -0,0 +1,334 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Provides the ManagedFilesystem class used by ManagedClient to represent
+ * available remote filesystems.
+ */
+angular.module('client').factory('ManagedFilesystem', ['$rootScope', '$injector',
+    function defineManagedFilesystem($rootScope, $injector) {
+
+    // Required types
+    var ManagedFileDownload = $injector.get('ManagedFileDownload');
+    var ManagedFileUpload   = $injector.get('ManagedFileUpload');
+
+    /**
+     * Object which serves as a surrogate interface, encapsulating a Guacamole
+     * filesystem object while it is active, allowing it to be detached and
+     * reattached from different client views.
+     * 
+     * @constructor
+     * @param {ManagedFilesystem|Object} [template={}]
+     *     The object whose properties should be copied within the new
+     *     ManagedFilesystem.
+     */
+    var ManagedFilesystem = function ManagedFilesystem(template) {
+
+        // Use empty object by default
+        template = template || {};
+
+        /**
+         * The Guacamole filesystem object, as received via a "filesystem"
+         * instruction.
+         *
+         * @type Guacamole.Object
+         */
+        this.object = template.object;
+
+        /**
+         * The declared, human-readable name of the filesystem
+         *
+         * @type String
+         */
+        this.name = template.name;
+
+        /**
+         * The root directory of the filesystem.
+         *
+         * @type ManagedFilesystem.File
+         */
+        this.root = template.root;
+
+        /**
+         * The current directory being viewed or manipulated within the
+         * filesystem.
+         *
+         * @type ManagedFilesystem.File
+         */
+        this.currentDirectory = template.currentDirectory || template.root;
+
+    };
+
+    /**
+     * Refreshes the contents of the given file, if that file is a directory.
+     * Only the immediate children of the file are refreshed. Files further
+     * down the directory tree are not refreshed.
+     *
+     * @param {ManagedFilesystem} filesystem
+     *     The filesystem associated with the file being refreshed.
+     *
+     * @param {ManagedFilesystem.File} file
+     *     The file being refreshed.
+     */
+    ManagedFilesystem.refresh = function updateDirectory(filesystem, file) {
+
+        // Do not attempt to refresh the contents of directories
+        if (file.mimetype !== Guacamole.Object.STREAM_INDEX_MIMETYPE)
+            return;
+
+        // Request contents of given file
+        filesystem.object.requestInputStream(file.streamName, function handleStream(stream, mimetype) {
+
+            // Ignore stream if mimetype is wrong
+            if (mimetype !== Guacamole.Object.STREAM_INDEX_MIMETYPE) {
+                stream.sendAck('Unexpected mimetype', Guacamole.Status.Code.UNSUPPORTED);
+                return;
+            }
+
+            // Signal server that data is ready to be received
+            stream.sendAck('Ready', Guacamole.Status.Code.SUCCESS);
+
+            // Read stream as JSON
+            var reader = new Guacamole.JSONReader(stream);
+
+            // Acknowledge received JSON blobs
+            reader.onprogress = function onprogress() {
+                stream.sendAck("Received", Guacamole.Status.Code.SUCCESS);
+            };
+
+            // Reset contents of directory
+            reader.onend = function jsonReady() {
+                $rootScope.$evalAsync(function updateFileContents() {
+
+                    // Empty contents
+                    file.files = {};
+
+                    // Determine the expected filename prefix of each stream
+                    var expectedPrefix = file.streamName;
+                    if (expectedPrefix.charAt(expectedPrefix.length - 1) !== '/')
+                        expectedPrefix += '/';
+
+                    // For each received stream name
+                    var mimetypes = reader.getJSON();
+                    for (var name in mimetypes) {
+
+                        // Assert prefix is correct
+                        if (name.substring(0, expectedPrefix.length) !== expectedPrefix)
+                            continue;
+
+                        // Extract filename from stream name
+                        var filename = name.substring(expectedPrefix.length);
+
+                        // Deduce type from mimetype
+                        var type = ManagedFilesystem.File.Type.NORMAL;
+                        if (mimetypes[name] === Guacamole.Object.STREAM_INDEX_MIMETYPE)
+                            type = ManagedFilesystem.File.Type.DIRECTORY;
+
+                        // Add file entry
+                        file.files[filename] = new ManagedFilesystem.File({
+                            mimetype   : mimetypes[name],
+                            streamName : name,
+                            type       : type,
+                            parent     : file,
+                            name       : filename
+                        });
+
+                    }
+
+                });
+            };
+
+        });
+
+    };
+
+    /**
+     * Creates a new ManagedFilesystem instance from the given Guacamole.Object
+     * and human-readable name. Upon creation, a request to populate the
+     * contents of the root directory will be automatically dispatched.
+     *
+     * @param {Guacamole.Object} object
+     *     The Guacamole.Object defining the filesystem.
+     *
+     * @param {String} name
+     *     A human-readable name for the filesystem.
+     *
+     * @returns {ManagedFilesystem}
+     *     The newly-created ManagedFilesystem.
+     */
+    ManagedFilesystem.getInstance = function getInstance(object, name) {
+
+        // Init new filesystem object
+        var managedFilesystem = new ManagedFilesystem({
+            object : object,
+            name   : name,
+            root   : new ManagedFilesystem.File({
+                mimetype   : Guacamole.Object.STREAM_INDEX_MIMETYPE,
+                streamName : Guacamole.Object.ROOT_STREAM,
+                type       : ManagedFilesystem.File.Type.DIRECTORY
+            })
+        });
+
+        // Retrieve contents of root
+        ManagedFilesystem.refresh(managedFilesystem, managedFilesystem.root);
+
+        return managedFilesystem;
+
+    };
+
+    /**
+     * Downloads the given file from the server using the given Guacamole
+     * client and filesystem. The file transfer can be monitored through the
+     * corresponding entry in the downloads array of the given ManagedClient.
+     *
+     * @param {ManagedClient} managedClient
+     *     The ManagedClient from which the file is to be downloaded.
+     *
+     * @param {ManagedFilesystem} managedFilesystem
+     *     The ManagedFilesystem from which the file is to be downloaded. Any
+     *     path information provided must be relative to this filesystem.
+     *
+     * @param {String} path
+     *     The full, absolute path of the file to download.
+     */
+    ManagedFilesystem.downloadFile = function downloadFile(managedClient, managedFilesystem, path) {
+
+        // Request download
+        managedFilesystem.object.requestInputStream(path, function downloadStreamReceived(stream, mimetype) {
+
+            // Parse filename from string
+            var filename = path.match(/(.*[\\/])?(.*)/)[2];
+
+            // Start and track download
+            managedClient.downloads.push(ManagedFileDownload.getInstance(stream, mimetype, filename));
+
+        });
+
+    };
+
+    /**
+     * Changes the current directory of the given filesystem, automatically
+     * refreshing the contents of that directory.
+     *
+     * @param {ManagedFilesystem} filesystem
+     *     The filesystem whose current directory should be changed.
+     *
+     * @param {ManagedFilesystem.File} file
+     *     The directory to change to.
+     */
+    ManagedFilesystem.changeDirectory = function changeDirectory(filesystem, file) {
+
+        // Refresh contents
+        ManagedFilesystem.refresh(filesystem, file);
+
+        // Set current directory
+        filesystem.currentDirectory = file;
+
+    };
+
+    /**
+     * A file within a ManagedFilesystem. Each ManagedFilesystem.File provides
+     * sufficient information for retrieval or replacement of the file's
+     * contents, as well as the file's name and type.
+     *
+     * @param {ManagedFilesystem|Object} [template={}]
+     *     The object whose properties should be copied within the new
+     *     ManagedFilesystem.File.
+     */
+    ManagedFilesystem.File = function File(template) {
+
+        /**
+         * The mimetype of the data contained within this file.
+         *
+         * @type String
+         */
+        this.mimetype = template.mimetype;
+
+        /**
+         * The name of the stream representing this files contents within its
+         * associated filesystem object.
+         *
+         * @type String
+         */
+        this.streamName = template.streamName;
+
+        /**
+         * The type of this file. All legal file type strings are defined
+         * within ManagedFilesystem.File.Type.
+         *
+         * @type String
+         */
+        this.type = template.type;
+
+        /**
+         * The name of this file.
+         *
+         * @type String
+         */
+        this.name = template.name;
+
+        /**
+         * The parent directory of this file. In the case of the root
+         * directory, this will be null.
+         *
+         * @type ManagedFilesystem.File
+         */
+        this.parent = template.parent;
+
+        /**
+         * Map of all known files containined within this file by name. This is
+         * only applicable to directories.
+         *
+         * @type Object.<String, ManagedFilesystem.File>
+         */
+        this.files = template.files || {};
+
+    };
+
+    /**
+     * All legal type strings for a ManagedFilesystem.File.
+     *
+     * @type Object.<String, String>
+     */
+    ManagedFilesystem.File.Type = {
+
+        /**
+         * A normal file. As ManagedFilesystem does not currently represent any
+         * other non-directory types of files, like symbolic links, this type
+         * string may be used for any non-directory file.
+         *
+         * @type String
+         */
+        NORMAL : 'NORMAL',
+
+        /**
+         * A directory.
+         *
+         * @type String
+         */
+        DIRECTORY : 'DIRECTORY'
+
+    };
+
+    return ManagedFilesystem;
+
+}]);
diff --git a/guacamole/src/main/webapp/app/element/directives/guacFocus.js b/guacamole/src/main/webapp/app/element/directives/guacFocus.js
new file mode 100644
index 0000000..b8f8d6c
--- /dev/null
+++ b/guacamole/src/main/webapp/app/element/directives/guacFocus.js
@@ -0,0 +1,76 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * A directive which allows elements to be manually focused / blurred.
+ */
+angular.module('element').directive('guacFocus', ['$parse', function guacFocus($parse) {
+
+    return {
+        restrict: 'A',
+
+        link: function linkGuacFocus($scope, $element, $attrs) {
+
+            /**
+             * Whether the element associated with this directive should be
+             * focussed.
+             *
+             * @type Boolean
+             */
+            var guacFocus = $parse($attrs.guacFocus);
+
+            /**
+             * The element which will be focused / blurred.
+             *
+             * @type Element
+             */
+            var element = $element[0];
+
+            // Set/unset focus depending on value of guacFocus
+            $scope.$watch(guacFocus, function updateFocus(value) {
+                $scope.$evalAsync(function updateFocusAsync() {
+                    if (value)
+                        element.focus();
+                    else
+                        element.blur();
+                });
+            });
+
+            // Set focus flag when focus is received
+            element.addEventListener('focus', function focusReceived() {
+                $scope.$evalAsync(function setGuacFocusAsync() {
+                    guacFocus.assign($scope, true);
+                });
+            });
+
+            // Unset focus flag when focus is lost
+            element.addEventListener('blur', function focusLost() {
+                $scope.$evalAsync(function unsetGuacFocusAsync() {
+                    guacFocus.assign($scope, false);
+                });
+            });
+
+        } // end guacFocus link function
+
+    };
+
+}]);
diff --git a/guacamole/src/main/webapp/app/element/directives/guacMarker.js b/guacamole/src/main/webapp/app/element/directives/guacMarker.js
new file mode 100644
index 0000000..444cf68
--- /dev/null
+++ b/guacamole/src/main/webapp/app/element/directives/guacMarker.js
@@ -0,0 +1,62 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * A directive which stores a marker which refers to a specific element,
+ * allowing that element to be scrolled into view when desired.
+ */
+angular.module('element').directive('guacMarker', ['$injector', function guacMarker($injector) {
+
+    // Required types
+    var Marker = $injector.get('Marker');
+
+    // Required services
+    var $parse = $injector.get('$parse');
+
+    return {
+        restrict: 'A',
+
+        link: function linkGuacMarker($scope, $element, $attrs) {
+
+            /**
+             * The property in which a new Marker should be stored. The new
+             * Marker will refer to the element associated with this directive.
+             *
+             * @type Marker
+             */
+            var guacMarker = $parse($attrs.guacMarker);
+
+            /**
+             * The element to associate with the new Marker.
+             *
+             * @type Element
+             */
+            var element = $element[0];
+
+            // Assign new marker
+            guacMarker.assign($scope, new Marker(element));
+
+        }
+
+    };
+
+}]);
diff --git a/guacamole/src/main/webapp/app/element/directives/guacResize.js b/guacamole/src/main/webapp/app/element/directives/guacResize.js
new file mode 100644
index 0000000..fbc2882
--- /dev/null
+++ b/guacamole/src/main/webapp/app/element/directives/guacResize.js
@@ -0,0 +1,117 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * A directive which calls a given callback when its associated element is
+ * resized. This will modify the internal DOM tree of the associated element,
+ * and the associated element MUST have position (for example,
+ * "position: relative").
+ */
+angular.module('element').directive('guacResize', ['$document', function guacResize($document) {
+
+    return {
+        restrict: 'A',
+
+        link: function linkGuacResize($scope, $element, $attrs) {
+
+            /**
+             * The function to call whenever the associated element is
+             * resized. The function will be passed the width and height of
+             * the element, in pixels.
+             *
+             * @type Function 
+             */
+            var guacResize = $scope.$eval($attrs.guacResize);
+
+            /**
+             * The element which will monitored for size changes.
+             *
+             * @type Element
+             */
+            var element = $element[0];
+
+            /**
+             * The resize sensor - an HTML object element.
+             *
+             * @type HTMLObjectElement
+             */
+            var resizeSensor = $document[0].createElement('object');
+
+            /**
+             * The width of the associated element, in pixels.
+             *
+             * @type Number
+             */
+            var lastWidth = element.offsetWidth;
+
+            /**
+             * The height of the associated element, in pixels.
+             *
+             * @type Number
+             */
+            var lastHeight = element.offsetHeight;
+
+            /**
+             * Checks whether the size of the associated element has changed
+             * and, if so, calls the resize callback with the new width and
+             * height as parameters.
+             */
+            var checkSize = function checkSize() {
+
+                // Call callback only if size actually changed
+                if (element.offsetWidth !== lastWidth
+                 || element.offsetHeight !== lastHeight) {
+
+                    // Call resize callback, if defined
+                    if (guacResize) {
+                        $scope.$evalAsync(function elementSizeChanged() {
+                            guacResize(element.offsetWidth, element.offsetHeight);
+                        });
+                    }
+
+                    // Update stored size
+                    lastWidth  = element.offsetWidth;
+                    lastHeight = element.offsetHeight;
+
+                 }
+
+            };
+
+            // Register event listener once window object exists
+            resizeSensor.onload = function resizeSensorReady() {
+                resizeSensor.contentDocument.defaultView.addEventListener('resize', checkSize);
+                checkSize();
+            };
+
+            // Load blank contents
+            resizeSensor.className = 'resize-sensor';
+            resizeSensor.type      = 'text/html';
+            resizeSensor.data      = 'app/element/templates/blank.html';
+
+            // Add resize sensor to associated element
+            element.insertBefore(resizeSensor, element.firstChild);
+
+        } // end guacResize link function
+
+    };
+
+}]);
diff --git a/guacamole/src/main/webapp/app/element/directives/guacScroll.js b/guacamole/src/main/webapp/app/element/directives/guacScroll.js
new file mode 100644
index 0000000..57bbf41
--- /dev/null
+++ b/guacamole/src/main/webapp/app/element/directives/guacScroll.js
@@ -0,0 +1,85 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * A directive which allows elements to be manually scrolled, and for their
+ * scroll state to be observed.
+ */
+angular.module('element').directive('guacScroll', [function guacScroll() {
+
+    return {
+        restrict: 'A',
+
+        link: function linkGuacScroll($scope, $element, $attrs) {
+
+            /**
+             * The current scroll state of the element.
+             *
+             * @type ScrollState
+             */
+            var guacScroll = $scope.$eval($attrs.guacScroll);
+
+            /**
+             * The element which is being scrolled, or monitored for changes
+             * in scroll.
+             *
+             * @type Element
+             */
+            var element = $element[0];
+
+            /**
+             * Returns the current left edge of the scrolling rectangle.
+             *
+             * @returns {Number}
+             *     The current left edge of the scrolling rectangle.
+             */
+            var getScrollLeft = function getScrollLeft() {
+                return guacScroll.left;
+            };
+
+            /**
+             * Returns the current top edge of the scrolling rectangle.
+             *
+             * @returns {Number}
+             *     The current top edge of the scrolling rectangle.
+             */
+            var getScrollTop = function getScrollTop() {
+                return guacScroll.top;
+            };
+
+            // Update underlying scrollLeft property when left changes
+            $scope.$watch(getScrollLeft, function scrollLeftChanged(left) {
+                element.scrollLeft = left;
+                guacScroll.left = element.scrollLeft;
+            });
+
+            // Update underlying scrollTop property when top changes
+            $scope.$watch(getScrollTop, function scrollTopChanged(top) {
+                element.scrollTop = top;
+                guacScroll.top = element.scrollTop;
+            });
+
+        } // end guacScroll link function
+
+    };
+
+}]);
diff --git a/guacamole/src/main/webapp/app/element/directives/guacUpload.js b/guacamole/src/main/webapp/app/element/directives/guacUpload.js
new file mode 100644
index 0000000..b235e0d
--- /dev/null
+++ b/guacamole/src/main/webapp/app/element/directives/guacUpload.js
@@ -0,0 +1,95 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * A directive which allows multiple files to be uploaded. Clicking on the
+ * associated element will result in a file selector dialog, which then calls
+ * the provided callback function with any chosen files.
+ */
+angular.module('element').directive('guacUpload', ['$document', function guacUpload($document) {
+
+    return {
+        restrict: 'A',
+
+        link: function linkGuacUpload($scope, $element, $attrs) {
+
+            /**
+             * The function to call whenever files are chosen. The callback is
+             * provided a single parameter: the FileList containing all chosen
+             * files.
+             *
+             * @type Function 
+             */
+            var guacUpload = $scope.$eval($attrs.guacUpload);
+
+            /**
+             * The element which will register the drag gesture.
+             *
+             * @type Element
+             */
+            var element = $element[0];
+
+            /**
+             * Internal form, containing a single file input element.
+             *
+             * @type HTMLFormElement
+             */
+            var form = $document[0].createElement('form');
+
+            /**
+             * Internal file input element.
+             *
+             * @type HTMLInputElement
+             */
+            var input = $document[0].createElement('input');
+
+            // Init input element
+            input.type = 'file';
+            input.multiple = true;
+
+            // Add input element to internal form
+            form.appendChild(input);
+
+            // Notify of any chosen files
+            input.addEventListener('change', function filesSelected() {
+                $scope.$apply(function setSelectedFiles() {
+
+                    // Only set chosen files selection is not canceled
+                    if (guacUpload && input.files.length > 0)
+                        guacUpload(input.files);
+
+                    // Reset selection
+                    form.reset();
+
+                });
+            });
+
+            // Open file chooser when element is clicked
+            element.addEventListener('click', function elementClicked() {
+                input.click();
+            });
+
+        } // end guacUpload link function
+
+    };
+
+}]);
diff --git a/guacamole/src/main/webapp/app/element/elementModule.js b/guacamole/src/main/webapp/app/element/elementModule.js
new file mode 100644
index 0000000..f43a0ab
--- /dev/null
+++ b/guacamole/src/main/webapp/app/element/elementModule.js
@@ -0,0 +1,27 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Module for manipulating element state, such as focus or scroll position, as
+ * well as handling browser events.
+ */
+angular.module('element', []);
diff --git a/guacamole/src/main/webapp/app/element/styles/resize-sensor.css b/guacamole/src/main/webapp/app/element/styles/resize-sensor.css
new file mode 100644
index 0000000..2f260af
--- /dev/null
+++ b/guacamole/src/main/webapp/app/element/styles/resize-sensor.css
@@ -0,0 +1,33 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+.resize-sensor {
+    height: 100%;
+    width: 100%;
+    position: absolute;
+    left: 0;
+    top: 0;
+    overflow: hidden;
+    border: none;
+    opacity: 0;
+    z-index: -1;
+}
diff --git a/guacamole/src/main/webapp/app/element/templates/blank.html b/guacamole/src/main/webapp/app/element/templates/blank.html
new file mode 100644
index 0000000..0a2bef6
--- /dev/null
+++ b/guacamole/src/main/webapp/app/element/templates/blank.html
@@ -0,0 +1,29 @@
+<!DOCTYPE html>
+<html>
+    <head>
+        <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
+        <title>_</title>
+    </head>
+    <!--
+       Copyright (C) 2015 Glyptodon LLC
+
+       Permission is hereby granted, free of charge, to any person obtaining a copy
+       of this software and associated documentation files (the "Software"), to deal
+       in the Software without restriction, including without limitation the rights
+       to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+       copies of the Software, and to permit persons to whom the Software is
+       furnished to do so, subject to the following conditions:
+
+       The above copyright notice and this permission notice shall be included in
+       all copies or substantial portions of the Software.
+
+       THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+       IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+       FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+       AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+       LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+       OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+       THE SOFTWARE.
+    -->
+    <body></body>
+</html>
diff --git a/guacamole/src/main/webapp/app/element/types/Marker.js b/guacamole/src/main/webapp/app/element/types/Marker.js
new file mode 100644
index 0000000..c3cada4
--- /dev/null
+++ b/guacamole/src/main/webapp/app/element/types/Marker.js
@@ -0,0 +1,50 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Provides the Marker class definition.
+ */
+angular.module('element').factory('Marker', [function defineMarker() {
+
+    /**
+     * Creates a new Marker which allows its associated element to be scolled
+     * into view as desired.
+     *
+     * @constructor
+     * @param {Element} element
+     *     The element to associate with this marker.
+     */
+    var Marker = function Marker(element) {
+
+        /**
+         * Scrolls scrollable elements, or the window, as needed to bring the
+         * element associated with this marker into view.
+         */
+        this.scrollIntoView = function scrollIntoView() {
+            element.scrollIntoView();
+        };
+
+    };
+
+    return Marker;
+
+}]);
diff --git a/guacamole/src/main/webapp/app/element/types/ScrollState.js b/guacamole/src/main/webapp/app/element/types/ScrollState.js
new file mode 100644
index 0000000..23d6b33
--- /dev/null
+++ b/guacamole/src/main/webapp/app/element/types/ScrollState.js
@@ -0,0 +1,63 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Provides the ScrollState class definition.
+ */
+angular.module('element').factory('ScrollState', [function defineScrollState() {
+
+    /**
+     * Creates a new ScrollState, representing the current scroll position of
+     * an arbitrary element. This constructor initializes the properties of the
+     * new ScrollState with the corresponding properties of the given template.
+     *
+     * @constructor
+     * @param {ScrollState|Object} [template={}]
+     *     The object whose properties should be copied within the new
+     *     ScrollState.
+     */
+    var ScrollState = function ScrollState(template) {
+
+        // Use empty object by default
+        template = template || {};
+
+        /**
+         * The left edge of the view rectangle within the scrollable area. This
+         * value naturally increases as the user scrolls right.
+         *
+         * @type Number
+         */
+        this.left = template.left || 0;
+
+        /**
+         * The top edge of the view rectangle within the scrollable area. This
+         * value naturally increases as the user scrolls down.
+         *
+         * @type Number
+         */
+        this.top = template.top || 0;
+
+    };
+
+    return ScrollState;
+
+}]);
diff --git a/guacamole/src/main/webapp/app/form/controllers/checkboxFieldController.js b/guacamole/src/main/webapp/app/form/controllers/checkboxFieldController.js
new file mode 100644
index 0000000..3f9da95
--- /dev/null
+++ b/guacamole/src/main/webapp/app/form/controllers/checkboxFieldController.js
@@ -0,0 +1,40 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+
+/**
+ * Controller for checkbox fields.
+ */
+angular.module('form').controller('checkboxFieldController', ['$scope',
+    function checkboxFieldController($scope) {
+
+    // Update typed value when model is changed
+    $scope.$watch('model', function modelChanged(model) {
+        $scope.typedValue = (model === $scope.field.options[0]);
+    });
+
+    // Update string value in model when typed value is changed
+    $scope.$watch('typedValue', function typedValueChanged(typedValue) {
+        $scope.model = (typedValue ? $scope.field.options[0] : '');
+    });
+
+}]);
diff --git a/guacamole/src/main/webapp/app/form/controllers/dateFieldController.js b/guacamole/src/main/webapp/app/form/controllers/dateFieldController.js
new file mode 100644
index 0000000..26b663f
--- /dev/null
+++ b/guacamole/src/main/webapp/app/form/controllers/dateFieldController.js
@@ -0,0 +1,92 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+
+/**
+ * Controller for date fields.
+ */
+angular.module('form').controller('dateFieldController', ['$scope', '$injector',
+    function dateFieldController($scope, $injector) {
+
+    // Required services
+    var $filter = $injector.get('$filter');
+
+    /**
+     * Options which dictate the behavior of the input field model, as defined
+     * by https://docs.angularjs.org/api/ng/directive/ngModelOptions
+     *
+     * @type Object.<String, String>
+     */
+    $scope.modelOptions = {
+
+        /**
+         * Space-delimited list of events on which the model will be updated.
+         *
+         * @type String
+         */
+        updateOn : 'blur',
+
+        /**
+         * The time zone to use when reading/writing the Date object of the
+         * model.
+         *
+         * @type String
+         */
+        timezone : 'UTC'
+
+    };
+
+    /**
+     * Parses the date components of the given string into a Date with only the
+     * date components set. The resulting Date will be in the UTC timezone,
+     * with the time left as midnight. The input string must be in the format
+     * YYYY-MM-DD (zero-padded).
+     *
+     * @param {String} str
+     *     The date string to parse.
+     *
+     * @returns {Date}
+     *     A Date object, in the UTC timezone, with only the date components
+     *     set.
+     */
+    var parseDate = function parseDate(str) {
+
+        // Parse date, return blank if invalid
+        var parsedDate = new Date(str + 'T00:00Z');
+        if (isNaN(parsedDate.getTime()))
+            return null;
+
+        return parsedDate;
+
+    };
+
+    // Update typed value when model is changed
+    $scope.$watch('model', function modelChanged(model) {
+        $scope.typedValue = (model ? parseDate(model) : null);
+    });
+
+    // Update string value in model when typed value is changed
+    $scope.$watch('typedValue', function typedValueChanged(typedValue) {
+        $scope.model = (typedValue ? $filter('date')(typedValue, 'yyyy-MM-dd', 'UTC') : '');
+    });
+
+}]);
diff --git a/guacamole/src/main/webapp/app/form/controllers/numberFieldController.js b/guacamole/src/main/webapp/app/form/controllers/numberFieldController.js
new file mode 100644
index 0000000..640384f
--- /dev/null
+++ b/guacamole/src/main/webapp/app/form/controllers/numberFieldController.js
@@ -0,0 +1,40 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+
+/**
+ * Controller for number fields.
+ */
+angular.module('form').controller('numberFieldController', ['$scope',
+    function numberFieldController($scope) {
+
+    // Update typed value when model is changed
+    $scope.$watch('model', function modelChanged(model) {
+        $scope.typedValue = (model ? Number(model) : null);
+    });
+
+    // Update string value in model when typed value is changed
+    $scope.$watch('typedValue', function typedValueChanged(typedValue) {
+        $scope.model = ((typedValue || typedValue === 0) ? typedValue.toString() : '');
+    });
+
+}]);
diff --git a/guacamole/src/main/webapp/app/form/controllers/passwordFieldController.js b/guacamole/src/main/webapp/app/form/controllers/passwordFieldController.js
new file mode 100644
index 0000000..4b726f1
--- /dev/null
+++ b/guacamole/src/main/webapp/app/form/controllers/passwordFieldController.js
@@ -0,0 +1,75 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+
+/**
+ * Controller for password fields.
+ */
+angular.module('form').controller('passwordFieldController', ['$scope',
+    function passwordFieldController($scope) {
+
+    /**
+     * The type to use for the input field. By default, the input field will
+     * have the type 'password', and thus will be masked.
+     *
+     * @type String
+     * @default 'password'
+     */
+    $scope.passwordInputType = 'password';
+
+    /**
+     * Returns a string which describes the action the next call to
+     * togglePassword() will have.
+     *
+     * @return {String}
+     *     A string which describes the action the next call to
+     *     togglePassword() will have.
+     */
+    $scope.getTogglePasswordHelpText = function getTogglePasswordHelpText() {
+
+        // If password is hidden, togglePassword() will show the password
+        if ($scope.passwordInputType === 'password')
+            return 'FORM.HELP_SHOW_PASSWORD';
+
+        // If password is shown, togglePassword() will hide the password
+        return 'FORM.HELP_HIDE_PASSWORD';
+
+    };
+
+    /**
+     * Toggles visibility of the field contents, if this field is a
+     * password field. Initially, password contents are masked
+     * (invisible).
+     */
+    $scope.togglePassword = function togglePassword() {
+
+        // If password is hidden, show the password
+        if ($scope.passwordInputType === 'password')
+            $scope.passwordInputType = 'text';
+
+        // If password is shown, hide the password
+        else
+            $scope.passwordInputType = 'password';
+
+    };
+
+}]);
diff --git a/guacamole/src/main/webapp/app/form/controllers/selectFieldController.js b/guacamole/src/main/webapp/app/form/controllers/selectFieldController.js
new file mode 100644
index 0000000..f3af8d8
--- /dev/null
+++ b/guacamole/src/main/webapp/app/form/controllers/selectFieldController.js
@@ -0,0 +1,70 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+
+/**
+ * Controller for select fields.
+ */
+angular.module('form').controller('selectFieldController', ['$scope', '$injector',
+    function selectFieldController($scope, $injector) {
+
+    // Required services
+    var translationStringService = $injector.get('translationStringService');
+
+    // Interpret undefined/null as empty string
+    $scope.$watch('model', function setModel(model) {
+        if (!model && model !== '')
+            $scope.model = '';
+    });
+
+    /**
+     * Produces the translation string for the given field option
+     * value. The translation string will be of the form:
+     *
+     * <code>NAMESPACE.FIELD_OPTION_NAME_VALUE<code>
+     *
+     * where <code>NAMESPACE</code> is the namespace provided to the
+     * directive, <code>NAME</code> is the field name transformed
+     * via translationStringService.canonicalize(), and
+     * <code>VALUE</code> is the option value transformed via
+     * translationStringService.canonicalize()
+     *
+     * @param {String} value
+     *     The name of the option value.
+     *
+     * @returns {String}
+     *     The translation string which produces the translated name of the
+     *     value specified.
+     */
+    $scope.getFieldOption = function getFieldOption(value) {
+
+        // If no field, or no value, then no corresponding translation string
+        if (!$scope.field || !$scope.field.name || !value)
+            return '';
+
+        return translationStringService.canonicalize($scope.namespace || 'MISSING_NAMESPACE')
+                + '.FIELD_OPTION_' + translationStringService.canonicalize($scope.field.name)
+                + '_'              + translationStringService.canonicalize(value || 'EMPTY');
+
+    };
+
+}]);
diff --git a/guacamole/src/main/webapp/app/form/controllers/timeFieldController.js b/guacamole/src/main/webapp/app/form/controllers/timeFieldController.js
new file mode 100644
index 0000000..58f0775
--- /dev/null
+++ b/guacamole/src/main/webapp/app/form/controllers/timeFieldController.js
@@ -0,0 +1,92 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+
+/**
+ * Controller for time fields.
+ */
+angular.module('form').controller('timeFieldController', ['$scope', '$injector',
+    function timeFieldController($scope, $injector) {
+
+    // Required services
+    var $filter = $injector.get('$filter');
+
+    /**
+     * Options which dictate the behavior of the input field model, as defined
+     * by https://docs.angularjs.org/api/ng/directive/ngModelOptions
+     *
+     * @type Object.<String, String>
+     */
+    $scope.modelOptions = {
+
+        /**
+         * Space-delimited list of events on which the model will be updated.
+         *
+         * @type String
+         */
+        updateOn : 'blur',
+
+        /**
+         * The time zone to use when reading/writing the Date object of the
+         * model.
+         *
+         * @type String
+         */
+        timezone : 'UTC'
+
+    };
+
+    /**
+     * Parses the time components of the given string into a Date with only the
+     * time components set. The resulting Date will be in the UTC timezone,
+     * with the date left as 1970-01-01. The input string must be in the format
+     * HH:MM:SS (zero-padded, 24-hour).
+     *
+     * @param {String} str
+     *     The time string to parse.
+     *
+     * @returns {Date}
+     *     A Date object, in the UTC timezone, with only the time components
+     *     set.
+     */
+    var parseTime = function parseTime(str) {
+
+        // Parse time, return blank if invalid
+        var parsedDate = new Date('1970-01-01T' + str + 'Z');
+        if (isNaN(parsedDate.getTime()))
+            return null;
+        
+        return parsedDate;
+
+    };
+
+    // Update typed value when model is changed
+    $scope.$watch('model', function modelChanged(model) {
+        $scope.typedValue = (model ? parseTime(model) : null);
+    });
+
+    // Update string value in model when typed value is changed
+    $scope.$watch('typedValue', function typedValueChanged(typedValue) {
+        $scope.model = (typedValue ? $filter('date')(typedValue, 'HH:mm:ss', 'UTC') : '');
+    });
+
+}]);
diff --git a/guacamole/src/main/webapp/app/form/controllers/timeZoneFieldController.js b/guacamole/src/main/webapp/app/form/controllers/timeZoneFieldController.js
new file mode 100644
index 0000000..0410567
--- /dev/null
+++ b/guacamole/src/main/webapp/app/form/controllers/timeZoneFieldController.js
@@ -0,0 +1,711 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+
+/**
+ * Controller for time zone fields. Time zone fields use Java IDs as the
+ * standard representation for each supported time zone.
+ */
+angular.module('form').controller('timeZoneFieldController', ['$scope', '$injector',
+    function timeZoneFieldController($scope, $injector) {
+
+    /**
+     * Map of time zone regions to the map of all time zone name/ID pairs
+     * within those regions.
+     *
+     * @type Object.<String, Object.<String, String>>
+     */
+    $scope.timeZones = {
+
+        "Africa" : {
+            "Abidjan"       : "Africa/Abidjan",
+            "Accra"         : "Africa/Accra",
+            "Addis Ababa"   : "Africa/Addis_Ababa",
+            "Algiers"       : "Africa/Algiers",
+            "Asmara"        : "Africa/Asmara",
+            "Asmera"        : "Africa/Asmera",
+            "Bamako"        : "Africa/Bamako",
+            "Bangui"        : "Africa/Bangui",
+            "Banjul"        : "Africa/Banjul",
+            "Bissau"        : "Africa/Bissau",
+            "Blantyre"      : "Africa/Blantyre",
+            "Brazzaville"   : "Africa/Brazzaville",
+            "Bujumbura"     : "Africa/Bujumbura",
+            "Cairo"         : "Africa/Cairo",
+            "Casablanca"    : "Africa/Casablanca",
+            "Ceuta"         : "Africa/Ceuta",
+            "Conakry"       : "Africa/Conakry",
+            "Dakar"         : "Africa/Dakar",
+            "Dar es Salaam" : "Africa/Dar_es_Salaam",
+            "Djibouti"      : "Africa/Djibouti",
+            "Douala"        : "Africa/Douala",
+            "El Aaiun"      : "Africa/El_Aaiun",
+            "Freetown"      : "Africa/Freetown",
+            "Gaborone"      : "Africa/Gaborone",
+            "Harare"        : "Africa/Harare",
+            "Johannesburg"  : "Africa/Johannesburg",
+            "Juba"          : "Africa/Juba",
+            "Kampala"       : "Africa/Kampala",
+            "Khartoum"      : "Africa/Khartoum",
+            "Kigali"        : "Africa/Kigali",
+            "Kinshasa"      : "Africa/Kinshasa",
+            "Lagos"         : "Africa/Lagos",
+            "Libreville"    : "Africa/Libreville",
+            "Lome"          : "Africa/Lome",
+            "Luanda"        : "Africa/Luanda",
+            "Lubumbashi"    : "Africa/Lubumbashi",
+            "Lusaka"        : "Africa/Lusaka",
+            "Malabo"        : "Africa/Malabo",
+            "Maputo"        : "Africa/Maputo",
+            "Maseru"        : "Africa/Maseru",
+            "Mbabane"       : "Africa/Mbabane",
+            "Mogadishu"     : "Africa/Mogadishu",
+            "Monrovia"      : "Africa/Monrovia",
+            "Nairobi"       : "Africa/Nairobi",
+            "Ndjamena"      : "Africa/Ndjamena",
+            "Niamey"        : "Africa/Niamey",
+            "Nouakchott"    : "Africa/Nouakchott",
+            "Ouagadougou"   : "Africa/Ouagadougou",
+            "Porto-Novo"    : "Africa/Porto-Novo",
+            "Sao Tome"      : "Africa/Sao_Tome",
+            "Timbuktu"      : "Africa/Timbuktu",
+            "Tripoli"       : "Africa/Tripoli",
+            "Tunis"         : "Africa/Tunis",
+            "Windhoek"      : "Africa/Windhoek"
+        },
+
+        "America" : {
+            "Adak"                           : "America/Adak",
+            "Anchorage"                      : "America/Anchorage",
+            "Anguilla"                       : "America/Anguilla",
+            "Antigua"                        : "America/Antigua",
+            "Araguaina"                      : "America/Araguaina",
+            "Argentina / Buenos Aires"       : "America/Argentina/Buenos_Aires",
+            "Argentina / Catamarca"          : "America/Argentina/Catamarca",
+            "Argentina / Comodoro Rivadavia" : "America/Argentina/ComodRivadavia",
+            "Argentina / Cordoba"            : "America/Argentina/Cordoba",
+            "Argentina / Jujuy"              : "America/Argentina/Jujuy",
+            "Argentina / La Rioja"           : "America/Argentina/La_Rioja",
+            "Argentina / Mendoza"            : "America/Argentina/Mendoza",
+            "Argentina / Rio Gallegos"       : "America/Argentina/Rio_Gallegos",
+            "Argentina / Salta"              : "America/Argentina/Salta",
+            "Argentina / San Juan"           : "America/Argentina/San_Juan",
+            "Argentina / San Luis"           : "America/Argentina/San_Luis",
+            "Argentina / Tucuman"            : "America/Argentina/Tucuman",
+            "Argentina / Ushuaia"            : "America/Argentina/Ushuaia",
+            "Aruba"                          : "America/Aruba",
+            "Asuncion"                       : "America/Asuncion",
+            "Atikokan"                       : "America/Atikokan",
+            "Atka"                           : "America/Atka",
+            "Bahia"                          : "America/Bahia",
+            "Bahia Banderas"                 : "America/Bahia_Banderas",
+            "Barbados"                       : "America/Barbados",
+            "Belem"                          : "America/Belem",
+            "Belize"                         : "America/Belize",
+            "Blanc-Sablon"                   : "America/Blanc-Sablon",
+            "Boa Vista"                      : "America/Boa_Vista",
+            "Bogota"                         : "America/Bogota",
+            "Boise"                          : "America/Boise",
+            "Buenos Aires"                   : "America/Buenos_Aires",
+            "Cambridge Bay"                  : "America/Cambridge_Bay",
+            "Campo Grande"                   : "America/Campo_Grande",
+            "Cancun"                         : "America/Cancun",
+            "Caracas"                        : "America/Caracas",
+            "Catamarca"                      : "America/Catamarca",
+            "Cayenne"                        : "America/Cayenne",
+            "Cayman"                         : "America/Cayman",
+            "Chicago"                        : "America/Chicago",
+            "Chihuahua"                      : "America/Chihuahua",
+            "Coral Harbour"                  : "America/Coral_Harbour",
+            "Cordoba"                        : "America/Cordoba",
+            "Costa Rica"                     : "America/Costa_Rica",
+            "Creston"                        : "America/Creston",
+            "Cuiaba"                         : "America/Cuiaba",
+            "Curacao"                        : "America/Curacao",
+            "Danmarkshavn"                   : "America/Danmarkshavn",
+            "Dawson"                         : "America/Dawson",
+            "Dawson Creek"                   : "America/Dawson_Creek",
+            "Denver"                         : "America/Denver",
+            "Detroit"                        : "America/Detroit",
+            "Dominica"                       : "America/Dominica",
+            "Edmonton"                       : "America/Edmonton",
+            "Eirunepe"                       : "America/Eirunepe",
+            "El Salvador"                    : "America/El_Salvador",
+            "Ensenada"                       : "America/Ensenada",
+            "Fort Wayne"                     : "America/Fort_Wayne",
+            "Fortaleza"                      : "America/Fortaleza",
+            "Glace Bay"                      : "America/Glace_Bay",
+            "Godthab"                        : "America/Godthab",
+            "Goose Bay"                      : "America/Goose_Bay",
+            "Grand Turk"                     : "America/Grand_Turk",
+            "Grenada"                        : "America/Grenada",
+            "Guadeloupe"                     : "America/Guadeloupe",
+            "Guatemala"                      : "America/Guatemala",
+            "Guayaquil"                      : "America/Guayaquil",
+            "Guyana"                         : "America/Guyana",
+            "Halifax"                        : "America/Halifax",
+            "Havana"                         : "America/Havana",
+            "Hermosillo"                     : "America/Hermosillo",
+            "Indiana / Indianapolis"         : "America/Indiana/Indianapolis",
+            "Indiana / Knox"                 : "America/Indiana/Knox",
+            "Indiana / Marengo"              : "America/Indiana/Marengo",
+            "Indiana / Petersburg"           : "America/Indiana/Petersburg",
+            "Indiana / Tell City"            : "America/Indiana/Tell_City",
+            "Indiana / Vevay"                : "America/Indiana/Vevay",
+            "Indiana / Vincennes"            : "America/Indiana/Vincennes",
+            "Indiana / Winamac"              : "America/Indiana/Winamac",
+            "Indianapolis"                   : "America/Indianapolis",
+            "Inuvik"                         : "America/Inuvik",
+            "Iqaluit"                        : "America/Iqaluit",
+            "Jamaica"                        : "America/Jamaica",
+            "Jujuy"                          : "America/Jujuy",
+            "Juneau"                         : "America/Juneau",
+            "Kentucky / Louisville"          : "America/Kentucky/Louisville",
+            "Kentucky / Monticello"          : "America/Kentucky/Monticello",
+            "Kralendijk"                     : "America/Kralendijk",
+            "La Paz"                         : "America/La_Paz",
+            "Lima"                           : "America/Lima",
+            "Los Angeles"                    : "America/Los_Angeles",
+            "Louisville"                     : "America/Louisville",
+            "Lower Princes"                  : "America/Lower_Princes",
+            "Maceio"                         : "America/Maceio",
+            "Managua"                        : "America/Managua",
+            "Manaus"                         : "America/Manaus",
+            "Marigot"                        : "America/Marigot",
+            "Martinique"                     : "America/Martinique",
+            "Matamoros"                      : "America/Matamoros",
+            "Mazatlan"                       : "America/Mazatlan",
+            "Mendoza"                        : "America/Mendoza",
+            "Menominee"                      : "America/Menominee",
+            "Merida"                         : "America/Merida",
+            "Metlakatla"                     : "America/Metlakatla",
+            "Mexico City"                    : "America/Mexico_City",
+            "Miquelon"                       : "America/Miquelon",
+            "Moncton"                        : "America/Moncton",
+            "Monterrey"                      : "America/Monterrey",
+            "Montevideo"                     : "America/Montevideo",
+            "Montreal"                       : "America/Montreal",
+            "Montserrat"                     : "America/Montserrat",
+            "Nassau"                         : "America/Nassau",
+            "New York"                       : "America/New_York",
+            "Nipigon"                        : "America/Nipigon",
+            "Nome"                           : "America/Nome",
+            "Noronha"                        : "America/Noronha",
+            "North Dakota / Beulah"          : "America/North_Dakota/Beulah",
+            "North Dakota / Center"          : "America/North_Dakota/Center",
+            "North Dakota / New Salem"       : "America/North_Dakota/New_Salem",
+            "Ojinaga"                        : "America/Ojinaga",
+            "Panama"                         : "America/Panama",
+            "Pangnirtung"                    : "America/Pangnirtung",
+            "Paramaribo"                     : "America/Paramaribo",
+            "Phoenix"                        : "America/Phoenix",
+            "Port-au-Prince"                 : "America/Port-au-Prince",
+            "Port of Spain"                  : "America/Port_of_Spain",
+            "Porto Acre"                     : "America/Porto_Acre",
+            "Porto Velho"                    : "America/Porto_Velho",
+            "Puerto Rico"                    : "America/Puerto_Rico",
+            "Rainy River"                    : "America/Rainy_River",
+            "Rankin Inlet"                   : "America/Rankin_Inlet",
+            "Recife"                         : "America/Recife",
+            "Regina"                         : "America/Regina",
+            "Resolute"                       : "America/Resolute",
+            "Rio Branco"                     : "America/Rio_Branco",
+            "Rosario"                        : "America/Rosario",
+            "Santa Isabel"                   : "America/Santa_Isabel",
+            "Santarem"                       : "America/Santarem",
+            "Santiago"                       : "America/Santiago",
+            "Santo Domingo"                  : "America/Santo_Domingo",
+            "Sao Paulo"                      : "America/Sao_Paulo",
+            "Scoresbysund"                   : "America/Scoresbysund",
+            "Shiprock"                       : "America/Shiprock",
+            "Sitka"                          : "America/Sitka",
+            "St. Barthelemy"                 : "America/St_Barthelemy",
+            "St. Johns"                      : "America/St_Johns",
+            "St. Kitts"                      : "America/St_Kitts",
+            "St. Lucia"                      : "America/St_Lucia",
+            "St. Thomas"                     : "America/St_Thomas",
+            "St. Vincent"                    : "America/St_Vincent",
+            "Swift Current"                  : "America/Swift_Current",
+            "Tegucigalpa"                    : "America/Tegucigalpa",
+            "Thule"                          : "America/Thule",
+            "Thunder Bay"                    : "America/Thunder_Bay",
+            "Tijuana"                        : "America/Tijuana",
+            "Toronto"                        : "America/Toronto",
+            "Tortola"                        : "America/Tortola",
+            "Vancouver"                      : "America/Vancouver",
+            "Virgin"                         : "America/Virgin",
+            "Whitehorse"                     : "America/Whitehorse",
+            "Winnipeg"                       : "America/Winnipeg",
+            "Yakutat"                        : "America/Yakutat",
+            "Yellowknife"                    : "America/Yellowknife"
+        },
+
+        "Antarctica" : {
+            "Casey"            : "Antarctica/Casey",
+            "Davis"            : "Antarctica/Davis",
+            "Dumont d'Urville" : "Antarctica/DumontDUrville",
+            "Macquarie"        : "Antarctica/Macquarie",
+            "Mawson"           : "Antarctica/Mawson",
+            "McMurdo"          : "Antarctica/McMurdo",
+            "Palmer"           : "Antarctica/Palmer",
+            "Rothera"          : "Antarctica/Rothera",
+            "South Pole"       : "Antarctica/South_Pole",
+            "Syowa"            : "Antarctica/Syowa",
+            "Troll"            : "Antarctica/Troll",
+            "Vostok"           : "Antarctica/Vostok"
+        },
+
+        "Arctic" : {
+            "Longyearbyen" : "Arctic/Longyearbyen"
+        },
+
+        "Asia" : {
+            "Aden"          : "Asia/Aden",
+            "Almaty"        : "Asia/Almaty",
+            "Amman"         : "Asia/Amman",
+            "Anadyr"        : "Asia/Anadyr",
+            "Aqtau"         : "Asia/Aqtau",
+            "Aqtobe"        : "Asia/Aqtobe",
+            "Ashgabat"      : "Asia/Ashgabat",
+            "Ashkhabad"     : "Asia/Ashkhabad",
+            "Baghdad"       : "Asia/Baghdad",
+            "Bahrain"       : "Asia/Bahrain",
+            "Baku"          : "Asia/Baku",
+            "Bangkok"       : "Asia/Bangkok",
+            "Beirut"        : "Asia/Beirut",
+            "Bishkek"       : "Asia/Bishkek",
+            "Brunei"        : "Asia/Brunei",
+            "Calcutta"      : "Asia/Calcutta",
+            "Chita"         : "Asia/Chita",
+            "Choibalsan"    : "Asia/Choibalsan",
+            "Chongqing"     : "Asia/Chongqing",
+            "Colombo"       : "Asia/Colombo",
+            "Dacca"         : "Asia/Dacca",
+            "Damascus"      : "Asia/Damascus",
+            "Dhaka"         : "Asia/Dhaka",
+            "Dili"          : "Asia/Dili",
+            "Dubai"         : "Asia/Dubai",
+            "Dushanbe"      : "Asia/Dushanbe",
+            "Gaza"          : "Asia/Gaza",
+            "Harbin"        : "Asia/Harbin",
+            "Hebron"        : "Asia/Hebron",
+            "Ho Chi Minh"   : "Asia/Ho_Chi_Minh",
+            "Hong Kong"     : "Asia/Hong_Kong",
+            "Hovd"          : "Asia/Hovd",
+            "Irkutsk"       : "Asia/Irkutsk",
+            "Istanbul"      : "Asia/Istanbul",
+            "Jakarta"       : "Asia/Jakarta",
+            "Jayapura"      : "Asia/Jayapura",
+            "Jerusalem"     : "Asia/Jerusalem",
+            "Kabul"         : "Asia/Kabul",
+            "Kamchatka"     : "Asia/Kamchatka",
+            "Karachi"       : "Asia/Karachi",
+            "Kashgar"       : "Asia/Kashgar",
+            "Kathmandu"     : "Asia/Kathmandu",
+            "Katmandu"      : "Asia/Katmandu",
+            "Khandyga"      : "Asia/Khandyga",
+            "Kolkata"       : "Asia/Kolkata",
+            "Krasnoyarsk"   : "Asia/Krasnoyarsk",
+            "Kuala Lumpur"  : "Asia/Kuala_Lumpur",
+            "Kuching"       : "Asia/Kuching",
+            "Kuwait"        : "Asia/Kuwait",
+            "Macao"         : "Asia/Macao",
+            "Macau"         : "Asia/Macau",
+            "Magadan"       : "Asia/Magadan",
+            "Makassar"      : "Asia/Makassar",
+            "Manila"        : "Asia/Manila",
+            "Muscat"        : "Asia/Muscat",
+            "Nicosia"       : "Asia/Nicosia",
+            "Novokuznetsk"  : "Asia/Novokuznetsk",
+            "Novosibirsk"   : "Asia/Novosibirsk",
+            "Omsk"          : "Asia/Omsk",
+            "Oral"          : "Asia/Oral",
+            "Phnom Penh"    : "Asia/Phnom_Penh",
+            "Pontianak"     : "Asia/Pontianak",
+            "Pyongyang"     : "Asia/Pyongyang",
+            "Qatar"         : "Asia/Qatar",
+            "Qyzylorda"     : "Asia/Qyzylorda",
+            "Rangoon"       : "Asia/Rangoon",
+            "Riyadh"        : "Asia/Riyadh",
+            "Saigon"        : "Asia/Saigon",
+            "Sakhalin"      : "Asia/Sakhalin",
+            "Samarkand"     : "Asia/Samarkand",
+            "Seoul"         : "Asia/Seoul",
+            "Shanghai"      : "Asia/Shanghai",
+            "Singapore"     : "Asia/Singapore",
+            "Srednekolymsk" : "Asia/Srednekolymsk",
+            "Taipei"        : "Asia/Taipei",
+            "Tashkent"      : "Asia/Tashkent",
+            "Tbilisi"       : "Asia/Tbilisi",
+            "Tehran"        : "Asia/Tehran",
+            "Tel Aviv"      : "Asia/Tel_Aviv",
+            "Thimbu"        : "Asia/Thimbu",
+            "Thimphu"       : "Asia/Thimphu",
+            "Tokyo"         : "Asia/Tokyo",
+            "Ujung Pandang" : "Asia/Ujung_Pandang",
+            "Ulaanbaatar"   : "Asia/Ulaanbaatar",
+            "Ulan Bator"    : "Asia/Ulan_Bator",
+            "Urumqi"        : "Asia/Urumqi",
+            "Ust-Nera"      : "Asia/Ust-Nera",
+            "Vientiane"     : "Asia/Vientiane",
+            "Vladivostok"   : "Asia/Vladivostok",
+            "Yakutsk"       : "Asia/Yakutsk",
+            "Yekaterinburg" : "Asia/Yekaterinburg",
+            "Yerevan"       : "Asia/Yerevan"
+        },
+
+        "Atlantic" : {
+            "Azores"        : "Atlantic/Azores",
+            "Bermuda"       : "Atlantic/Bermuda",
+            "Canary"        : "Atlantic/Canary",
+            "Cape Verde"    : "Atlantic/Cape_Verde",
+            "Faeroe"        : "Atlantic/Faeroe",
+            "Faroe"         : "Atlantic/Faroe",
+            "Jan Mayen"     : "Atlantic/Jan_Mayen",
+            "Madeira"       : "Atlantic/Madeira",
+            "Reykjavik"     : "Atlantic/Reykjavik",
+            "South Georgia" : "Atlantic/South_Georgia",
+            "St. Helena"    : "Atlantic/St_Helena",
+            "Stanley"       : "Atlantic/Stanley"
+        },
+
+        "Australia" : {
+            "Adelaide"    : "Australia/Adelaide",
+            "Brisbane"    : "Australia/Brisbane",
+            "Broken Hill" : "Australia/Broken_Hill",
+            "Canberra"    : "Australia/Canberra",
+            "Currie"      : "Australia/Currie",
+            "Darwin"      : "Australia/Darwin",
+            "Eucla"       : "Australia/Eucla",
+            "Hobart"      : "Australia/Hobart",
+            "Lindeman"    : "Australia/Lindeman",
+            "Lord Howe"   : "Australia/Lord_Howe",
+            "Melbourne"   : "Australia/Melbourne",
+            "North"       : "Australia/North",
+            "Perth"       : "Australia/Perth",
+            "Queensland"  : "Australia/Queensland",
+            "South"       : "Australia/South",
+            "Sydney"      : "Australia/Sydney",
+            "Tasmania"    : "Australia/Tasmania",
+            "Victoria"    : "Australia/Victoria",
+            "West"        : "Australia/West",
+            "Yancowinna"  : "Australia/Yancowinna"
+        },
+
+        "Brazil" : {
+            "Acre"                : "Brazil/Acre",
+            "Fernando de Noronha" : "Brazil/DeNoronha",
+            "East"                : "Brazil/East",
+            "West"                : "Brazil/West"
+        },
+
+        "Canada" : {
+            "Atlantic"          : "Canada/Atlantic",
+            "Central"           : "Canada/Central",
+            "East-Saskatchewan" : "Canada/East-Saskatchewan",
+            "Eastern"           : "Canada/Eastern",
+            "Mountain"          : "Canada/Mountain",
+            "Newfoundland"      : "Canada/Newfoundland",
+            "Pacific"           : "Canada/Pacific",
+            "Saskatchewan"      : "Canada/Saskatchewan",
+            "Yukon"             : "Canada/Yukon"
+        },
+
+        "Chile" : {
+            "Continental"   : "Chile/Continental",
+            "Easter Island" : "Chile/EasterIsland"
+        },
+
+        "Europe" : {
+            "Amsterdam"   : "Europe/Amsterdam",
+            "Andorra"     : "Europe/Andorra",
+            "Athens"      : "Europe/Athens",
+            "Belfast"     : "Europe/Belfast",
+            "Belgrade"    : "Europe/Belgrade",
+            "Berlin"      : "Europe/Berlin",
+            "Bratislava"  : "Europe/Bratislava",
+            "Brussels"    : "Europe/Brussels",
+            "Bucharest"   : "Europe/Bucharest",
+            "Budapest"    : "Europe/Budapest",
+            "Busingen"    : "Europe/Busingen",
+            "Chisinau"    : "Europe/Chisinau",
+            "Copenhagen"  : "Europe/Copenhagen",
+            "Dublin"      : "Europe/Dublin",
+            "Gibraltar"   : "Europe/Gibraltar",
+            "Guernsey"    : "Europe/Guernsey",
+            "Helsinki"    : "Europe/Helsinki",
+            "Isle of Man" : "Europe/Isle_of_Man",
+            "Istanbul"    : "Europe/Istanbul",
+            "Jersey"      : "Europe/Jersey",
+            "Kaliningrad" : "Europe/Kaliningrad",
+            "Kiev"        : "Europe/Kiev",
+            "Lisbon"      : "Europe/Lisbon",
+            "Ljubljana"   : "Europe/Ljubljana",
+            "London"      : "Europe/London",
+            "Luxembourg"  : "Europe/Luxembourg",
+            "Madrid"      : "Europe/Madrid",
+            "Malta"       : "Europe/Malta",
+            "Mariehamn"   : "Europe/Mariehamn",
+            "Minsk"       : "Europe/Minsk",
+            "Monaco"      : "Europe/Monaco",
+            "Moscow"      : "Europe/Moscow",
+            "Nicosia"     : "Europe/Nicosia",
+            "Oslo"        : "Europe/Oslo",
+            "Paris"       : "Europe/Paris",
+            "Podgorica"   : "Europe/Podgorica",
+            "Prague"      : "Europe/Prague",
+            "Riga"        : "Europe/Riga",
+            "Rome"        : "Europe/Rome",
+            "Samara"      : "Europe/Samara",
+            "San Marino"  : "Europe/San_Marino",
+            "Sarajevo"    : "Europe/Sarajevo",
+            "Simferopol"  : "Europe/Simferopol",
+            "Skopje"      : "Europe/Skopje",
+            "Sofia"       : "Europe/Sofia",
+            "Stockholm"   : "Europe/Stockholm",
+            "Tallinn"     : "Europe/Tallinn",
+            "Tirane"      : "Europe/Tirane",
+            "Tiraspol"    : "Europe/Tiraspol",
+            "Uzhgorod"    : "Europe/Uzhgorod",
+            "Vaduz"       : "Europe/Vaduz",
+            "Vatican"     : "Europe/Vatican",
+            "Vienna"      : "Europe/Vienna",
+            "Vilnius"     : "Europe/Vilnius",
+            "Volgograd"   : "Europe/Volgograd",
+            "Warsaw"      : "Europe/Warsaw",
+            "Zagreb"      : "Europe/Zagreb",
+            "Zaporozhye"  : "Europe/Zaporozhye",
+            "Zurich"      : "Europe/Zurich"
+        },
+
+        "GMT" : {
+            "GMT-14" : "Etc/GMT-14",
+            "GMT-13" : "Etc/GMT-13",
+            "GMT-12" : "Etc/GMT-12",
+            "GMT-11" : "Etc/GMT-11",
+            "GMT-10" : "Etc/GMT-10",
+            "GMT-9"  : "Etc/GMT-9",
+            "GMT-8"  : "Etc/GMT-8",
+            "GMT-7"  : "Etc/GMT-7",
+            "GMT-6"  : "Etc/GMT-6",
+            "GMT-5"  : "Etc/GMT-5",
+            "GMT-4"  : "Etc/GMT-4",
+            "GMT-3"  : "Etc/GMT-3",
+            "GMT-2"  : "Etc/GMT-2",
+            "GMT-1"  : "Etc/GMT-1",
+            "GMT+0"  : "Etc/GMT+0",
+            "GMT+1"  : "Etc/GMT+1",
+            "GMT+2"  : "Etc/GMT+2",
+            "GMT+3"  : "Etc/GMT+3",
+            "GMT+4"  : "Etc/GMT+4",
+            "GMT+5"  : "Etc/GMT+5",
+            "GMT+6"  : "Etc/GMT+6",
+            "GMT+7"  : "Etc/GMT+7",
+            "GMT+8"  : "Etc/GMT+8",
+            "GMT+9"  : "Etc/GMT+9",
+            "GMT+10" : "Etc/GMT+10",
+            "GMT+11" : "Etc/GMT+11",
+            "GMT+12" : "Etc/GMT+12"
+        },
+
+        "Indian" : {
+            "Antananarivo" : "Indian/Antananarivo",
+            "Chagos"       : "Indian/Chagos",
+            "Christmas"    : "Indian/Christmas",
+            "Cocos"        : "Indian/Cocos",
+            "Comoro"       : "Indian/Comoro",
+            "Kerguelen"    : "Indian/Kerguelen",
+            "Mahe"         : "Indian/Mahe",
+            "Maldives"     : "Indian/Maldives",
+            "Mauritius"    : "Indian/Mauritius",
+            "Mayotte"      : "Indian/Mayotte",
+            "Reunion"      : "Indian/Reunion"
+        },
+
+        "Mexico" : {
+            "Baja Norte" : "Mexico/BajaNorte",
+            "Baja Sur"   : "Mexico/BajaSur",
+            "General"    : "Mexico/General"
+        },
+
+        "Pacific" : {
+            "Apia"         : "Pacific/Apia",
+            "Auckland"     : "Pacific/Auckland",
+            "Bougainville" : "Pacific/Bougainville",
+            "Chatham"      : "Pacific/Chatham",
+            "Chuuk"        : "Pacific/Chuuk",
+            "Easter"       : "Pacific/Easter",
+            "Efate"        : "Pacific/Efate",
+            "Enderbury"    : "Pacific/Enderbury",
+            "Fakaofo"      : "Pacific/Fakaofo",
+            "Fiji"         : "Pacific/Fiji",
+            "Funafuti"     : "Pacific/Funafuti",
+            "Galapagos"    : "Pacific/Galapagos",
+            "Gambier"      : "Pacific/Gambier",
+            "Guadalcanal"  : "Pacific/Guadalcanal",
+            "Guam"         : "Pacific/Guam",
+            "Honolulu"     : "Pacific/Honolulu",
+            "Johnston"     : "Pacific/Johnston",
+            "Kiritimati"   : "Pacific/Kiritimati",
+            "Kosrae"       : "Pacific/Kosrae",
+            "Kwajalein"    : "Pacific/Kwajalein",
+            "Majuro"       : "Pacific/Majuro",
+            "Marquesas"    : "Pacific/Marquesas",
+            "Midway"       : "Pacific/Midway",
+            "Nauru"        : "Pacific/Nauru",
+            "Niue"         : "Pacific/Niue",
+            "Norfolk"      : "Pacific/Norfolk",
+            "Noumea"       : "Pacific/Noumea",
+            "Pago Pago"    : "Pacific/Pago_Pago",
+            "Palau"        : "Pacific/Palau",
+            "Pitcairn"     : "Pacific/Pitcairn",
+            "Pohnpei"      : "Pacific/Pohnpei",
+            "Ponape"       : "Pacific/Ponape",
+            "Port Moresby" : "Pacific/Port_Moresby",
+            "Rarotonga"    : "Pacific/Rarotonga",
+            "Saipan"       : "Pacific/Saipan",
+            "Samoa"        : "Pacific/Samoa",
+            "Tahiti"       : "Pacific/Tahiti",
+            "Tarawa"       : "Pacific/Tarawa",
+            "Tongatapu"    : "Pacific/Tongatapu",
+            "Truk"         : "Pacific/Truk",
+            "Wake"         : "Pacific/Wake",
+            "Wallis"       : "Pacific/Wallis",
+            "Yap"          : "Pacific/Yap"
+        }
+
+    };
+
+    /**
+     * All selectable regions.
+     *
+     * @type String[]
+     */
+    $scope.regions = (function collectRegions() {
+
+        // Start with blank entry
+        var regions = [ '' ];
+
+        // Add each available region
+        for (var region in $scope.timeZones)
+            regions.push(region);
+
+        return regions;
+
+    })();
+
+    /**
+     * Direct mapping of all time zone IDs to the region containing that ID.
+     *
+     * @type Object.<String, String>
+     */
+    var timeZoneRegions = (function mapRegions() {
+
+        var regions = {};
+
+        // For each available region
+        for (var region in $scope.timeZones) {
+
+            // Get time zones within that region
+            var timeZonesInRegion = $scope.timeZones[region];
+
+            // For each of those time zones
+            for (var timeZoneName in timeZonesInRegion) {
+
+                // Get corresponding ID
+                var timeZoneID = timeZonesInRegion[timeZoneName];
+
+                // Store region in map
+                regions[timeZoneID] = region;
+
+            }
+
+        }
+
+        return regions;
+
+    })();
+
+    /**
+     * Map of regions to the currently selected time zone for that region.
+     * Initially, all regions will be set to default selections (the first
+     * time zone, sorted lexicographically).
+     *
+     * @type Object.<String, String>
+     */
+    var selectedTimeZone = (function produceDefaultTimeZones() {
+
+        var defaultTimeZone = {};
+
+        // For each available region
+        for (var region in $scope.timeZones) {
+
+            // Get time zones within that region
+            var timeZonesInRegion = $scope.timeZones[region];
+
+            // No default initially
+            var defaultZoneName = null;
+            var defaultZoneID = null;
+
+            // For each of those time zones
+            for (var timeZoneName in timeZonesInRegion) {
+
+                // Get corresponding ID
+                var timeZoneID = timeZonesInRegion[timeZoneName];
+
+                // Set as default if earlier than existing default
+                if (!defaultZoneName || timeZoneName < defaultZoneName) {
+                    defaultZoneName = timeZoneName;
+                    defaultZoneID = timeZoneID;
+                }
+
+            }
+
+            // Store default zone
+            defaultTimeZone[region] = defaultZoneID;
+
+        }
+
+        return defaultTimeZone;
+
+    })();
+
+    /**
+     * The name of the region currently selected. The selected region narrows
+     * which time zones are selectable.
+     *
+     * @type String
+     */
+    $scope.region = '';
+
+    // Ensure corresponding region is selected
+    $scope.$watch('model', function setModel(model) {
+        $scope.region = timeZoneRegions[model] || '';
+        selectedTimeZone[$scope.region] = model;
+    });
+
+    // Restore time zone selection when region changes
+    $scope.$watch('region', function restoreSelection(region) {
+        $scope.model = selectedTimeZone[region] || null;
+    });
+
+}]);
diff --git a/guacamole/src/main/webapp/app/form/directives/form.js b/guacamole/src/main/webapp/app/form/directives/form.js
new file mode 100644
index 0000000..269b32e
--- /dev/null
+++ b/guacamole/src/main/webapp/app/form/directives/form.js
@@ -0,0 +1,172 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+
+/**
+ * A directive that allows editing of a collection of fields.
+ */
+angular.module('form').directive('guacForm', [function form() {
+
+    return {
+        // Element only
+        restrict: 'E',
+        replace: true,
+        scope: {
+
+            /**
+             * The translation namespace of the translation strings that will
+             * be generated for all fields. This namespace is absolutely
+             * required. If this namespace is omitted, all generated
+             * translation strings will be placed within the MISSING_NAMESPACE
+             * namespace, as a warning.
+             *
+             * @type String
+             */
+            namespace : '=',
+
+            /**
+             * The form content to display. This may be a form, an array of
+             * forms, or a simple array of fields.
+             *
+             * @type Form[]|Form|Field[]|Field
+             */
+            content : '=',
+
+            /**
+             * The object which will receive all field values. Each field value
+             * will be assigned to the property of this object having the same
+             * name.
+             *
+             * @type Object.<String, String>
+             */
+            model : '='
+
+        },
+        templateUrl: 'app/form/templates/form.html',
+        controller: ['$scope', '$injector', function formController($scope, $injector) {
+
+            // Required services
+            var translationStringService = $injector.get('translationStringService');
+
+            /**
+             * The array of all forms to display.
+             *
+             * @type Form[]
+             */
+            $scope.forms = [];
+
+            /**
+             * The object which will receive all field values. Normally, this
+             * will be the object provided within the "model" attribute. If
+             * no such object has been provided, a blank model will be used
+             * instead as a placeholder, such that the fields of this form
+             * will have something to bind to.
+             *
+             * @type Object.<String, String>
+             */
+            $scope.values = {};
+
+            /**
+             * Produces the translation string for the section header of the
+             * given form. The translation string will be of the form:
+             *
+             * <code>NAMESPACE.SECTION_HEADER_NAME<code>
+             *
+             * where <code>NAMESPACE</code> is the namespace provided to the
+             * directive and <code>NAME</code> is the form name transformed
+             * via translationStringService.canonicalize().
+             *
+             * @param {Form} form
+             *     The form for which to produce the translation string.
+             *
+             * @returns {String}
+             *     The translation string which produces the translated header
+             *     of the form.
+             */
+            $scope.getSectionHeader = function getSectionHeader(form) {
+
+                // If no form, or no name, then no header
+                if (!form || !form.name)
+                    return '';
+
+                return translationStringService.canonicalize($scope.namespace || 'MISSING_NAMESPACE')
+                        + '.SECTION_HEADER_' + translationStringService.canonicalize(form.name);
+
+            };
+
+            /**
+             * Determines whether the given object is a form, under the
+             * assumption that the object is either a form or a field.
+             *
+             * @param {Form|Field} obj
+             *     The object to test.
+             *
+             * @returns {Boolean}
+             *     true if the given object appears to be a form, false
+             *     otherwise.
+             */
+            var isForm = function isForm(obj) {
+                return !!('name' in obj && 'fields' in obj);
+            };
+
+            // Produce set of forms from any given content
+            $scope.$watch('content', function setContent(content) {
+
+                // If no content provided, there are no forms
+                if (!content) {
+                    $scope.forms = [];
+                    return;
+                }
+
+                // Ensure content is an array
+                if (!angular.isArray(content))
+                    content = [content];
+
+                // If content is an array of fields, convert to an array of forms
+                if (content.length && !isForm(content[0])) {
+                    content = [{
+                        fields : content
+                    }];
+                }
+
+                // Content is now an array of forms
+                $scope.forms = content;
+
+            });
+
+            // Update string value and re-assign to model when field is changed
+            $scope.$watch('model', function setModel(model) {
+
+                // Assign new model only if provided
+                if (model)
+                    $scope.values = model;
+
+                // Otherwise, use blank model
+                else
+                    $scope.values = {};
+
+            });
+
+        }] // end controller
+    };
+
+}]);
diff --git a/guacamole/src/main/webapp/app/form/directives/formField.js b/guacamole/src/main/webapp/app/form/directives/formField.js
new file mode 100644
index 0000000..a0fa2a7
--- /dev/null
+++ b/guacamole/src/main/webapp/app/form/directives/formField.js
@@ -0,0 +1,121 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+
+/**
+ * A directive that allows editing of a field.
+ */
+angular.module('form').directive('guacFormField', [function formField() {
+    
+    return {
+        // Element only
+        restrict: 'E',
+        replace: true,
+        scope: {
+
+            /**
+             * The translation namespace of the translation strings that will
+             * be generated for this field. This namespace is absolutely
+             * required. If this namespace is omitted, all generated
+             * translation strings will be placed within the MISSING_NAMESPACE
+             * namespace, as a warning.
+             *
+             * @type String
+             */
+            namespace : '=',
+
+            /**
+             * The field to display.
+             *
+             * @type Field
+             */
+            field : '=',
+
+            /**
+             * The property which contains this fields current value. When this
+             * field changes, the property will be updated accordingly.
+             *
+             * @type String
+             */
+            model : '='
+
+        },
+        templateUrl: 'app/form/templates/formField.html',
+        controller: ['$scope', '$injector', '$element', function formFieldController($scope, $injector, $element) {
+
+            // Required services
+            var formService              = $injector.get('formService');
+            var translationStringService = $injector.get('translationStringService');
+
+            /**
+             * The element which should contain any compiled field content. The
+             * actual content of a field is dynamically determined by its type.
+             *
+             * @type Element[]
+             */
+            var fieldContent = $element.find('.form-field');
+
+            /**
+             * Produces the translation string for the header of the current
+             * field. The translation string will be of the form:
+             *
+             * <code>NAMESPACE.FIELD_HEADER_NAME<code>
+             *
+             * where <code>NAMESPACE</code> is the namespace provided to the
+             * directive and <code>NAME</code> is the field name transformed
+             * via translationStringService.canonicalize().
+             *
+             * @returns {String}
+             *     The translation string which produces the translated header
+             *     of the field.
+             */
+            $scope.getFieldHeader = function getFieldHeader() {
+
+                // If no field, or no name, then no header
+                if (!$scope.field || !$scope.field.name)
+                    return '';
+
+                return translationStringService.canonicalize($scope.namespace || 'MISSING_NAMESPACE')
+                        + '.FIELD_HEADER_' + translationStringService.canonicalize($scope.field.name);
+
+            };
+
+            // Update field contents when field definition is changed
+            $scope.$watch('field', function setField(field) {
+
+                // Reset contents
+                fieldContent.innerHTML = '';
+
+                // Append field content
+                if (field) {
+                    formService.createFieldElement(field.type, $scope)
+                    .then(function fieldElementCreated(element) {
+                        fieldContent.append(element);
+                    });
+                }
+
+            });
+
+        }] // end controller
+    };
+    
+}]);
\ No newline at end of file
diff --git a/guacamole/src/main/webapp/app/form/directives/guacLenientDate.js b/guacamole/src/main/webapp/app/form/directives/guacLenientDate.js
new file mode 100644
index 0000000..4bd282f
--- /dev/null
+++ b/guacamole/src/main/webapp/app/form/directives/guacLenientDate.js
@@ -0,0 +1,83 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * A directive which modifies the parsing and formatting of ngModel when used
+ * on an HTML5 date input field, relaxing the otherwise strict parsing and
+ * validation behavior. The behavior of this directive for other input elements
+ * is undefined.
+ */
+angular.module('form').directive('guacLenientDate', ['$injector',
+    function guacLenientDate($injector) {
+
+    // Required services
+    var $filter = $injector.get('$filter');
+
+    /**
+     * Directive configuration object.
+     *
+     * @type Object.<String, Object>
+     */
+    var config = {
+        restrict : 'A',
+        require  : 'ngModel'
+    };
+
+    // Linking function
+    config.link = function linkGuacLenientDate($scope, $element, $attrs, ngModel) {
+
+        // Parse date strings leniently
+        ngModel.$parsers = [function parse(viewValue) {
+
+            // If blank, return null
+            if (!viewValue)
+                return null;
+
+            // Match basic date pattern
+            var match = /([0-9]*)(?:-([0-9]*)(?:-([0-9]*))?)?/.exec(viewValue);
+            if (!match)
+                return null;
+
+            // Determine year, month, and day based on pattern
+            var year  = parseInt(match[1] || '0') || new Date().getFullYear();
+            var month = parseInt(match[2] || '0') || 1;
+            var day   = parseInt(match[3] || '0') || 1;
+
+            // Convert to Date object
+            var parsedDate = new Date(Date.UTC(year, month - 1, day));
+            if (isNaN(parsedDate.getTime()))
+                return null;
+
+            return parsedDate;
+
+        }];
+
+        // Format date strings as "yyyy-MM-dd"
+        ngModel.$formatters = [function format(modelValue) {
+            return modelValue ? $filter('date')(modelValue, 'yyyy-MM-dd', 'UTC') : '';
+        }];
+
+    };
+
+    return config;
+
+}]);
diff --git a/guacamole/src/main/webapp/app/form/directives/guacLenientTime.js b/guacamole/src/main/webapp/app/form/directives/guacLenientTime.js
new file mode 100644
index 0000000..03957b5
--- /dev/null
+++ b/guacamole/src/main/webapp/app/form/directives/guacLenientTime.js
@@ -0,0 +1,103 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * A directive which modifies the parsing and formatting of ngModel when used
+ * on an HTML5 time input field, relaxing the otherwise strict parsing and
+ * validation behavior. The behavior of this directive for other input elements
+ * is undefined.
+ */
+angular.module('form').directive('guacLenientTime', ['$injector',
+    function guacLenientTime($injector) {
+
+    // Required services
+    var $filter = $injector.get('$filter');
+
+    /**
+     * Directive configuration object.
+     *
+     * @type Object.<String, Object>
+     */
+    var config = {
+        restrict : 'A',
+        require  : 'ngModel'
+    };
+
+    // Linking function
+    config.link = function linkGuacLenientTIme($scope, $element, $attrs, ngModel) {
+
+        // Parse time strings leniently
+        ngModel.$parsers = [function parse(viewValue) {
+
+            // If blank, return null
+            if (!viewValue)
+                return null;
+
+            // Match basic time pattern
+            var match = /([0-9]*)(?::([0-9]*)(?::([0-9]*))?)?(?:\s*(a|p))?/.exec(viewValue.toLowerCase());
+            if (!match)
+                return null;
+
+            // Determine hour, minute, and second based on pattern
+            var hour   = parseInt(match[1] || '0');
+            var minute = parseInt(match[2] || '0');
+            var second = parseInt(match[3] || '0');
+
+            // Handle AM/PM
+            if (match[4]) {
+
+                // Interpret 12 AM as 00:00 and 12 PM as 12:00
+                if (hour === 12)
+                    hour = 0;
+
+                // Increment hour to evening if PM
+                if (match[4] === 'p')
+                    hour += 12;
+
+            }
+
+            // Wrap seconds and minutes into minutes and hours
+            minute += second / 60; second %= 60;
+            hour   += minute / 60; minute %= 60;
+
+            // Constrain hours to 0 - 23
+            hour %= 24;
+
+            // Convert to Date object
+            var parsedDate = new Date(Date.UTC(1970, 0, 1, hour, minute, second));
+            if (isNaN(parsedDate.getTime()))
+                return null;
+
+            return parsedDate;
+
+        }];
+
+        // Format time strings as "HH:mm:ss"
+        ngModel.$formatters = [function format(modelValue) {
+            return modelValue ? $filter('date')(modelValue, 'HH:mm:ss', 'UTC') : '';
+        }];
+
+    };
+
+    return config;
+
+}]);
diff --git a/guacamole/src/main/webapp/app/form/formModule.js b/guacamole/src/main/webapp/app/form/formModule.js
new file mode 100644
index 0000000..a3e1f4a
--- /dev/null
+++ b/guacamole/src/main/webapp/app/form/formModule.js
@@ -0,0 +1,26 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Module for displaying dynamic forms.
+ */
+angular.module('form', ['locale']);
diff --git a/guacamole/src/main/webapp/app/form/services/formService.js b/guacamole/src/main/webapp/app/form/services/formService.js
new file mode 100644
index 0000000..1f47219
--- /dev/null
+++ b/guacamole/src/main/webapp/app/form/services/formService.js
@@ -0,0 +1,265 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * A service for maintaining form-related metadata and linking that data to
+ * corresponding controllers and templates.
+ */
+angular.module('form').provider('formService', function formServiceProvider() {
+
+    /**
+     * Reference to the provider itself.
+     *
+     * @type formServiceProvider
+     */
+    var provider = this;
+
+    /**
+     * Map of all registered field type definitions by name.
+     *
+     * @type Object.<String, FieldType>
+     */
+    this.fieldTypes = {
+
+        /**
+         * Text field type.
+         *
+         * @see {@link Field.Type.TEXT}
+         * @type FieldType
+         */
+        'TEXT' : {
+            templateUrl : 'app/form/templates/textField.html'
+        },
+
+        /**
+         * Numeric field type.
+         *
+         * @see {@link Field.Type.NUMERIC}
+         * @type FieldType
+         */
+        'NUMERIC' : {
+            module      : 'form',
+            controller  : 'numberFieldController',
+            templateUrl : 'app/form/templates/numberField.html'
+        },
+
+        /**
+         * Boolean field type.
+         *
+         * @see {@link Field.Type.BOOLEAN}
+         * @type FieldType
+         */
+        'BOOLEAN' : {
+            module      : 'form',
+            controller  : 'checkboxFieldController',
+            templateUrl : 'app/form/templates/checkboxField.html'
+        },
+
+        /**
+         * Username field type. Identical in principle to a text field, but may
+         * have different semantics.
+         *
+         * @see {@link Field.Type.USERNAME}
+         * @type FieldType
+         */
+        'USERNAME' : {
+            templateUrl : 'app/form/templates/textField.html'
+        },
+
+        /**
+         * Password field type. Similar to a text field, but the contents of
+         * the field are masked.
+         *
+         * @see {@link Field.Type.PASSWORD}
+         * @type FieldType
+         */
+        'PASSWORD' : {
+            module      : 'form',
+            controller  : 'passwordFieldController',
+            templateUrl : 'app/form/templates/passwordField.html'
+        },
+
+        /**
+         * Enumerated field type. The user is presented a finite list of values
+         * to choose from.
+         *
+         * @see {@link Field.Type.ENUM}
+         * @type FieldType
+         */
+        'ENUM' : {
+            module      : 'form',
+            controller  : 'selectFieldController',
+            templateUrl : 'app/form/templates/selectField.html'
+        },
+
+        /**
+         * Multiline field type. The user may enter multiple lines of text.
+         *
+         * @see {@link Field.Type.MULTILINE}
+         * @type FieldType
+         */
+        'MULTILINE' : {
+            templateUrl : 'app/form/templates/textAreaField.html'
+        },
+
+        /**
+         * Field type which allows selection of time zones.
+         *
+         * @see {@link Field.Type.TIMEZONE}
+         * @type FieldType
+         */
+        'TIMEZONE' : {
+            module      : 'form',
+            controller  : 'timeZoneFieldController',
+            templateUrl : 'app/form/templates/timeZoneField.html'
+        },
+
+        /**
+         * Field type which allows selection of individual dates.
+         *
+         * @see {@link Field.Type.DATE}
+         * @type FieldType
+         */
+        'DATE' : {
+            module      : 'form',
+            controller  : 'dateFieldController',
+            templateUrl : 'app/form/templates/dateField.html'
+        },
+
+        /**
+         * Field type which allows selection of times of day.
+         *
+         * @see {@link Field.Type.TIME}
+         * @type FieldType
+         */
+        'TIME' : {
+            module      : 'form',
+            controller  : 'timeFieldController',
+            templateUrl : 'app/form/templates/timeField.html'
+        }
+
+    };
+
+    /**
+     * Registers a new field type under the given name.
+     *
+     * @param {String} fieldTypeName
+     *     The name which uniquely identifies the field type being registered.
+     *
+     * @param {FieldType} fieldType
+     *     The field type definition to associate with the given name.
+     */
+    this.registerFieldType = function registerFieldType(fieldTypeName, fieldType) {
+
+        // Store field type
+        provider.fieldTypes[fieldTypeName] = fieldType;
+
+    };
+
+    // Factory method required by provider
+    this.$get = ['$injector', function formServiceFactory($injector) {
+
+        // Required services
+        var $compile         = $injector.get('$compile');
+        var $q               = $injector.get('$q');
+        var $templateRequest = $injector.get('$templateRequest');
+
+        var service = {};
+
+        service.fieldTypes = provider.fieldTypes;
+
+        /**
+         * Compiles and links the field associated with the given name to the given
+         * scope, producing a distinct and independent DOM Element which functions
+         * as an instance of that field. The scope object provided must include at
+         * least the following properties:
+         *
+         * namespace:
+         *     A String which defines the unique namespace associated the
+         *     translation strings used by the form using a field of this type.
+         *
+         * field:
+         *     The Field object that is being rendered, representing a field of
+         *     this type.
+         *
+         * model:
+         *     The current String value of the field, if any.
+         *
+         * @param {String} fieldTypeName
+         *     The name of the field type defining the nature of the element to be
+         *     created.
+         *
+         * @param {Object} scope
+         *     The scope to which the new element will be linked.
+         *
+         * @return {Promise.<Element>}
+         *     A Promise which resolves to the compiled Element. If an error occurs
+         *     while retrieving the field type, this Promise will be rejected.
+         */
+        service.createFieldElement = function createFieldElement(fieldTypeName, scope) {
+
+            // Ensure field type is defined
+            var fieldType = provider.fieldTypes[fieldTypeName];
+            if (!fieldType)
+                return $q.reject();
+
+            // Populate scope using defined controller
+            if (fieldType.module && fieldType.controller) {
+                var $controller = angular.injector(['ng', fieldType.module]).get('$controller');
+                $controller(fieldType.controller, {'$scope' : scope});
+            }
+
+            // Defer compilation of template pending successful retrieval
+            var compiledTemplate = $q.defer();
+
+            // Use raw HTML template if provided
+            if (fieldType.template)
+                compiledTemplate.resolve($compile(fieldType.template)(scope));
+
+            // If no raw HTML template is provided, retrieve template from URL
+            else {
+
+                // Attempt to retrieve template HTML
+                $templateRequest(fieldType.templateUrl)
+
+                // Resolve with compiled HTML upon success
+                .then(function templateRetrieved(html) {
+                    compiledTemplate.resolve($compile(html)(scope));
+                })
+
+                // Reject on failure
+                ['catch'](function templateError() {
+                    compiledTemplate.reject();
+                });
+
+            }
+
+            // Return promise which resolves to the compiled template
+            return compiledTemplate.promise;
+
+        };
+
+        return service;
+
+    }];
+
+});
diff --git a/guacamole/src/main/webapp/app/form/styles/form-field.css b/guacamole/src/main/webapp/app/form/styles/form-field.css
new file mode 100644
index 0000000..b40dcbf
--- /dev/null
+++ b/guacamole/src/main/webapp/app/form/styles/form-field.css
@@ -0,0 +1,50 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/* Keep toggle-password icon on same line */
+.form-field .password-field {
+    white-space: nowrap;
+}
+
+/* Generic 1x1em icon/button */
+.form-field .password-field .icon.toggle-password {
+
+    display: inline-block;
+    opacity: 0.5;
+    cursor: default;
+
+    background-repeat: no-repeat;
+    background-size: 1em;
+    width: 1em;
+    height: 1em;
+
+}
+
+/* Icon for unmasking passwords */
+.form-field .password-field input[type=password] ~ .icon.toggle-password {
+    background-image: url('images/action-icons/guac-show-pass.png');
+}
+
+/* Icon for masking passwords */
+.form-field .password-field input[type=text] ~ .icon.toggle-password {
+    background-image: url('images/action-icons/guac-hide-pass.png');
+}
diff --git a/guacamole/src/main/webapp/app/form/styles/form.css b/guacamole/src/main/webapp/app/form/styles/form.css
new file mode 100644
index 0000000..0e52280
--- /dev/null
+++ b/guacamole/src/main/webapp/app/form/styles/form.css
@@ -0,0 +1,27 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+.form table.fields th {
+    text-align: left;
+    font-weight: normal;
+    padding-right: 1em;
+}
diff --git a/guacamole/src/main/webapp/app/form/templates/checkboxField.html b/guacamole/src/main/webapp/app/form/templates/checkboxField.html
new file mode 100644
index 0000000..ad9d8e0
--- /dev/null
+++ b/guacamole/src/main/webapp/app/form/templates/checkboxField.html
@@ -0,0 +1 @@
+<input type="checkbox" ng-model="typedValue" autocorrect="off" autocapitalize="off"/>
\ No newline at end of file
diff --git a/guacamole/src/main/webapp/app/form/templates/dateField.html b/guacamole/src/main/webapp/app/form/templates/dateField.html
new file mode 100644
index 0000000..a186e19
--- /dev/null
+++ b/guacamole/src/main/webapp/app/form/templates/dateField.html
@@ -0,0 +1,9 @@
+<div class="date-field">
+    <input type="date"
+           ng-model="typedValue"
+           ng-model-options="modelOptions"
+           guac-lenient-date
+           placeholder="{{'FORM.FIELD_PLACEHOLDER_DATE' | translate}}"
+           autocorrect="off"
+           autocapitalize="off"/>
+</div>
diff --git a/guacamole/src/main/webapp/app/form/templates/form.html b/guacamole/src/main/webapp/app/form/templates/form.html
new file mode 100644
index 0000000..1f9b6d8
--- /dev/null
+++ b/guacamole/src/main/webapp/app/form/templates/form.html
@@ -0,0 +1,35 @@
+<div class="form-group">
+    <div ng-repeat="form in forms" class="form">
+        <!--
+            Copyright 2015 Glyptodon LLC.
+
+            Permission is hereby granted, free of charge, to any person obtaining a copy
+            of this software and associated documentation files (the "Software"), to deal
+            in the Software without restriction, including without limitation the rights
+            to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+            copies of the Software, and to permit persons to whom the Software is
+            furnished to do so, subject to the following conditions:
+
+            The above copyright notice and this permission notice shall be included in
+            all copies or substantial portions of the Software.
+
+            THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+            IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+            FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+            AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+            LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+            OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+            THE SOFTWARE.
+        -->
+
+        <!-- Form name -->
+        <h3 ng-show="form.name">{{getSectionHeader(form) | translate}}</h3>
+
+        <!-- All fields in form -->
+        <div class="fields">
+            <guac-form-field ng-repeat="field in form.fields" namespace="namespace"
+                             field="field" model="values[field.name]"></guac-form-field>
+        </div>
+
+    </div>
+</div>
diff --git a/guacamole/src/main/webapp/app/form/templates/formField.html b/guacamole/src/main/webapp/app/form/templates/formField.html
new file mode 100644
index 0000000..a90eec4
--- /dev/null
+++ b/guacamole/src/main/webapp/app/form/templates/formField.html
@@ -0,0 +1,31 @@
+<label class="labeled-field" ng-class="{empty: !model}">
+    <!--
+        Copyright 2014 Glyptodon LLC.
+
+        Permission is hereby granted, free of charge, to any person obtaining a copy
+        of this software and associated documentation files (the "Software"), to deal
+        in the Software without restriction, including without limitation the rights
+        to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+        copies of the Software, and to permit persons to whom the Software is
+        furnished to do so, subject to the following conditions:
+
+        The above copyright notice and this permission notice shall be included in
+        all copies or substantial portions of the Software.
+
+        THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+        IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+        FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+        AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+        LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+        OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+        THE SOFTWARE.
+    -->
+
+    <!-- Field header -->
+    <span class="field-header">{{getFieldHeader() | translate}}</span>
+
+    <!-- Field content -->
+    <div class="form-field">
+    </div>
+
+</label>
diff --git a/guacamole/src/main/webapp/app/form/templates/numberField.html b/guacamole/src/main/webapp/app/form/templates/numberField.html
new file mode 100644
index 0000000..3d6312e
--- /dev/null
+++ b/guacamole/src/main/webapp/app/form/templates/numberField.html
@@ -0,0 +1 @@
+<input type="number" ng-model="typedValue" autocorrect="off" autocapitalize="off"/>
\ No newline at end of file
diff --git a/guacamole/src/main/webapp/app/form/templates/passwordField.html b/guacamole/src/main/webapp/app/form/templates/passwordField.html
new file mode 100644
index 0000000..506d8b6
--- /dev/null
+++ b/guacamole/src/main/webapp/app/form/templates/passwordField.html
@@ -0,0 +1,4 @@
+<div class="password-field">
+    <input type="{{passwordInputType}}" ng-model="model" ng-trim="false" autocorrect="off" autocapitalize="off"/>
+    <div class="icon toggle-password" ng-click="togglePassword()" title="{{getTogglePasswordHelpText() | translate}}"></div>
+</div>
\ No newline at end of file
diff --git a/guacamole/src/main/webapp/app/form/templates/selectField.html b/guacamole/src/main/webapp/app/form/templates/selectField.html
new file mode 100644
index 0000000..3bd2bb8
--- /dev/null
+++ b/guacamole/src/main/webapp/app/form/templates/selectField.html
@@ -0,0 +1 @@
+<select ng-model="model" ng-options="option as getFieldOption(option) | translate for option in field.options | orderBy: value"></select>
\ No newline at end of file
diff --git a/guacamole/src/main/webapp/app/form/templates/textAreaField.html b/guacamole/src/main/webapp/app/form/templates/textAreaField.html
new file mode 100644
index 0000000..082476f
--- /dev/null
+++ b/guacamole/src/main/webapp/app/form/templates/textAreaField.html
@@ -0,0 +1 @@
+<textarea ng-model="model" autocorrect="off" autocapitalize="off"></textarea>
\ No newline at end of file
diff --git a/guacamole/src/main/webapp/app/form/templates/textField.html b/guacamole/src/main/webapp/app/form/templates/textField.html
new file mode 100644
index 0000000..e213bc2
--- /dev/null
+++ b/guacamole/src/main/webapp/app/form/templates/textField.html
@@ -0,0 +1 @@
+<input type="text" ng-model="model" autocorrect="off" autocapitalize="off"/>
\ No newline at end of file
diff --git a/guacamole/src/main/webapp/app/form/templates/timeField.html b/guacamole/src/main/webapp/app/form/templates/timeField.html
new file mode 100644
index 0000000..24ae968
--- /dev/null
+++ b/guacamole/src/main/webapp/app/form/templates/timeField.html
@@ -0,0 +1,9 @@
+<div class="time-field">
+    <input type="time"
+           ng-model="typedValue"
+           ng-model-options="modelOptions"
+           guac-lenient-time
+           placeholder="{{'FORM.FIELD_PLACEHOLDER_TIME' | translate}}"
+           autocorrect="off"
+           autocapitalize="off"/>
+</div>
diff --git a/guacamole/src/main/webapp/app/form/templates/timeZoneField.html b/guacamole/src/main/webapp/app/form/templates/timeZoneField.html
new file mode 100644
index 0000000..e5db1b8
--- /dev/null
+++ b/guacamole/src/main/webapp/app/form/templates/timeZoneField.html
@@ -0,0 +1,14 @@
+<div class="time-zone-field">
+
+    <!-- Available time zone regions -->
+    <select class="time-zone-region"
+            ng-model="region"
+            ng-options="name for name in regions | orderBy: name"></select>
+
+    <!-- Time zones within selected region -->
+    <select class="time-zone"
+            ng-disabled="!region"
+            ng-model="model"
+            ng-options="name for (name, value) in timeZones[region] | orderBy: name"></select>
+
+</div>
diff --git a/guacamole/src/main/webapp/app/form/types/FieldType.js b/guacamole/src/main/webapp/app/form/types/FieldType.js
new file mode 100644
index 0000000..5e94f61
--- /dev/null
+++ b/guacamole/src/main/webapp/app/form/types/FieldType.js
@@ -0,0 +1,92 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Service which defines the FieldType class.
+ */
+angular.module('form').factory('FieldType', [function defineFieldType() {
+            
+    /**
+     * The object used by the formService for describing field types.
+     * 
+     * @constructor
+     * @param {FieldType|Object} [template={}]
+     *     The object whose properties should be copied within the new
+     *     FieldType.
+     */
+    var FieldType = function FieldType(template) {
+
+        // Use empty object by default
+        template = template || {};
+
+        /**
+         * The raw HTML of the template that should be injected into the DOM of
+         * a form using this field type. If provided, this will be used instead
+         * of templateUrl.
+         *
+         * @type String
+         */
+        this.template = template.template;
+
+        /**
+         * The URL of the template that should be injected into the DOM of a
+         * form using this field type. This property will be ignored if a raw
+         * HTML template is supplied via the template property.
+         *
+         * @type String
+         */
+        this.templateUrl = template.templateUrl;
+
+        /**
+         * The name of the AngularJS module defining the controller for this
+         * field type. This is optional, as not all field types will need
+         * controllers.
+         *
+         * @type String
+         */
+        this.module = template.module;
+
+        /**
+         * The name of the controller for this field type. This is optional, as
+         * not all field types will need controllers. If a controller is
+         * specified, it will receive the following properties on the scope:
+         *
+         * namespace:
+         *     A String which defines the unique namespace associated the
+         *     translation strings used by the form using a field of this type.
+         *
+         * field:
+         *     The Field object that is being rendered, representing a field of
+         *     this type.
+         *
+         * model:
+         *     The current String value of the field, if any.
+         *
+         * @type String
+         */
+        this.controller = template.controller;
+
+    };
+
+    return FieldType;
+
+}]);
\ No newline at end of file
diff --git a/guacamole/src/main/webapp/app/groupList/directives/guacGroupList.js b/guacamole/src/main/webapp/app/groupList/directives/guacGroupList.js
new file mode 100644
index 0000000..2241ce1
--- /dev/null
+++ b/guacamole/src/main/webapp/app/groupList/directives/guacGroupList.js
@@ -0,0 +1,249 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * A directive which displays the contents of a connection group within an
+ * automatically-paginated view.
+ */
+angular.module('groupList').directive('guacGroupList', [function guacGroupList() {
+
+    return {
+        restrict: 'E',
+        replace: true,
+        scope: {
+
+            /**
+             * The connection groups to display as a map of data source
+             * identifier to corresponding root group.
+             *
+             * @type Object.<String, ConnectionGroup>
+             */
+            connectionGroups : '=',
+
+            /**
+             * Arbitrary object which shall be made available to the connection
+             * and connection group templates within the scope as
+             * <code>context</code>.
+             * 
+             * @type Object
+             */
+            context : '=',
+
+            /**
+             * The URL or ID of the Angular template to use when rendering a
+             * connection. The @link{GroupListItem} associated with that
+             * connection will be exposed within the scope of the template
+             * as <code>item</code>, and the arbitrary context object, if any,
+             * will be exposed as <code>context</code>.
+             *
+             * @type String
+             */
+            connectionTemplate : '=',
+
+            /**
+             * The URL or ID of the Angular template to use when rendering a
+             * connection group. The @link{GroupListItem} associated with that
+             * connection group will be exposed within the scope of the
+             * template as <code>item</code>, and the arbitrary context object,
+             * if any, will be exposed as <code>context</code>.
+             *
+             * @type String
+             */
+            connectionGroupTemplate : '=',
+
+            /**
+             * Whether the root of the connection group hierarchy given should
+             * be shown. If false (the default), only the descendants of the
+             * given connection group will be listed.
+             * 
+             * @type Boolean
+             */
+            showRootGroup : '=',
+
+            /**
+             * The maximum number of connections or groups to show per page.
+             *
+             * @type Number
+             */
+            pageSize : '='
+
+        },
+
+        templateUrl: 'app/groupList/templates/guacGroupList.html',
+        controller: ['$scope', '$injector', function guacGroupListController($scope, $injector) {
+
+            // Required services
+            var activeConnectionService = $injector.get('activeConnectionService');
+            var dataSourceService       = $injector.get('dataSourceService');
+
+            // Required types
+            var GroupListItem = $injector.get('GroupListItem');
+
+            /**
+             * Map of data source identifier to the number of active
+             * connections associated with a given connection identifier.
+             * If this information is unknown, or there are no active
+             * connections for a given identifier, no number will be stored.
+             *
+             * @type Object.<String, Object.<String, Number>>
+             */
+            var connectionCount = {};
+
+            /**
+             * A list of all items which should appear at the root level. As
+             * connections and connection groups from multiple data sources may
+             * be included in a guacGroupList, there may be multiple root
+             * items, even if the root connection group is shown.
+             *
+             * @type GroupListItem[]
+             */
+            $scope.rootItems = [];
+
+            /**
+             * Returns the number of active usages of a given connection.
+             *
+             * @param {String} dataSource
+             *     The identifier of the data source containing the given
+             *     connection.
+             *
+             * @param {Connection} connection
+             *     The connection whose active connections should be counted.
+             *
+             * @returns {Number}
+             *     The number of currently-active usages of the given
+             *     connection.
+             */
+            var countActiveConnections = function countActiveConnections(dataSource, connection) {
+                return connectionCount[dataSource][connection.identifier];
+            };
+
+            /**
+             * Returns whether the given item represents a connection that can
+             * be displayed. If there is no connection template, then no
+             * connection is visible.
+             * 
+             * @param {GroupListItem} item
+             *     The item to check.
+             *
+             * @returns {Boolean}
+             *     true if the given item is a connection that can be
+             *     displayed, false otherwise.
+             */
+            $scope.isVisibleConnection = function isVisibleConnection(item) {
+                return item.isConnection && !!$scope.connectionTemplate;
+            };
+
+            /**
+             * Returns whether the given item represents a connection group
+             * that can be displayed. If there is no connection group template,
+             * then no connection group is visible.
+             * 
+             * @param {GroupListItem} item
+             *     The item to check.
+             *
+             * @returns {Boolean}
+             *     true if the given item is a connection group that can be
+             *     displayed, false otherwise.
+             */
+            $scope.isVisibleConnectionGroup = function isVisibleConnectionGroup(item) {
+                return item.isConnectionGroup && !!$scope.connectionGroupTemplate;
+            };
+
+            // Set contents whenever the connection group is assigned or changed
+            $scope.$watch('connectionGroups', function setContents(connectionGroups) {
+
+                // Reset stored data
+                var dataSources = [];
+                $scope.rootItems = [];
+                connectionCount = {};
+
+                // If connection groups are given, add them to the interface
+                if (connectionGroups) {
+
+                    // Add each provided connection group
+                    angular.forEach(connectionGroups, function addConnectionGroup(connectionGroup, dataSource) {
+
+                        // Prepare data source for active connection counting
+                        dataSources.push(dataSource);
+                        connectionCount[dataSource] = {};
+
+                        // Create root item for current connection group
+                        var rootItem = GroupListItem.fromConnectionGroup(dataSource, connectionGroup,
+                            !!$scope.connectionTemplate, countActiveConnections);
+
+                        // If root group is to be shown, add it as a root item
+                        if ($scope.showRootGroup)
+                            $scope.rootItems.push(rootItem);
+
+                        // Otherwise, add its children as root items
+                        else {
+                            angular.forEach(rootItem.children, function addRootItem(child) {
+                                $scope.rootItems.push(child);
+                            });
+                        }
+
+                    });
+
+                    // Count active connections by connection identifier
+                    dataSourceService.apply(
+                        activeConnectionService.getActiveConnections,
+                        dataSources
+                    )
+                    .then(function activeConnectionsRetrieved(activeConnectionMap) {
+
+                        // Within each data source, count each active connection by identifier
+                        angular.forEach(activeConnectionMap, function addActiveConnections(activeConnections, dataSource) {
+                            angular.forEach(activeConnections, function addActiveConnection(activeConnection) {
+
+                                // If counter already exists, increment
+                                var identifier = activeConnection.connectionIdentifier;
+                                if (connectionCount[dataSource][identifier])
+                                    connectionCount[dataSource][identifier]++;
+
+                                // Otherwise, initialize counter to 1
+                                else
+                                    connectionCount[dataSource][identifier] = 1;
+
+                            });
+                        });
+
+                    });
+
+                }
+
+            });
+
+            /**
+             * Toggle the open/closed status of a group list item.
+             * 
+             * @param {GroupListItem} groupListItem
+             *     The list item to expand, which should represent a
+             *     connection group.
+             */
+            $scope.toggleExpanded = function toggleExpanded(groupListItem) {
+                groupListItem.isExpanded = !groupListItem.isExpanded;
+            };
+
+        }]
+
+    };
+}]);
diff --git a/guacamole/src/main/webapp/app/groupList/directives/guacGroupListFilter.js b/guacamole/src/main/webapp/app/groupList/directives/guacGroupListFilter.js
new file mode 100644
index 0000000..156a041
--- /dev/null
+++ b/guacamole/src/main/webapp/app/groupList/directives/guacGroupListFilter.js
@@ -0,0 +1,212 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * A directive which provides a filtering text input field which automatically
+ * produces a filtered subset of the given connection groups.
+ */
+angular.module('groupList').directive('guacGroupListFilter', [function guacGroupListFilter() {
+
+    return {
+        restrict: 'E',
+        replace: true,
+        scope: {
+
+            /**
+             * The property to which a subset of the provided map of connection
+             * groups will be assigned.
+             *
+             * @type Array
+             */
+            filteredConnectionGroups : '=',
+
+            /**
+             * The placeholder text to display within the filter input field
+             * when no filter has been provided.
+             * 
+             * @type String
+             */
+            placeholder : '&',
+
+            /**
+             * The connection groups to filter, as a map of data source
+             * identifier to corresponding root group. A subset of this map
+             * will be exposed as filteredConnectionGroups.
+             *
+             * @type Object.<String, ConnectionGroup>
+             */
+            connectionGroups : '&',
+
+            /**
+             * An array of expressions to filter against for each connection in
+             * the hierarchy of connections and groups in the provided map.
+             * These expressions must be Angular expressions which resolve to
+             * properties on the connections in the provided map.
+             *
+             * @type String[]
+             */
+            connectionProperties : '&',
+
+            /**
+             * An array of expressions to filter against for each connection group
+             * in the hierarchy of connections and groups in the provided map.
+             * These expressions must be Angular expressions which resolve to
+             * properties on the connection groups in the provided map.
+             *
+             * @type String[]
+             */
+            connectionGroupProperties : '&'
+
+        },
+
+        templateUrl: 'app/groupList/templates/guacGroupListFilter.html',
+        controller: ['$scope', '$injector', function guacGroupListFilterController($scope, $injector) {
+
+            // Required types
+            var ConnectionGroup = $injector.get('ConnectionGroup');
+            var FilterPattern   = $injector.get('FilterPattern');
+
+            /**
+             * The pattern object to use when filtering connections.
+             *
+             * @type FilterPattern
+             */
+            var connectionFilterPattern = new FilterPattern($scope.connectionProperties());
+
+            /**
+             * The pattern object to use when filtering connection groups.
+             *
+             * @type FilterPattern
+             */
+            var connectionGroupFilterPattern = new FilterPattern($scope.connectionGroupProperties());
+
+            /**
+             * The filter search string to use to restrict the displayed
+             * connection groups.
+             *
+             * @type String
+             */
+            $scope.searchString = null;
+
+            /**
+             * Flattens the connection group hierarchy of the given connection
+             * group such that all descendants are copied as immediate
+             * children. The hierarchy of nested connection groups is otherwise
+             * completely preserved. A connection or connection group nested
+             * two or more levels deep within the hierarchy will thus appear
+             * within the returned connection group in two places: in its
+             * original location AND as an immediate child.
+             *
+             * @param {ConnectionGroup} connectionGroup
+             *     The connection group whose descendents should be copied as
+             *     first-level children.
+             *
+             * @returns {ConnectionGroup}
+             *     A new connection group completely identical to the provided
+             *     connection group, except that absolutely all descendents
+             *     have been copied into the first level of children.
+             */
+            var flattenConnectionGroup = function flattenConnectionGroup(connectionGroup) {
+
+                // Replace connection group with shallow copy
+                connectionGroup = new ConnectionGroup(connectionGroup);
+
+                // Ensure child arrays are defined and independent copies
+                connectionGroup.childConnections = angular.copy(connectionGroup.childConnections) || [];
+                connectionGroup.childConnectionGroups = angular.copy(connectionGroup.childConnectionGroups) || [];
+
+                // Flatten all children to the top-level group
+                angular.forEach(connectionGroup.childConnectionGroups, function flattenChild(child) {
+
+                    var flattenedChild = flattenConnectionGroup(child);
+
+                    // Merge all child connections
+                    Array.prototype.push.apply(
+                        connectionGroup.childConnections,
+                        flattenedChild.childConnections
+                    );
+
+                    // Merge all child connection groups
+                    Array.prototype.push.apply(
+                        connectionGroup.childConnectionGroups,
+                        flattenedChild.childConnectionGroups
+                    );
+
+                });
+
+                return connectionGroup;
+
+            };
+
+            /**
+             * Applies the current filter predicate, filtering all provided
+             * connection groups and storing the result in
+             * filteredConnectionGroups.
+             */
+            var updateFilteredConnectionGroups = function updateFilteredConnectionGroups() {
+
+                // Do not apply any filtering (and do not flatten) if no
+                // search string is provided
+                if (!$scope.searchString) {
+                    $scope.filteredConnectionGroups = $scope.connectionGroups() || {};
+                    return;
+                }
+
+                // Clear all current filtered groups
+                $scope.filteredConnectionGroups = {};
+
+                // Re-filter any provided groups
+                var connectionGroups = $scope.connectionGroups();
+                if (connectionGroups) {
+                    angular.forEach(connectionGroups, function updateFilteredConnectionGroup(connectionGroup, dataSource) {
+
+                        // Flatten hierarchy of connection group
+                        var filteredGroup = flattenConnectionGroup(connectionGroup);
+
+                        // Filter all direct children
+                        filteredGroup.childConnections = filteredGroup.childConnections.filter(connectionFilterPattern.predicate);
+                        filteredGroup.childConnectionGroups = filteredGroup.childConnectionGroups.filter(connectionGroupFilterPattern.predicate);
+
+                        // Store now-filtered root
+                        $scope.filteredConnectionGroups[dataSource] = filteredGroup;
+
+                    });
+                }
+
+            };
+
+            // Recompile and refilter when pattern is changed
+            $scope.$watch('searchString', function searchStringChanged(searchString) {
+                connectionFilterPattern.compile(searchString);
+                connectionGroupFilterPattern.compile(searchString);
+                updateFilteredConnectionGroups();
+            });
+
+            // Refilter when items change
+            $scope.$watchCollection($scope.connectionGroups, function itemsChanged() {
+                updateFilteredConnectionGroups();
+            });
+
+        }]
+
+    };
+}]);
diff --git a/guacamole/src/main/webapp/app/groupList/groupListModule.js b/guacamole/src/main/webapp/app/groupList/groupListModule.js
new file mode 100644
index 0000000..57447fd
--- /dev/null
+++ b/guacamole/src/main/webapp/app/groupList/groupListModule.js
@@ -0,0 +1,27 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Module for displaying the contents of a connection group, allowing the user
+ * to select individual connections or groups.
+ */
+angular.module('groupList', ['list', 'rest']);
diff --git a/guacamole/src/main/webapp/app/groupList/templates/guacGroupList.html b/guacamole/src/main/webapp/app/groupList/templates/guacGroupList.html
new file mode 100644
index 0000000..9059ab6
--- /dev/null
+++ b/guacamole/src/main/webapp/app/groupList/templates/guacGroupList.html
@@ -0,0 +1,63 @@
+<div class="group-list">
+    <!--
+       Copyright (C) 2014 Glyptodon LLC
+
+       Permission is hereby granted, free of charge, to any person obtaining a copy
+       of this software and associated documentation files (the "Software"), to deal
+       in the Software without restriction, including without limitation the rights
+       to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+       copies of the Software, and to permit persons to whom the Software is
+       furnished to do so, subject to the following conditions:
+
+       The above copyright notice and this permission notice shall be included in
+       all copies or substantial portions of the Software.
+
+       THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+       IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+       FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+       AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+       LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+       OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+       THE SOFTWARE.
+    -->
+
+    <script type="text/ng-template" id="nestedGroup.html">
+
+        <!-- Connection -->
+        <div class="connection" ng-show="isVisibleConnection(item)">
+            <div class="caption">
+                <ng-include src="connectionTemplate"/>
+            </div>
+        </div>
+
+        <!-- Connection group -->
+        <div class="group" ng-show="isVisibleConnectionGroup(item)">
+            <div class="caption">
+
+                <!-- Connection group icon -->
+                <div class="icon group type" ng-click="toggleExpanded(item)"
+                     ng-class="{expanded: item.isExpanded, empty: !item.children.length, balancer: item.isBalancing}"></div>
+
+                <ng-include src="connectionGroupTemplate"/>
+
+            </div>
+
+            <!-- Children of this group -->
+            <div class="children" ng-show="item.isExpanded">
+                <div class="list-item" ng-repeat="item in item.children | orderBy : 'name'" ng-include="'nestedGroup.html'">
+            </div>
+
+        </div>
+
+    </script>
+
+    <!-- Root-level connections / groups -->
+    <div class="group-list-page">
+        <div class="list-item" ng-repeat="item in childrenPage" ng-include="'nestedGroup.html'"></div>
+    </div>
+
+    <!-- Pager for connections / groups -->
+    <guac-pager page="childrenPage" items="rootItems | orderBy : 'name'"
+                page-size="pageSize"></guac-pager>
+
+</div>
diff --git a/guacamole/src/main/webapp/app/groupList/templates/guacGroupListFilter.html b/guacamole/src/main/webapp/app/groupList/templates/guacGroupListFilter.html
new file mode 100644
index 0000000..9ac3a82
--- /dev/null
+++ b/guacamole/src/main/webapp/app/groupList/templates/guacGroupListFilter.html
@@ -0,0 +1,27 @@
+<div class="group-list-filter filter">
+    <!--
+       Copyright (C) 2015 Glyptodon LLC
+
+       Permission is hereby granted, free of charge, to any person obtaining a copy
+       of this software and associated documentation files (the "Software"), to deal
+       in the Software without restriction, including without limitation the rights
+       to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+       copies of the Software, and to permit persons to whom the Software is
+       furnished to do so, subject to the following conditions:
+
+       The above copyright notice and this permission notice shall be included in
+       all copies or substantial portions of the Software.
+
+       THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+       IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+       FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+       AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+       LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+       OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+       THE SOFTWARE.
+    -->
+
+    <!-- Filter string -->
+    <input class="search-string" placeholder="{{placeholder()}}" type="text" ng-model="searchString"/>
+
+</div>
diff --git a/guacamole/src/main/webapp/app/groupList/types/GroupListItem.js b/guacamole/src/main/webapp/app/groupList/types/GroupListItem.js
new file mode 100644
index 0000000..09d6390
--- /dev/null
+++ b/guacamole/src/main/webapp/app/groupList/types/GroupListItem.js
@@ -0,0 +1,281 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Provides the GroupListItem class definition.
+ */
+angular.module('groupList').factory('GroupListItem', ['ConnectionGroup', function defineGroupListItem(ConnectionGroup) {
+
+    /**
+     * Creates a new GroupListItem, initializing the properties of that
+     * GroupListItem with the corresponding properties of the given template.
+     *
+     * @constructor
+     * @param {GroupListItem|Object} [template={}]
+     *     The object whose properties should be copied within the new
+     *     GroupListItem.
+     */
+    var GroupListItem = function GroupListItem(template) {
+
+        // Use empty object by default
+        template = template || {};
+
+        /**
+         * The identifier of the data source associated with the connection or
+         * connection group this item represents.
+         *
+         * @type String
+         */
+        this.dataSource = template.dataSource;
+
+        /**
+         * The unique identifier associated with the connection or connection
+         * group this item represents.
+         *
+         * @type String
+         */
+        this.identifier = template.identifier;
+
+        /**
+         * The human-readable display name of this item.
+         * 
+         * @type String
+         */
+        this.name = template.name;
+
+        /**
+         * The unique identifier of the protocol, if this item represents a
+         * connection. If this item does not represent a connection, this
+         * property is not applicable.
+         * 
+         * @type String
+         */
+        this.protocol = template.protocol;
+
+        /**
+         * All children items of this item. If this item contains no children,
+         * this will be an empty array.
+         *
+         * @type GroupListItem[]
+         */
+        this.children = template.children || [];
+
+        /**
+         * Whether this item represents a connection. If this item represents
+         * a connection group, this MUST be false.
+         *
+         * @type Boolean
+         */
+        this.isConnection = template.isConnection;
+
+        /**
+         * Whether this item represents a connection group. If this item
+         * represents a connection, this MUST be false.
+         *
+         * @type Boolean
+         */
+        this.isConnectionGroup = template.isConnectionGroup;
+
+        /**
+         * Whether this item represents a balancing connection group.
+         *
+         * @type Boolean
+         */
+        this.isBalancing = template.isBalancing;
+
+        /**
+         * Whether the children items should be displayed.
+         *
+         * @type Boolean
+         */
+        this.isExpanded = template.isExpanded;
+        
+        /**
+         * Returns the number of currently active users for this connection or
+         * connection group, if known.
+         * 
+         * @type Number
+         */
+        this.getActiveConnections = template.getActiveConnections || (function getActiveConnections() {
+            return null;
+        });
+
+        /**
+         * The connection or connection group whose data is exposed within
+         * this GroupListItem.
+         *
+         * @type Connection|ConnectionGroup
+         */
+        this.wrappedItem = template.wrappedItem;
+
+    };
+
+    /**
+     * Creates a new GroupListItem using the contents of the given connection.
+     *
+     * @param {String} dataSource
+     *     The identifier of the data source containing the given connection
+     *     group.
+     *
+     * @param {ConnectionGroup} connection
+     *     The connection whose contents should be represented by the new
+     *     GroupListItem.
+     *
+     * @param {Function} [countActiveConnections]
+     *     A getter which returns the current number of active connections for
+     *     the given connection. If omitted, the number of active connections
+     *     known at the time this function was called is used instead. This
+     *     function will be passed, in order, the data source identifier and
+     *     the connection in question.
+     *
+     * @returns {GroupListItem}
+     *     A new GroupListItem which represents the given connection.
+     */
+    GroupListItem.fromConnection = function fromConnection(dataSource,
+        connection, countActiveConnections) {
+
+        // Return item representing the given connection
+        return new GroupListItem({
+
+            // Identifying information
+            name       : connection.name,
+            identifier : connection.identifier,
+            protocol   : connection.protocol,
+            dataSource : dataSource,
+
+            // Type information
+            isConnection      : true,
+            isConnectionGroup : false,
+            
+            // Count of currently active connections using this connection
+            getActiveConnections : function getActiveConnections() {
+
+                // Use getter, if provided
+                if (countActiveConnections)
+                    return countActiveConnections(dataSource, connection);
+
+                return connection.activeConnections;
+
+            },
+
+            // Wrapped item
+            wrappedItem : connection
+
+        });
+
+    };
+
+    /**
+     * Creates a new GroupListItem using the contents and descendants of the
+     * given connection group.
+     *
+     * @param {String} dataSource
+     *     The identifier of the data source containing the given connection
+     *     group.
+     *
+     * @param {ConnectionGroup} connectionGroup
+     *     The connection group whose contents and descendants should be
+     *     represented by the new GroupListItem and its descendants.
+     *     
+     * @param {Boolean} [includeConnections=true]
+     *     Whether connections should be included in the contents of the
+     *     resulting GroupListItem. By default, connections are included.
+     *
+     * @param {Function} [countActiveConnections]
+     *     A getter which returns the current number of active connections for
+     *     the given connection. If omitted, the number of active connections
+     *     known at the time this function was called is used instead. This
+     *     function will be passed, in order, the data source identifier and
+     *     the connection group in question.
+     *
+     * @param {Function} [countActiveConnectionGroups]
+     *     A getter which returns the current number of active connections for
+     *     the given connection group. If omitted, the number of active
+     *     connections known at the time this function was called is used
+     *     instead. This function will be passed, in order, the data source
+     *     identifier and the connection group in question.
+     *
+     * @returns {GroupListItem}
+     *     A new GroupListItem which represents the given connection group,
+     *     including all descendants.
+     */
+    GroupListItem.fromConnectionGroup = function fromConnectionGroup(dataSource,
+        connectionGroup, includeConnections, countActiveConnections,
+        countActiveConnectionGroups) {
+
+        var children = [];
+
+        // Add any child connections
+        if (connectionGroup.childConnections && includeConnections !== false) {
+            connectionGroup.childConnections.forEach(function addChildConnection(child) {
+                children.push(GroupListItem.fromConnection(dataSource, child,
+                    countActiveConnections));
+            });
+        }
+
+        // Add any child groups 
+        if (connectionGroup.childConnectionGroups) {
+            connectionGroup.childConnectionGroups.forEach(function addChildGroup(child) {
+                children.push(GroupListItem.fromConnectionGroup(dataSource,
+                    child, includeConnections, countActiveConnections,
+                    countActiveConnectionGroups));
+            });
+        }
+
+        // Return item representing the given connection group
+        return new GroupListItem({
+
+            // Identifying information
+            name       : connectionGroup.name,
+            identifier : connectionGroup.identifier,
+            dataSource : dataSource,
+
+            // Type information
+            isConnection      : false,
+            isConnectionGroup : true,
+            isBalancing       : connectionGroup.type === ConnectionGroup.Type.BALANCING,
+
+            // Already-converted children
+            children : children,
+
+            // Count of currently active connection groups using this connection
+            getActiveConnections : function getActiveConnections() {
+
+                // Use getter, if provided
+                if (countActiveConnectionGroups)
+                    return countActiveConnectionGroups(dataSource, connectionGroup);
+
+                return connectionGroup.activeConnections;
+
+            },
+
+
+            // Wrapped item
+            wrappedItem : connectionGroup
+
+        });
+
+    };
+
+    return GroupListItem;
+
+}]);
diff --git a/guacamole/src/main/webapp/app/history/historyModule.js b/guacamole/src/main/webapp/app/history/historyModule.js
new file mode 100644
index 0000000..c81af8d
--- /dev/null
+++ b/guacamole/src/main/webapp/app/history/historyModule.js
@@ -0,0 +1,26 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * The module for code relating to connection history.
+ */
+angular.module('history', []);
diff --git a/guacamole/src/main/webapp/app/history/services/guacHistory.js b/guacamole/src/main/webapp/app/history/services/guacHistory.js
new file mode 100644
index 0000000..c10a0cb
--- /dev/null
+++ b/guacamole/src/main/webapp/app/history/services/guacHistory.js
@@ -0,0 +1,103 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * A service for reading and manipulating the Guacamole connection history.
+ */
+angular.module('history').factory('guacHistory', ['HistoryEntry', function guacHistory(HistoryEntry) {
+
+    var service = {};
+
+    // The parameter name for getting the history from local storage
+    var GUAC_HISTORY_STORAGE_KEY = "GUAC_HISTORY";
+                                    
+    /**
+     * The number of entries to allow before removing old entries based on the
+     * cutoff.
+     */
+    var IDEAL_LENGTH = 6;
+
+    /**
+     * The top few recent connections, sorted in order of most recent access.
+     * 
+     * @type HistoryEntry[]
+     */
+    service.recentConnections = [];
+
+    /**
+     * Updates the thumbnail and access time of the history entry for the
+     * connection with the given ID.
+     * 
+     * @param {String} id
+     *     The ID of the connection whose history entry should be updated.
+     * 
+     * @param {String} thumbnail
+     *     The URL of the thumbnail image to associate with the history entry.
+     */
+    service.updateThumbnail = function(id, thumbnail) {
+
+        var i;
+
+        // Remove any existing entry for this connection
+        for (i=0; i < service.recentConnections.length; i++) {
+            if (service.recentConnections[i].id === id) {
+                service.recentConnections.splice(i, 1);
+                break;
+            }
+        }
+
+        // Store new entry in history
+        service.recentConnections.unshift(new HistoryEntry(
+            id,
+            thumbnail,
+            new Date().getTime()
+        ));
+
+        // Truncate history to ideal length
+        if (service.recentConnections.length > IDEAL_LENGTH)
+            service.recentConnections.length = IDEAL_LENGTH;
+
+        // Save updated history, ignore inability to use localStorage
+        try {
+            if (localStorage)
+                localStorage.setItem(GUAC_HISTORY_STORAGE_KEY, JSON.stringify(service.recentConnections));
+        }
+        catch (ignore) {}
+
+    };
+
+    // Get stored connection history, ignore inability to use localStorage
+    try {
+
+        if (localStorage) {
+            var storedHistory = JSON.parse(localStorage.getItem(GUAC_HISTORY_STORAGE_KEY) || "[]");
+            if (storedHistory instanceof Array)
+                service.recentConnections = storedHistory;
+
+        }
+
+    }
+    catch (ignore) {}
+
+    return service;
+
+}]);
diff --git a/guacamole/src/main/webapp/app/history/types/HistoryEntry.js b/guacamole/src/main/webapp/app/history/types/HistoryEntry.js
new file mode 100644
index 0000000..c6e5dcb
--- /dev/null
+++ b/guacamole/src/main/webapp/app/history/types/HistoryEntry.js
@@ -0,0 +1,55 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Provides the HistoryEntry class used by the guacHistory service.
+ */
+angular.module('history').factory('HistoryEntry', [function defineHistoryEntry() {
+
+    /**
+     * A single entry in the connection history.
+     * 
+     * @constructor
+     * @param {String} id The ID of the connection.
+     * 
+     * @param {String} thumbnail
+     *     The URL of the thumbnail to use to represent the connection.
+     */
+    var HistoryEntry = function HistoryEntry(id, thumbnail) {
+
+        /**
+         * The ID of the connection associated with this history entry,
+         * including type prefix.
+         */
+        this.id = id;
+
+        /**
+         * The thumbnail associated with the connection associated with this
+         * history entry.
+         */
+        this.thumbnail = thumbnail;
+
+    };
+
+    return HistoryEntry;
+
+}]);
diff --git a/guacamole/src/main/webapp/app/home/controllers/homeController.js b/guacamole/src/main/webapp/app/home/controllers/homeController.js
new file mode 100644
index 0000000..1360ff5
--- /dev/null
+++ b/guacamole/src/main/webapp/app/home/controllers/homeController.js
@@ -0,0 +1,133 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * The controller for the home page.
+ */
+angular.module('home').controller('homeController', ['$scope', '$injector', 
+        function homeController($scope, $injector) {
+
+    // Get required types
+    var ConnectionGroup  = $injector.get('ConnectionGroup');
+    var ClientIdentifier = $injector.get('ClientIdentifier');
+            
+    // Get required services
+    var authenticationService  = $injector.get('authenticationService');
+    var connectionGroupService = $injector.get('connectionGroupService');
+    var dataSourceService      = $injector.get('dataSourceService');
+
+    /**
+     * Map of data source identifier to the root connection group of that data
+     * source, or null if the connection group hierarchy has not yet been
+     * loaded.
+     *
+     * @type Object.<String, ConnectionGroup>
+     */
+    $scope.rootConnectionGroups = null;
+
+    /**
+     * Array of all connection properties that are filterable.
+     *
+     * @type String[]
+     */
+    $scope.filteredConnectionProperties = [
+        'name'
+    ];
+
+    /**
+     * Array of all connection group properties that are filterable.
+     *
+     * @type String[]
+     */
+    $scope.filteredConnectionGroupProperties = [
+        'name'
+    ];
+
+    /**
+     * Returns whether critical data has completed being loaded.
+     *
+     * @returns {Boolean}
+     *     true if enough data has been loaded for the user interface to be
+     *     useful, false otherwise.
+     */
+    $scope.isLoaded = function isLoaded() {
+
+        return $scope.rootConnectionGroup !== null;
+
+    };
+
+    /**
+     * Object passed to the guacGroupList directive, providing context-specific
+     * functions or data.
+     */
+    $scope.context = {
+
+        /**
+         * Returns the unique string identifier which must be used when
+         * connecting to a connection or connection group represented by the
+         * given GroupListItem.
+         *
+         * @param {GroupListItem} item
+         *     The GroupListItem to determine the client identifier of.
+         *
+         * @returns {String}
+         *     The client identifier associated with the connection or
+         *     connection group represented by the given GroupListItem, or null
+         *     if the GroupListItem cannot have an associated client
+         *     identifier.
+         */
+        getClientIdentifier : function getClientIdentifier(item) {
+
+            // If the item is a connection, generate a connection identifier
+            if (item.isConnection)
+                return ClientIdentifier.toString({
+                    dataSource : item.dataSource,
+                    type       : ClientIdentifier.Types.CONNECTION,
+                    id         : item.identifier
+                });
+
+            // If the item is a connection, generate a connection group identifier
+            if (item.isConnectionGroup)
+                return ClientIdentifier.toString({
+                    dataSource : item.dataSource,
+                    type       : ClientIdentifier.Types.CONNECTION_GROUP,
+                    id         : item.identifier
+                });
+
+            // Otherwise, no such identifier can exist
+            return null;
+
+        }
+
+    };
+
+    // Retrieve root groups and all descendants
+    dataSourceService.apply(
+        connectionGroupService.getConnectionGroupTree,
+        authenticationService.getAvailableDataSources(),
+        ConnectionGroup.ROOT_IDENTIFIER
+    )
+    .then(function rootGroupsRetrieved(rootConnectionGroups) {
+        $scope.rootConnectionGroups = rootConnectionGroups;
+    });
+
+}]);
diff --git a/guacamole/src/main/webapp/app/home/directives/guacRecentConnections.js b/guacamole/src/main/webapp/app/home/directives/guacRecentConnections.js
new file mode 100644
index 0000000..a5d913a
--- /dev/null
+++ b/guacamole/src/main/webapp/app/home/directives/guacRecentConnections.js
@@ -0,0 +1,202 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * A directive which displays the contents of a connection group.
+ */
+angular.module('home').directive('guacRecentConnections', [function guacRecentConnections() {
+
+    return {
+        restrict: 'E',
+        replace: true,
+        scope: {
+
+            /**
+             * The root connection groups to display, and all visible
+             * descendants, as a map of data source identifier to the root
+             * connection group within that data source. Recent connections
+             * will only be shown if they exist within this hierarchy,
+             * regardless of their existence within the history.
+             *
+             * @type Object.<String, ConnectionGroup>
+             */
+            rootGroups : '='
+
+        },
+
+        templateUrl: 'app/home/templates/guacRecentConnections.html',
+        controller: ['$scope', '$injector', function guacRecentConnectionsController($scope, $injector) {
+
+            // Required types
+            var ActiveConnection = $injector.get('ActiveConnection');
+            var ClientIdentifier = $injector.get('ClientIdentifier');
+            var RecentConnection = $injector.get('RecentConnection');
+
+            // Required services
+            var guacClientManager = $injector.get('guacClientManager');
+            var guacHistory       = $injector.get('guacHistory');
+
+            /**
+             * Array of all known and visible active connections.
+             *
+             * @type ActiveConnection[]
+             */
+            $scope.activeConnections = [];
+
+            /**
+             * Array of all known and visible recently-used connections.
+             *
+             * @type RecentConnection[]
+             */
+            $scope.recentConnections = [];
+
+            /**
+             * Returns whether recent connections are available for display.
+             * Note that, for the sake of this directive, recent connections
+             * include any currently-active connections, even if they are not
+             * yet in the history.
+             *
+             * @returns {Boolean}
+             *     true if recent (or active) connections are present, false
+             *     otherwise.
+             */
+            $scope.hasRecentConnections = function hasRecentConnections() {
+                return !!($scope.activeConnections.length || $scope.recentConnections.length);
+            };
+
+            /**
+             * Map of all visible objects, connections or connection groups, by
+             * object identifier.
+             *
+             * @type Object.<String, Connection|ConnectionGroup>
+             */
+            var visibleObjects = {};
+
+            /**
+             * Adds the given connection to the internal set of visible
+             * objects.
+             *
+             * @param {String} dataSource
+             *     The identifier of the data source associated with the
+             *     given connection group.
+             *
+             * @param {Connection} connection
+             *     The connection to add to the internal set of visible objects.
+             */
+            var addVisibleConnection = function addVisibleConnection(dataSource, connection) {
+
+                // Add given connection to set of visible objects
+                visibleObjects[ClientIdentifier.toString({
+                    dataSource : dataSource,
+                    type       : ClientIdentifier.Types.CONNECTION,
+                    id         : connection.identifier
+                })] = connection;
+
+            };
+
+            /**
+             * Adds the given connection group to the internal set of visible
+             * objects, along with any descendants.
+             *
+             * @param {String} dataSource
+             *     The identifier of the data source associated with the
+             *     given connection group.
+             *
+             * @param {ConnectionGroup} connectionGroup
+             *     The connection group to add to the internal set of visible
+             *     objects, along with any descendants.
+             */
+            var addVisibleConnectionGroup = function addVisibleConnectionGroup(dataSource, connectionGroup) {
+
+                // Add given connection group to set of visible objects
+                visibleObjects[ClientIdentifier.toString({
+                    dataSource : dataSource,
+                    type       : ClientIdentifier.Types.CONNECTION_GROUP,
+                    id         : connectionGroup.identifier
+                })] = connectionGroup;
+
+                // Add all child connections
+                if (connectionGroup.childConnections)
+                    connectionGroup.childConnections.forEach(function addChildConnection(child) {
+                        addVisibleConnection(dataSource, child);
+                    });
+
+                // Add all child connection groups
+                if (connectionGroup.childConnectionGroups)
+                    connectionGroup.childConnectionGroups.forEach(function addChildConnectionGroup(child) {
+                        addVisibleConnectionGroup(dataSource, child);
+                    });
+
+            };
+
+            // Update visible objects when root groups are set
+            $scope.$watch("rootGroups", function setRootGroups(rootGroups) {
+
+                // Clear connection arrays
+                $scope.activeConnections = [];
+                $scope.recentConnections = [];
+
+                // Produce collection of visible objects
+                visibleObjects = {};
+                if (rootGroups) {
+                    angular.forEach(rootGroups, function addConnectionGroup(rootGroup, dataSource) {
+                        addVisibleConnectionGroup(dataSource, rootGroup);
+                    });
+                }
+
+                var managedClients = guacClientManager.getManagedClients();
+
+                // Add all active connections
+                for (var id in managedClients) {
+
+                    // Get corresponding managed client
+                    var client = managedClients[id];
+
+                    // Add active connections for clients with associated visible objects
+                    if (id in visibleObjects) {
+
+                        var object = visibleObjects[id];
+                        $scope.activeConnections.push(new ActiveConnection(object.name, client));
+
+                    }
+
+                }
+
+                // Add any recent connections that are visible
+                guacHistory.recentConnections.forEach(function addRecentConnection(historyEntry) {
+
+                    // Add recent connections for history entries with associated visible objects
+                    if (historyEntry.id in visibleObjects && !(historyEntry.id in managedClients)) {
+
+                        var object = visibleObjects[historyEntry.id];
+                        $scope.recentConnections.push(new RecentConnection(object.name, historyEntry));
+
+                    }
+
+                });
+
+            }); // end rootGroup scope watch
+
+        }]
+
+    };
+}]);
diff --git a/guacamole/src/main/webapp/app/home/homeModule.js b/guacamole/src/main/webapp/app/home/homeModule.js
new file mode 100644
index 0000000..6c0b9a5
--- /dev/null
+++ b/guacamole/src/main/webapp/app/home/homeModule.js
@@ -0,0 +1,23 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+angular.module('home', ['client', 'groupList', 'history', 'navigation', 'rest']);
diff --git a/guacamole/src/main/webapp/app/home/styles/home.css b/guacamole/src/main/webapp/app/home/styles/home.css
new file mode 100644
index 0000000..d34ca6f
--- /dev/null
+++ b/guacamole/src/main/webapp/app/home/styles/home.css
@@ -0,0 +1,55 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+.history-unavailable div.recent-connections {
+    display: none;
+}
+
+div.recent-connections,
+div.clipboardDiv,
+div.settings,
+div.all-connections {
+    margin: 1em;
+    padding: 0;
+}
+
+.all-connections .list-buttons {
+    text-align: center;
+    padding: 0;
+}
+
+div.recent-connections {
+    text-align: center;
+}
+
+div.recent-connections div.connection {
+    -moz-border-radius: 0.5em;
+    -webkit-border-radius: 0.5em;
+    -khtml-border-radius: 0.5em;
+    border-radius: 0.5em;
+    display: inline-block;
+    padding: 1em;
+    margin: 1em;
+    text-align: center;
+    max-width: 75%;
+    overflow: hidden;
+}
diff --git a/guacamole/src/main/webapp/app/home/templates/connection.html b/guacamole/src/main/webapp/app/home/templates/connection.html
new file mode 100644
index 0000000..87f101a
--- /dev/null
+++ b/guacamole/src/main/webapp/app/home/templates/connection.html
@@ -0,0 +1,40 @@
+<a ng-href="#/client/{{context.getClientIdentifier(item)}}">
+    <!--
+       Copyright (C) 2014 Glyptodon LLC
+
+       Permission is hereby granted, free of charge, to any person obtaining a copy
+       of this software and associated documentation files (the "Software"), to deal
+       in the Software without restriction, including without limitation the rights
+       to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+       copies of the Software, and to permit persons to whom the Software is
+       furnished to do so, subject to the following conditions:
+
+       The above copyright notice and this permission notice shall be included in
+       all copies or substantial portions of the Software.
+
+       THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+       IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+       FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+       AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+       LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+       OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+       THE SOFTWARE.
+    -->
+
+    <div class="caption" ng-class="{active: item.getActiveConnections()}">
+
+        <!-- Connection icon -->
+        <div class="protocol">
+            <div class="icon type" ng-class="item.protocol"></div>
+        </div>
+
+        <!-- Connection name -->
+        <span class="name">{{item.name}}</span>
+        
+        <!-- Active user count -->
+        <span class="activeUserCount" ng-show="item.getActiveConnections()"
+            translate="HOME.INFO_ACTIVE_USER_COUNT"
+            translate-values="{USERS: item.getActiveConnections()}"></span>
+
+    </div>
+</a>
diff --git a/guacamole/src/main/webapp/app/home/templates/connectionGroup.html b/guacamole/src/main/webapp/app/home/templates/connectionGroup.html
new file mode 100644
index 0000000..3f5e1ca
--- /dev/null
+++ b/guacamole/src/main/webapp/app/home/templates/connectionGroup.html
@@ -0,0 +1,26 @@
+<span class="name">
+    <!--
+       Copyright (C) 2014 Glyptodon LLC
+
+       Permission is hereby granted, free of charge, to any person obtaining a copy
+       of this software and associated documentation files (the "Software"), to deal
+       in the Software without restriction, including without limitation the rights
+       to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+       copies of the Software, and to permit persons to whom the Software is
+       furnished to do so, subject to the following conditions:
+
+       The above copyright notice and this permission notice shall be included in
+       all copies or substantial portions of the Software.
+
+       THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+       IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+       FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+       AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+       LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+       OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+       THE SOFTWARE.
+    -->
+
+    <a ng-show="item.isBalancing" ng-href="#/client/{{context.getClientIdentifier(item)}}">{{item.name}}</a>
+    <span ng-show="!item.isBalancing">{{item.name}}</span>
+</span>
diff --git a/guacamole/src/main/webapp/app/home/templates/guacRecentConnections.html b/guacamole/src/main/webapp/app/home/templates/guacRecentConnections.html
new file mode 100644
index 0000000..0f1985f
--- /dev/null
+++ b/guacamole/src/main/webapp/app/home/templates/guacRecentConnections.html
@@ -0,0 +1,61 @@
+<div>
+    <!--
+       Copyright (C) 2014 Glyptodon LLC
+
+       Permission is hereby granted, free of charge, to any person obtaining a copy
+       of this software and associated documentation files (the "Software"), to deal
+       in the Software without restriction, including without limitation the rights
+       to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+       copies of the Software, and to permit persons to whom the Software is
+       furnished to do so, subject to the following conditions:
+
+       The above copyright notice and this permission notice shall be included in
+       all copies or substantial portions of the Software.
+
+       THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+       IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+       FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+       AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+       LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+       OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+       THE SOFTWARE.
+    -->
+
+    <!-- Text displayed if no recent connections exist -->
+    <p class="placeholder" ng-hide="hasRecentConnections()">{{'HOME.INFO_NO_RECENT_CONNECTIONS' | translate}}</p>
+
+    <!-- All active connections -->
+    <div ng-repeat="activeConnection in activeConnections" class="connection">
+        <a href="#/client/{{activeConnection.client.id}}">
+
+            <!-- Connection thumbnail -->
+            <div class="thumbnail">
+                <guac-thumbnail client="activeConnection.client"></guac-thumbnail>
+            </div>
+
+            <!-- Connection name -->
+            <div class="caption">
+                <span class="name">{{activeConnection.name}}</span>
+            </div>
+
+        </a>
+    </div>
+    
+    <!-- All recent connections -->
+    <div ng-repeat="recentConnection in recentConnections" class="connection">
+        <a href="#/client/{{recentConnection.entry.id}}">
+
+            <!-- Connection thumbnail -->
+            <div class="thumbnail">
+                <img alt="{{recentConnection.name}}" ng-src="{{recentConnection.entry.thumbnail}}"/>
+            </div>
+
+            <!-- Connection name -->
+            <div class="caption">
+                <span class="name">{{recentConnection.name}}</span>
+            </div>
+
+        </a>
+    </div>
+
+</div>
diff --git a/guacamole/src/main/webapp/app/home/templates/home.html b/guacamole/src/main/webapp/app/home/templates/home.html
new file mode 100644
index 0000000..d38d5da
--- /dev/null
+++ b/guacamole/src/main/webapp/app/home/templates/home.html
@@ -0,0 +1,56 @@
+<!--
+   Copyright (C) 2015 Glyptodon LLC
+
+   Permission is hereby granted, free of charge, to any person obtaining a copy
+   of this software and associated documentation files (the "Software"), to deal
+   in the Software without restriction, including without limitation the rights
+   to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+   copies of the Software, and to permit persons to whom the Software is
+   furnished to do so, subject to the following conditions:
+
+   The above copyright notice and this permission notice shall be included in
+   all copies or substantial portions of the Software.
+
+   THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+   IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+   FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+   AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+   LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+   OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+   THE SOFTWARE.
+-->
+
+<div class="view" ng-class="{loading: !isLoaded()}">
+
+    <div class="connection-list-ui">
+
+        <!-- The recent connections for this user -->
+        <div class="header">
+            <h2>{{'HOME.SECTION_HEADER_RECENT_CONNECTIONS' | translate}}</h2>
+            <guac-user-menu></guac-user-menu>
+        </div>
+        <div class="recent-connections">
+            <guac-recent-connections root-groups="rootConnectionGroups"></guac-recent-connections>
+        </div>
+
+        <!-- All connections for this user -->
+        <div class="header">
+            <h2>{{'HOME.SECTION_HEADER_ALL_CONNECTIONS' | translate}}</h2>
+            <guac-group-list-filter connection-groups="rootConnectionGroups"
+                filtered-connection-groups="filteredRootConnectionGroups"
+                placeholder="'HOME.FIELD_PLACEHOLDER_FILTER' | translate"
+                connection-properties="filteredConnectionProperties"
+                connection-group-properties="filteredConnectionGroupProperties"></guac-group-list-filter>
+        </div>
+        <div class="all-connections">
+            <guac-group-list
+                context="context"
+                connection-groups="filteredRootConnectionGroups"
+                connection-template="'app/home/templates/connection.html'"
+                connection-group-template="'app/home/templates/connectionGroup.html'"
+                page-size="20"></guac-group-list>
+        </div>
+
+    </div>
+
+</div>
\ No newline at end of file
diff --git a/guacamole/src/main/webapp/app/home/types/ActiveConnection.js b/guacamole/src/main/webapp/app/home/types/ActiveConnection.js
new file mode 100644
index 0000000..aef6a91
--- /dev/null
+++ b/guacamole/src/main/webapp/app/home/types/ActiveConnection.js
@@ -0,0 +1,55 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Provides the ActiveConnection class used by the guacRecentConnections
+ * directive.
+ */
+angular.module('home').factory('ActiveConnection', [function defineActiveConnection() {
+
+    /**
+     * A recently-user connection, visible to the current user, with an
+     * associated history entry.
+     * 
+     * @constructor
+     */
+    var ActiveConnection = function ActiveConnection(name, client) {
+
+        /**
+         * The human-readable name of this connection.
+         * 
+         * @type String
+         */
+        this.name = name;
+
+        /**
+         * The client associated with this active connection.
+         * 
+         * @type ManagedClient 
+         */
+        this.client = client;
+
+    };
+
+    return ActiveConnection;
+
+}]);
diff --git a/guacamole/src/main/webapp/app/home/types/RecentConnection.js b/guacamole/src/main/webapp/app/home/types/RecentConnection.js
new file mode 100644
index 0000000..d28e52b
--- /dev/null
+++ b/guacamole/src/main/webapp/app/home/types/RecentConnection.js
@@ -0,0 +1,55 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Provides the RecentConnection class used by the guacRecentConnections
+ * directive.
+ */
+angular.module('home').factory('RecentConnection', [function defineRecentConnection() {
+
+    /**
+     * A recently-user connection, visible to the current user, with an
+     * associated history entry.
+     * 
+     * @constructor
+     */
+    var RecentConnection = function RecentConnection(name, entry) {
+
+        /**
+         * The human-readable name of this connection.
+         * 
+         * @type String
+         */
+        this.name = name;
+
+        /**
+         * The history entry associated with this recent connection.
+         * 
+         * @type HistoryEntry
+         */
+        this.entry = entry;
+
+    };
+
+    return RecentConnection;
+
+}]);
diff --git a/guacamole/src/main/webapp/app/index/config/indexHttpPatchConfig.js b/guacamole/src/main/webapp/app/index/config/indexHttpPatchConfig.js
new file mode 100644
index 0000000..dc55149
--- /dev/null
+++ b/guacamole/src/main/webapp/app/index/config/indexHttpPatchConfig.js
@@ -0,0 +1,34 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * The config block for setting up the HTTP PATCH method.
+ */
+angular.module('index').config(['$httpProvider', 
+        function indexHttpPatchConfig($httpProvider) {
+    
+    $httpProvider.defaults.headers.patch = {
+        'Content-Type': 'application/json'
+    }
+}]);
+
+
diff --git a/guacamole/src/main/webapp/app/index/config/indexRouteConfig.js b/guacamole/src/main/webapp/app/index/config/indexRouteConfig.js
new file mode 100644
index 0000000..515dcd1
--- /dev/null
+++ b/guacamole/src/main/webapp/app/index/config/indexRouteConfig.js
@@ -0,0 +1,174 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * The config block for setting up all the url routing.
+ */
+angular.module('index').config(['$routeProvider', '$locationProvider', 
+        function indexRouteConfig($routeProvider, $locationProvider) {
+
+    // Disable HTML5 mode (use # for routing)
+    $locationProvider.html5Mode(false);
+
+    /**
+     * Attempts to re-authenticate with the Guacamole server, sending any
+     * query parameters in the URL, along with the current auth token, and
+     * updating locally stored token if necessary.
+     *
+     * @param {Service} $injector
+     *     The Angular $injector service.
+     * 
+     * @returns {Promise}
+     *     A promise which resolves successfully only after an attempt to
+     *     re-authenticate has been made. If the authentication attempt fails,
+     *     the promise will be rejected.
+     */
+    var updateCurrentToken = ['$injector', function updateCurrentToken($injector) {
+
+        // Required services
+        var $location             = $injector.get('$location');
+        var authenticationService = $injector.get('authenticationService');
+
+        // Re-authenticate including any parameters in URL
+        return authenticationService.updateCurrentToken($location.search());
+
+    }];
+
+    /**
+     * Redirects the user to their home page. This necessarily requires
+     * attempting to re-authenticate with the Guacamole server, as the user's
+     * credentials may have changed, and thus their most-appropriate home page
+     * may have changed as well.
+     *
+     * @param {Service} $injector
+     *     The Angular $injector service.
+     * 
+     * @returns {Promise}
+     *     A promise which resolves successfully only after an attempt to
+     *     re-authenticate and determine the user's proper home page has been
+     *     made.
+     */
+    var routeToUserHomePage = ['$injector', function routeToUserHomePage($injector) {
+
+        // Required services
+        var $location       = $injector.get('$location');
+        var $q              = $injector.get('$q');
+        var userPageService = $injector.get('userPageService');
+
+        // Promise for routing attempt
+        var route = $q.defer();
+
+        // Re-authenticate including any parameters in URL
+        $injector.invoke(updateCurrentToken)
+        .then(function tokenUpdateComplete() {
+
+            // Redirect to home page
+            userPageService.getHomePage()
+            .then(function homePageRetrieved(homePage) {
+
+                // If home page is the requested location, allow through
+                if ($location.path() === homePage.url)
+                    route.resolve();
+
+                // Otherwise, reject and reroute
+                else {
+                    $location.url(homePage.url);
+                    route.reject();
+                }
+
+            })
+
+            // If retrieval of home page fails, assume requested page is OK
+            ['catch'](function homePageFailed() {
+                route.resolve();
+            });
+
+        });
+
+        // Return promise that will resolve only if the requested page is the
+        // home page
+        return route.promise;
+
+    }];
+
+    // Configure each possible route
+    $routeProvider
+
+        // Home screen
+        .when('/', {
+            title         : 'APP.NAME',
+            bodyClassName : 'home',
+            templateUrl   : 'app/home/templates/home.html',
+            controller    : 'homeController',
+            resolve       : { routeToUserHomePage: routeToUserHomePage }
+        })
+
+        // Management screen
+        .when('/settings/:dataSource?/:tab', {
+            title         : 'APP.NAME',
+            bodyClassName : 'settings',
+            templateUrl   : 'app/settings/templates/settings.html',
+            controller    : 'settingsController',
+            resolve       : { updateCurrentToken: updateCurrentToken }
+        })
+
+        // Connection editor
+        .when('/manage/:dataSource/connections/:id?', {
+            title         : 'APP.NAME',
+            bodyClassName : 'manage',
+            templateUrl   : 'app/manage/templates/manageConnection.html',
+            controller    : 'manageConnectionController',
+            resolve       : { updateCurrentToken: updateCurrentToken }
+        })
+
+        // Connection group editor
+        .when('/manage/:dataSource/connectionGroups/:id?', {
+            title         : 'APP.NAME',
+            bodyClassName : 'manage',
+            templateUrl   : 'app/manage/templates/manageConnectionGroup.html',
+            controller    : 'manageConnectionGroupController',
+            resolve       : { updateCurrentToken: updateCurrentToken }
+        })
+
+        // User editor
+        .when('/manage/:dataSource/users/:id?', {
+            title         : 'APP.NAME',
+            bodyClassName : 'manage',
+            templateUrl   : 'app/manage/templates/manageUser.html',
+            controller    : 'manageUserController',
+            resolve       : { updateCurrentToken: updateCurrentToken }
+        })
+
+        // Client view
+        .when('/client/:id/:params?', {
+            bodyClassName : 'client',
+            templateUrl   : 'app/client/templates/client.html',
+            controller    : 'clientController',
+            resolve       : { updateCurrentToken: updateCurrentToken }
+        })
+
+        // Redirect to home screen if page not found
+        .otherwise({
+            resolve : { routeToUserHomePage: routeToUserHomePage }
+        });
+
+}]);
diff --git a/guacamole/src/main/webapp/app/index/config/indexTranslationConfig.js b/guacamole/src/main/webapp/app/index/config/indexTranslationConfig.js
new file mode 100644
index 0000000..b59d37f
--- /dev/null
+++ b/guacamole/src/main/webapp/app/index/config/indexTranslationConfig.js
@@ -0,0 +1,47 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * The configuration block for setting up everything having to do with i18n.
+ */
+angular.module('index').config(['$injector', function($injector) {
+
+    // Required providers
+    var $translateProvider        = $injector.get('$translateProvider');
+    var preferenceServiceProvider = $injector.get('preferenceServiceProvider');
+
+    // Fallback to US English
+    $translateProvider.fallbackLanguage('en');
+
+    // Prefer chosen language
+    $translateProvider.preferredLanguage(preferenceServiceProvider.preferences.language);
+
+    // Escape any HTML in translation strings
+    $translateProvider.useSanitizeValueStrategy('escape');
+
+    // Load translations via translationLoader service
+    $translateProvider.useLoader('translationLoader');
+
+    // Provide pluralization, etc. via messageformat.js
+    $translateProvider.useMessageFormatInterpolation();
+
+}]);
diff --git a/guacamole/src/main/webapp/app/index/controllers/indexController.js b/guacamole/src/main/webapp/app/index/controllers/indexController.js
new file mode 100644
index 0000000..3980568
--- /dev/null
+++ b/guacamole/src/main/webapp/app/index/controllers/indexController.js
@@ -0,0 +1,172 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * The controller for the root of the application.
+ */
+angular.module('index').controller('indexController', ['$scope', '$injector',
+        function indexController($scope, $injector) {
+
+    // Required services
+    var $document        = $injector.get('$document');
+    var $window          = $injector.get('$window');
+    var guacNotification = $injector.get('guacNotification');
+    
+    /**
+     * The notification service.
+     */
+    $scope.guacNotification = guacNotification;
+
+    /**
+     * The message to display to the user as instructions for the login
+     * process.
+     *
+     * @type String
+     */
+    $scope.loginHelpText = null;
+
+    /**
+     * The credentials that the authentication service is has already accepted,
+     * pending additional credentials, if any. If the user is logged in, or no
+     * credentials have been accepted, this will be null. If credentials have
+     * been accepted, this will be a map of name/value pairs corresponding to
+     * the parameters submitted in a previous authentication attempt.
+     *
+     * @type Object.<String, String>
+     */
+    $scope.acceptedCredentials = null;
+
+    /**
+     * The credentials that the authentication service is currently expecting,
+     * if any. If the user is logged in, this will be null.
+     *
+     * @type Field[]
+     */
+    $scope.expectedCredentials = null;
+
+    /**
+     * Basic page-level information.
+     */
+    $scope.page = {
+
+        /**
+         * The title of the page.
+         * 
+         * @type String
+         */
+        title: '',
+
+        /**
+         * The name of the CSS class to apply to the page body, if any.
+         *
+         * @type String
+         */
+        bodyClassName: ''
+
+    };
+
+    // Create event listeners at the global level
+    var keyboard = new Guacamole.Keyboard($document[0]);
+
+    // Broadcast keydown events
+    keyboard.onkeydown = function onkeydown(keysym) {
+
+        // Do not handle key events if not logged in
+        if ($scope.expectedCredentials)
+            return true;
+
+        // Warn of pending keydown
+        var guacBeforeKeydownEvent = $scope.$broadcast('guacBeforeKeydown', keysym, keyboard);
+        if (guacBeforeKeydownEvent.defaultPrevented)
+            return true;
+
+        // If not prevented via guacBeforeKeydown, fire corresponding keydown event
+        var guacKeydownEvent = $scope.$broadcast('guacKeydown', keysym, keyboard);
+        return !guacKeydownEvent.defaultPrevented;
+
+    };
+    
+    // Broadcast keyup events
+    keyboard.onkeyup = function onkeyup(keysym) {
+
+        // Do not handle key events if not logged in
+        if ($scope.expectedCredentials)
+            return;
+
+        // Warn of pending keyup
+        var guacBeforeKeydownEvent = $scope.$broadcast('guacBeforeKeyup', keysym, keyboard);
+        if (guacBeforeKeydownEvent.defaultPrevented)
+            return;
+
+        // If not prevented via guacBeforeKeyup, fire corresponding keydown event
+        $scope.$broadcast('guacKeyup', keysym, keyboard);
+
+    };
+
+    // Release all keys when window loses focus
+    $window.onblur = function () {
+        keyboard.reset();
+    };
+
+    // Display login screen if a whole new set of credentials is needed
+    $scope.$on('guacInvalidCredentials', function loginInvalid(event, parameters, error) {
+        $scope.page.title = 'APP.NAME';
+        $scope.page.bodyClassName = '';
+        $scope.loginHelpText = null;
+        $scope.acceptedCredentials = {};
+        $scope.expectedCredentials = error.expected;
+    });
+
+    // Prompt for remaining credentials if provided credentials were not enough
+    $scope.$on('guacInsufficientCredentials', function loginInsufficient(event, parameters, error) {
+        $scope.page.title = 'APP.NAME';
+        $scope.page.bodyClassName = '';
+        $scope.loginHelpText = error.message;
+        $scope.acceptedCredentials = parameters;
+        $scope.expectedCredentials = error.expected;
+    });
+
+    // Clear login screen if login was successful
+    $scope.$on('guacLogin', function loginSuccessful() {
+        $scope.loginHelpText = null;
+        $scope.acceptedCredentials = null;
+        $scope.expectedCredentials = null;
+    });
+
+    // Update title and CSS class upon navigation
+    $scope.$on('$routeChangeSuccess', function(event, current, previous) {
+       
+        // If the current route is available
+        if (current.$$route) {
+
+            // Set title
+            var title = current.$$route.title;
+            if (title)
+                $scope.page.title = title;
+
+            // Set body CSS class
+            $scope.page.bodyClassName = current.$$route.bodyClassName || '';
+        }
+
+    });
+
+}]);
diff --git a/guacamole/src/main/webapp/app/index/indexModule.js b/guacamole/src/main/webapp/app/index/indexModule.js
new file mode 100644
index 0000000..8d30eaa
--- /dev/null
+++ b/guacamole/src/main/webapp/app/index/indexModule.js
@@ -0,0 +1,40 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * The module for the root of the application.
+ */
+angular.module('index', [
+    'auth',
+    'client',
+    'home',
+    'login',
+    'manage',
+    'navigation',
+    'ngRoute',
+    'ngTouch',
+    'notification',
+    'pascalprecht.translate',
+    'rest',
+    'settings',
+    'templates-main'
+]);
diff --git a/guacamole/src/main/webapp/app/index/styles/animation.css b/guacamole/src/main/webapp/app/index/styles/animation.css
new file mode 100644
index 0000000..470e4cc
--- /dev/null
+++ b/guacamole/src/main/webapp/app/index/styles/animation.css
@@ -0,0 +1,53 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * fadein: Fade from fully transparent to fully opaque.
+ */
+ at keyframes fadein {
+    from { opacity: 0; }
+    to   { opacity: 1; }
+}
+ at -moz-keyframes fadein {
+    from { opacity: 0; }
+    to   { opacity: 1; }
+}
+ at -webkit-keyframes fadein {
+    from { opacity: 0; }
+    to   { opacity: 1; }
+}
+
+/**
+ * fadeout: Fade from fully opaque to fully transparent.
+ */
+ at keyframes fadeout {
+    from { opacity: 1; }
+    to   { opacity: 0; }
+}
+ at -moz-keyframes fadeout {
+    from { opacity: 1; }
+    to   { opacity: 0; }
+}
+ at -webkit-keyframes fadeout {
+    from { opacity: 1; }
+    to   { opacity: 0; }
+}
diff --git a/guacamole/src/main/webapp/app/index/styles/buttons.css b/guacamole/src/main/webapp/app/index/styles/buttons.css
new file mode 100644
index 0000000..280cd47
--- /dev/null
+++ b/guacamole/src/main/webapp/app/index/styles/buttons.css
@@ -0,0 +1,137 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+a.button {
+    cursor: default;
+    display: inline-block;
+}
+
+input[type="submit"], button, a.button {
+    
+    -webkit-appearance: none;
+    text-decoration: none;
+
+    background-color: #3C3C3C;
+    border: 1px solid rgba(0, 0, 0, 0.4);
+
+    color: white;
+    text-shadow: -1px -1px rgba(0, 0, 0, 0.3);
+    font-size: 1em;
+    font-weight: bold;
+    font-family: Carlito, FreeSans, Helvetica, Arial, sans-serif;
+
+    padding: 0.35em 1em;
+    min-width: 5em;
+    margin: 0.25em;
+
+    box-shadow: 0 1px 2px rgba(0, 0, 0, 0.25);
+
+}
+
+input[type="submit"]:hover, button:hover, a.button:hover {
+    background-color: #5A5A5A;
+}
+
+input[type="submit"]:active, button:active, a.button:active {
+
+    background-color: #2C2C2C;
+    
+    box-shadow: 
+                inset 1px 1px 0.25em rgba(0, 0, 0, 0.25),
+                -1px -1px 0.25em rgba(0, 0, 0, 0.25),
+                1px 1px 0.25em rgba(255, 255, 255, 0.25);
+}
+
+button.danger, a.button.danger {
+    background: #A43;
+}
+
+button.danger:hover, a.button.danger:hover {
+    background: #C54;
+}
+
+button.danger:active, a.button.danger:active {
+    background: #932;
+}
+
+input[type="submit"]:disabled, button:disabled, button.danger:disabled {
+    background-color: #3C3C3C;
+    color: rgba(255, 255, 255, 0.5);
+    opacity: 0.75;
+}
+
+.button.logout,          button.logout,
+.button.reconnect,       button.reconnect,
+.button.manage,          button.manage,
+.button.back,            button.back,
+.button.home,            button.home,
+.button.change-password, button.change-password {
+    position: relative;
+    padding-left: 1.8em;
+}
+
+.button.logout::before,          button.logout::before,
+.button.reconnect::before,       button.reconnect::before,
+.button.manage::before,          button.manage::before,
+.button.back::before,            button.back::before,
+.button.home::before,            button.home::before,
+.button.change-password::before, button.change-password::before {
+    content: ' ';
+    position: absolute;
+    left: 0;
+    top: 0;
+    bottom: 0;
+    width: 1.8em;
+    background-repeat: no-repeat;
+    background-size: 1em;
+    background-position: 0.5em 0.45em;
+}
+
+.button.logout::before,
+button.logout::before {
+    background-image: url('images/action-icons/guac-logout.png');
+}
+
+.button.reconnect::before,
+button.reconnect::before {
+    background-image: url('images/circle-arrows.png');
+}
+
+.button.manage::before,
+button.manage::before {
+    background-image: url('images/action-icons/guac-config.png');
+}
+
+.button.back::before,
+button.back::before {
+    background-image: url('images/action-icons/guac-back.png');
+}
+
+.button.home::before,
+button.home::before {
+    background-image: url('images/action-icons/guac-home.png');
+}
+
+.button.change-password::before,
+button.change-password::before {
+    background-image: url('images/action-icons/guac-key.png');
+}
diff --git a/guacamole/src/main/webapp/app/index/styles/dialog.css b/guacamole/src/main/webapp/app/index/styles/dialog.css
new file mode 100644
index 0000000..bad3110
--- /dev/null
+++ b/guacamole/src/main/webapp/app/index/styles/dialog.css
@@ -0,0 +1,97 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+.dialog-container {
+    position: fixed;
+    top: 0;
+    left: 0;
+    bottom: 0;
+    right: 0;
+    background: rgba(0, 0, 0, 0.5);
+    padding: 1em;
+}
+
+.dialog-outer {
+    display: table;
+    height: 100%;
+    width: 100%;
+    position: fixed;
+    left: 0;
+    top: 0;
+    background: rgba(0, 0, 0, 0.5);
+}
+
+.dialog-middle {
+    width: 100%;
+    text-align: center;
+    display: table-cell;
+    vertical-align: middle;
+}
+
+.dialog.edit {
+    max-height: 100%;
+}
+
+.dialog {
+
+    max-width: 100%;
+    width: 8in;
+    margin-left: auto;
+    margin-right: auto;
+    overflow: auto;
+
+    border: 1px solid rgba(0, 0, 0, 0.5);
+    background: #E7E7E7;
+
+    -moz-border-radius: 0.2em;
+    -webkit-border-radius: 0.2em;
+    -khtml-border-radius: 0.2em;
+    border-radius: 0.2em;
+
+    box-shadow: 0.1em 0.1em 0.2em rgba(0, 0, 0, 0.6);
+    
+}
+
+.dialog > * {
+    margin: 1em;
+}
+
+.dialog .header {
+    margin: 0;
+}
+
+.dialog td {
+    position: relative;
+}
+
+.dialog .overlay {
+    position: fixed;
+    top: 0;
+    left: 0;
+    bottom: 0;
+    right: 0;
+    z-index: 1;
+}
+
+.dialog .footer {
+    text-align: center;
+}
diff --git a/guacamole/src/main/webapp/app/index/styles/font-carlito.css b/guacamole/src/main/webapp/app/index/styles/font-carlito.css
new file mode 100644
index 0000000..1b54466
--- /dev/null
+++ b/guacamole/src/main/webapp/app/index/styles/font-carlito.css
@@ -0,0 +1,50 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/*
+ * The Carlito font is licensed under the SIL Open Font License, and thus is
+ * freely redistributable so long as it is distributed with software.
+ *
+ * The SIL OFL license can be found at http://scripts.sil.org/OFL or in the
+ * "LICENSE" file within the same directory as the Carlito-*.woff font files.
+ */
+
+ at font-face {
+    font-family: 'Carlito';
+    font-weight: normal;
+    font-style: normal;
+    src: url('fonts/carlito/Carlito-Regular.woff') format('woff');
+}
+
+ at font-face {
+    font-family: 'Carlito';
+    font-weight: bold;
+    font-style: normal;
+    src: url('fonts/carlito/Carlito-Bold.woff') format('woff');
+}
+
+ at font-face {
+    font-family: 'Carlito';
+    font-weight: normal;
+    font-style: italic;
+    src: url('fonts/carlito/Carlito-Italic.woff') format('woff');
+}
diff --git a/guacamole/src/main/webapp/app/index/styles/headers.css b/guacamole/src/main/webapp/app/index/styles/headers.css
new file mode 100644
index 0000000..4da0a24
--- /dev/null
+++ b/guacamole/src/main/webapp/app/index/styles/headers.css
@@ -0,0 +1,107 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+h1 {
+    
+    margin: 0;
+    padding: 0.5em;
+
+    font-size: 2em;
+    vertical-align: middle;
+    text-align: center;
+
+}
+
+h2 {
+    font-size: 1.25em;
+    font-weight: bold;
+    text-transform: uppercase;
+    padding: 0.75em 0.5em;
+    margin: 0;
+}
+
+.header {
+
+    border-bottom: 1px solid rgba(0, 0, 0, 0.125);
+    box-shadow: 0 1px 2px rgba(0, 0, 0, 0.125);
+    background: rgba(0, 0, 0, 0.04);
+
+    margin-bottom: 1em;
+    margin-top: 0;
+    border-top: none;
+    width: 100%;
+
+    /* IE10 */
+    display: -ms-flexbox;
+    -ms-flex-align: stretch;
+    -ms-flex-direction: row;
+
+    /* Ancient Mozilla */
+    display: -moz-box;
+    -moz-box-align: stretch;
+    -moz-box-orient: horizontal;
+    
+    /* Ancient WebKit */
+    display: -webkit-box;
+    -webkit-box-align: stretch;
+    -webkit-box-orient: horizontal;
+
+    /* Old WebKit */
+    display: -webkit-flex;
+    -webkit-align-items: stretch;
+    -webkit-flex-direction: row;
+
+    /* W3C */
+    display: flex;
+    align-items: stretch;
+    flex-direction: row;
+
+}
+
+.header ~ * .header,
+.header ~ .header {
+    margin-top: 1em;
+    border-top: 1px solid rgba(0, 0, 0, 0.125);
+}
+
+.header h2 {
+    -ms-flex: 1 1 auto;
+    -moz-box-flex: 1;
+    -webkit-box-flex: 1;
+    -webkit-flex: 1 1 auto;
+    flex: 1 1 auto;
+}
+
+.header .filter {
+    margin: 0;
+    padding: 0.75em 0.5em;
+}
+
+.header .filter input {
+    -moz-border-radius: 0;
+    -webkit-border-radius: 0;
+    -khtml-border-radius: 0;
+    border-radius: 0;
+    border: none;
+    border-left: 1px solid rgba(0, 0, 0, 0.125);
+    background-color: transparent;
+}
diff --git a/guacamole/src/main/webapp/app/index/styles/input.css b/guacamole/src/main/webapp/app/index/styles/input.css
new file mode 100644
index 0000000..a912cd8
--- /dev/null
+++ b/guacamole/src/main/webapp/app/index/styles/input.css
@@ -0,0 +1,48 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+input[type=checkbox], input[type=number], input[type=text], input[type=radio], label, textarea {
+    -webkit-tap-highlight-color: rgba(128,192,128,0.5);
+}
+
+div.location, input[type=text], input[type=number], input[type=password], textarea {
+    border: 1px solid #777;
+    -moz-border-radius: 0.2em;
+    -webkit-border-radius: 0.2em;
+    -khtml-border-radius: 0.2em;
+    border-radius: 0.2em;
+    width: 100%;
+    max-width: 16em;
+    padding: 0.25em;
+    font-size: 0.8em;
+    background: white;
+    cursor: text;
+}
+
+textarea {
+    max-width: none;
+    width: 30em;
+    height: 10em;
+    white-space: pre;
+    word-wrap: normal;
+    overflow: auto;
+}
diff --git a/guacamole/src/main/webapp/app/index/styles/lists.css b/guacamole/src/main/webapp/app/index/styles/lists.css
new file mode 100644
index 0000000..25ca6d3
--- /dev/null
+++ b/guacamole/src/main/webapp/app/index/styles/lists.css
@@ -0,0 +1,96 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+.user,
+.group,
+.connection {
+    cursor: pointer;
+}
+
+.user a,
+.connection a,
+.group a {
+    text-decoration:none;
+    color: black;
+}
+
+.user a:hover,
+.connection a:hover,
+.group a:hover {
+    text-decoration:none;
+    color: black;
+}
+
+.user a:visited,
+.connection a:visited,
+.group a:visited {
+    text-decoration:none;
+    color: black;
+}
+
+.connection:hover {
+    background: #CDA;
+}
+
+.connection .thumbnail {
+    margin: 0.5em;
+}
+
+.connection .thumbnail > * {
+    border: 1px solid black;
+    background: black;
+    box-shadow: 1px 1px 5px black;
+    max-width: 75%;
+    display: inline-block;
+}
+
+div.recent-connections .connection .thumbnail {
+    display: block;
+}
+
+div.recent-connections .protocol {
+    display: none;
+}
+
+.caption * {
+    vertical-align: middle;
+}
+
+.caption .choice {
+    display: inline-block;
+}
+
+.caption .name {
+    margin-left: 0.25em;
+}
+
+.placeholder {
+
+    color: rgba(255, 255, 255, 0.5);
+    text-shadow: -1px -1px rgba(0, 0, 0, 0.5);
+    text-align: center;
+    opacity: 0.5;
+
+    font-size: 2em;
+    font-weight: bolder;
+
+}
diff --git a/guacamole/src/main/webapp/app/index/styles/loading.css b/guacamole/src/main/webapp/app/index/styles/loading.css
new file mode 100644
index 0000000..0cf0faf
--- /dev/null
+++ b/guacamole/src/main/webapp/app/index/styles/loading.css
@@ -0,0 +1,79 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+.loading {
+    position: relative;
+    min-height: 200px;
+}
+
+.view.loading {
+    position: fixed;
+    top: 0;
+    left: 0;
+    width: 100%;
+    height: 100%;
+}
+
+.loading * {
+    visibility: hidden;
+}
+
+.loading::before {
+
+    display: block;
+    position: absolute;
+    content: '';
+    
+    /* Dictated by size of image */
+    width: 96px;
+    height: 96px;
+    margin-left: -48px;
+    margin-top: -48px;
+    
+    top: 50%;
+    left: 50%;
+    
+    background-image: url('images/cog.png');
+    background-size: 96px 96px;
+    background-position: center center;
+    background-repeat: no-repeat;
+    
+    animation:         spinning-cog 4s linear infinite;
+    -moz-animation:    spinning-cog 4s linear infinite;
+    -webkit-animation: spinning-cog 4s linear infinite;
+    
+}
+
+ at keyframes spinning-cog {
+    0%   { transform: rotate(0deg);   }
+    100% { transform: rotate(360deg); }
+}
+
+ at -moz-keyframes spinning-cog {
+    0%   { -moz-transform: rotate(0deg);   }
+    100% { -moz-transform: rotate(360deg); }
+}
+
+ at -webkit-keyframes spinning-cog {
+    0%   { -webkit-transform: rotate(0deg);   }
+    100% { -webkit-transform: rotate(360deg); }
+}
diff --git a/guacamole/src/main/webapp/app/index/styles/sorted-tables.css b/guacamole/src/main/webapp/app/index/styles/sorted-tables.css
new file mode 100644
index 0000000..2c84bca
--- /dev/null
+++ b/guacamole/src/main/webapp/app/index/styles/sorted-tables.css
@@ -0,0 +1,64 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+table.sorted {
+    border-collapse: collapse;
+}
+
+table.sorted th {
+    background: rgba(0, 0, 0, 0.125);
+    font-weight: normal;
+}
+
+table.sorted th,
+table.sorted td {
+    border: 1px solid #AAA;
+    padding: 0.5em 1em;
+}
+
+table.sorted th.sortable {
+    cursor: pointer;
+}
+
+table.sorted th.sort-primary {
+    font-weight: bold;
+    padding-right: 0;
+}
+
+table.sorted th.sort-primary:after {
+
+    display: inline-block;
+    width: 1em;
+    height: 1em;
+    vertical-align: middle;
+    content: ' ';
+
+    background-size: 1em 1em;
+    background-position: right center;
+    background-repeat: no-repeat;
+    background-image: url('images/arrows/down.png');
+
+}
+
+table.sorted th.sort-primary.sort-descending:after {
+    background-image: url('images/arrows/up.png');
+}
diff --git a/guacamole/src/main/webapp/app/index/styles/status.css b/guacamole/src/main/webapp/app/index/styles/status.css
new file mode 100644
index 0000000..2b079cd
--- /dev/null
+++ b/guacamole/src/main/webapp/app/index/styles/status.css
@@ -0,0 +1,83 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+.status-outer {
+    display: table;
+    height: 100%;
+    width: 100%;
+    position: fixed;
+    left: 0;
+    top: 0;
+    background: rgba(0, 0, 0, 0.5);
+    z-index: 10;
+}
+
+.status-middle {
+    width: 100%;
+    text-align: center;
+    display: table-cell;
+    vertical-align: middle;
+}
+
+.status-middle .notification {
+
+    width: 75%;
+    max-width: 5in;
+    margin-left: auto;
+    margin-right: auto;
+    overflow: auto;
+
+    text-align: left;
+    
+}
+
+.status-middle .notification .body {
+    margin: 1.25em;
+}
+
+.status-middle .notification .buttons {
+    margin: 1em;
+}
+
+/* Fade entire status area in/out based on shown status */
+
+.status-outer {
+    visibility: hidden;
+    opacity: 0;
+    transition: opacity, visibility;
+    transition-duration: 0.25s;
+}
+
+.shown.status-outer {
+    visibility: visible;
+    opacity: 1;
+}
+
+/* Hide dialog immediately based on status */
+
+.status-middle .notification {
+    visibility: hidden;
+}
+
+.shown .status-middle .notification {
+    visibility: visible;
+}
diff --git a/guacamole/src/main/webapp/app/index/styles/ui.css b/guacamole/src/main/webapp/app/index/styles/ui.css
new file mode 100644
index 0000000..a7a92ae
--- /dev/null
+++ b/guacamole/src/main/webapp/app/index/styles/ui.css
@@ -0,0 +1,237 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+* {
+    -webkit-tap-highlight-color: rgba(0,0,0,0);
+    -webkit-box-sizing: border-box;
+    -moz-box-sizing: border-box;
+    box-sizing: border-box;
+}
+
+body {
+    background: white;
+    font-family: Carlito, FreeSans, Helvetica, Arial, sans-serif;
+    padding: 0;
+    margin: 0;
+}
+
+img {
+    border: none;
+    vertical-align: middle;
+}
+
+div.section {
+    margin: 1em;
+    padding: 0;
+}
+
+/*
+ * List elements
+ */
+
+.list-item {
+
+    display: block;
+    text-align: left;
+    cursor: pointer;
+
+    position: relative;
+
+}
+
+.icon {
+    width: 24px;
+    height: 24px;
+    background-size: 16px 16px;
+    -moz-background-size: 16px 16px;
+    -webkit-background-size: 16px 16px;
+    -khtml-background-size: 16px 16px;
+    background-repeat: no-repeat;
+    background-position: center center;
+    display: inline-block;
+    vertical-align: middle;
+}
+
+.list-item * {
+    vertical-align: middle;
+}
+
+.list-item .caption {
+    padding: 0.1em;
+}
+
+.list-item .caption:after {
+    clear: right;
+    content: "";
+    display: block;
+}
+
+.list-item .name {
+    color: black;
+    font-weight: normal;
+    padding: 0.1em;
+    margin-left: 0.25em;
+}
+
+.list-item .usage {
+    float: right;
+    font-style: italic;
+    color: gray;
+}
+
+.list-item.in-use {
+    opacity: 0.5;
+}
+
+.choice .list-item.in-use {
+    opacity: 1;
+}
+
+/*
+ * List element styling
+ */
+
+.list-item.selected {
+    background: #DEB;
+}
+
+.caption.active * {
+    opacity: 0.5;
+}
+
+.caption .activeUserCount {
+    font-style: italic;
+    margin-right: 1em;
+    float: right;
+}
+
+.list-item:not(.selected) .caption:hover {
+    background: #CDA;
+}
+
+.choice .list-item {
+    display: inline-block;
+}
+
+.choice input[type='checkbox'] {
+    vertical-align: top;
+    height: 24px;
+    padding: 0;
+    margin: 0;
+}
+
+.disabled .list-item:not(.selected) {
+    opacity: 0.25;
+}
+
+.disabled .list-item:not(.selected):hover {
+    background: inherit;
+}
+
+/*
+ * List element icons
+ */
+
+.icon.user {
+    background-image: url('images/user-icons/guac-user.png');
+}
+
+.icon.user.add {
+    background-image: url('images/action-icons/guac-user-add.png');
+}
+
+.icon.connection {
+    background-image: url('images/protocol-icons/guac-plug.png');
+}
+
+.icon.connection.add {
+    background-image: url('images/action-icons/guac-monitor-add.png');
+}
+
+.protocol {
+    display: inline-block;
+}
+
+.protocol .icon {
+    width: 24px;
+    height: 24px;
+    background-image: url('images/protocol-icons/guac-plug.png');
+    background-size: 16px 16px;
+    -moz-background-size: 16px 16px;
+    -webkit-background-size: 16px 16px;
+    -khtml-background-size: 16px 16px;
+    background-repeat: no-repeat;
+    background-position: center center;
+}
+
+.protocol .icon.ssh,
+.protocol .icon.telnet {
+    background-image: url('images/protocol-icons/guac-text.png');
+}
+
+.protocol .icon.vnc,
+.protocol .icon.rdp {
+    background-image: url('images/protocol-icons/guac-monitor.png');
+}
+/*
+ * Groups
+ */
+
+.group > .children {
+    margin-left: 13px;
+    padding-left: 6px;
+}
+ 
+.group .icon.group.type.empty.balancer {
+    opacity: 1;
+    background-image: url('images/protocol-icons/guac-monitor.png');
+}
+
+.group.expanded > .children {
+    display: block;
+    border-left: 1px dotted rgba(0, 0, 0, 0.25);
+}
+
+.group > .caption .icon.group {
+    opacity: 0.75;
+    background-image: url('images/group-icons/guac-closed.png');
+}
+
+.group .icon.type.group.expanded {
+    background-image: url('images/group-icons/guac-open.png');
+}
+
+.group .icon.type.group.empty {
+    opacity: 0.25;
+    background-image: url('images/group-icons/guac-open.png');
+}
+
+.history th,
+.history td {
+    padding-left: 1em;
+    padding-right: 1em;
+}
+
+.buttons {
+    text-align: center;
+    margin: 1em;
+}
diff --git a/guacamole/src/main/webapp/app/list/directives/guacFilter.js b/guacamole/src/main/webapp/app/list/directives/guacFilter.js
new file mode 100644
index 0000000..cbca761
--- /dev/null
+++ b/guacamole/src/main/webapp/app/list/directives/guacFilter.js
@@ -0,0 +1,117 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * A directive which provides a filtering text input field which automatically
+ * produces a filtered subset of the elements of some given array.
+ */
+angular.module('list').directive('guacFilter', [function guacFilter() {
+
+    return {
+        restrict: 'E',
+        replace: true,
+        scope: {
+
+            /**
+             * The property to which a subset of the provided array will be
+             * assigned.
+             *
+             * @type Array
+             */
+            filteredItems : '=',
+
+            /**
+             * The placeholder text to display within the filter input field
+             * when no filter has been provided.
+             * 
+             * @type String
+             */
+            placeholder : '&',
+
+            /**
+             * An array of objects to filter. A subset of this array will be
+             * exposed as filteredItems.
+             *
+             * @type Array
+             */
+            items : '&',
+
+            /**
+             * An array of expressions to filter against for each object in the
+             * items array. These expressions must be Angular expressions
+             * which resolve to properties on the objects in the items array.
+             *
+             * @type String[]
+             */
+            properties : '&'
+
+        },
+
+        templateUrl: 'app/list/templates/guacFilter.html',
+        controller: ['$scope', '$injector', function guacFilterController($scope, $injector) {
+
+            // Required types
+            var FilterPattern = $injector.get('FilterPattern');
+
+            /**
+             * The pattern object to use when filtering items.
+             *
+             * @type FilterPattern
+             */
+            var filterPattern = new FilterPattern($scope.properties());
+
+            /**
+             * The filter search string to use to restrict the displayed items.
+             *
+             * @type String
+             */
+            $scope.searchString = null;
+
+            /**
+             * Applies the current filter predicate, filtering all provided
+             * items and storing the result in filteredItems.
+             */
+            var updateFilteredItems = function updateFilteredItems() {
+
+                var items = $scope.items();
+                if (items)
+                    $scope.filteredItems = items.filter(filterPattern.predicate);
+                else
+                    $scope.filteredItems = [];
+
+            };
+
+            // Recompile and refilter when pattern is changed
+            $scope.$watch('searchString', function searchStringChanged(searchString) {
+                filterPattern.compile(searchString);
+                updateFilteredItems();
+            });
+
+            // Refilter when items change
+            $scope.$watchCollection($scope.items, function itemsChanged() {
+                updateFilteredItems();
+            });
+
+        }]
+
+    };
+}]);
diff --git a/guacamole/src/main/webapp/app/list/directives/guacPager.js b/guacamole/src/main/webapp/app/list/directives/guacPager.js
new file mode 100644
index 0000000..8031ddd
--- /dev/null
+++ b/guacamole/src/main/webapp/app/list/directives/guacPager.js
@@ -0,0 +1,303 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * A directive which provides pagination controls, along with a paginated
+ * subset of the elements of some given array.
+ */
+angular.module('list').directive('guacPager', [function guacPager() {
+
+    return {
+        restrict: 'E',
+        replace: true,
+        scope: {
+
+            /**
+             * The property to which a subset of the provided array will be
+             * assigned.
+             *
+             * @type Array
+             */
+            page : '=',
+
+            /**
+             * The maximum number of items per page.
+             *
+             * @type Number
+             */
+            pageSize : '&',
+
+            /**
+             * The maximum number of page choices to provide, regardless of the
+             * total number of pages.
+             *
+             * @type Number
+             */
+            pageCount : '&',
+
+            /**
+             * An array objects to paginate. Subsets of this array will be
+             * exposed as pages.
+             *
+             * @type Array
+             */
+            items : '&'
+
+        },
+
+        templateUrl: 'app/list/templates/guacPager.html',
+        controller: ['$scope', function guacPagerController($scope) {
+
+            /**
+             * The default size of a page, if not provided via the pageSize
+             * attribute.
+             *
+             * @type Number
+             */
+            var DEFAULT_PAGE_SIZE = 10;
+
+            /**
+             * The default maximum number of page choices to provide, if a
+             * value is not providede via the pageCount attribute.
+             *
+             * @type Number
+             */
+            var DEFAULT_PAGE_COUNT = 11;
+
+            /**
+             * An array of arrays, where the Nth array contains the contents of
+             * the Nth page.
+             *
+             * @type Array[]
+             */
+            var pages = [];
+
+            /**
+             * The number of the first selectable page.
+             *
+             * @type Number;
+             */
+            $scope.firstPage = 1;
+
+            /**
+             * The number of the page immediately before the currently-selected
+             * page.
+             *
+             * @type Number;
+             */
+            $scope.previousPage = 1;
+
+            /**
+             * The number of the currently-selected page.
+             *
+             * @type Number;
+             */
+            $scope.currentPage = 1;
+
+            /**
+             * The number of the page immediately after the currently-selected
+             * page.
+             *
+             * @type Number;
+             */
+            $scope.nextPage = 1;
+
+            /**
+             * The number of the last selectable page.
+             *
+             * @type Number;
+             */
+            $scope.lastPage = 1;
+
+            /**
+             * An array of relevant page numbers that the user may want to jump
+             * to directly.
+             *
+             * @type Number[]
+             */
+            $scope.pageNumbers = [];
+
+            /**
+             * Updates the displayed page number choices.
+             */
+            var updatePageNumbers = function updatePageNumbers() {
+
+                // Get page count
+                var pageCount = $scope.pageCount() || DEFAULT_PAGE_COUNT;
+
+                // Determine start/end of page window
+                var windowStart = $scope.currentPage - (pageCount - 1) / 2;
+                var windowEnd   = windowStart + pageCount - 1;
+
+                // Shift window as necessary if it extends beyond the first page
+                if (windowStart < $scope.firstPage) {
+                    windowEnd = Math.min($scope.lastPage, windowEnd - windowStart + $scope.firstPage);
+                    windowStart = $scope.firstPage;
+                }
+
+                // Shift window as necessary if it extends beyond the last page
+                else if (windowEnd > $scope.lastPage) {
+                    windowStart = Math.max(1, windowStart - windowEnd + $scope.lastPage);
+                    windowEnd = $scope.lastPage;
+                }
+
+                // Generate list of relevant page numbers
+                $scope.pageNumbers = [];
+                for (var pageNumber = windowStart; pageNumber <= windowEnd; pageNumber++)
+                    $scope.pageNumbers.push(pageNumber);
+
+            };
+
+            /**
+             * Iterates through the bound items array, splitting it into pages
+             * based on the current page size.
+             */
+            var updatePages = function updatePages() {
+
+                // Get current items and page size
+                var items = $scope.items();
+                var pageSize = $scope.pageSize() || DEFAULT_PAGE_SIZE;
+
+                // Clear current pages
+                pages = [];
+
+                // Only split into pages if items actually exist
+                if (items) {
+
+                    // Split into pages of pageSize items each
+                    for (var i = 0; i < items.length; i += pageSize)
+                        pages.push(items.slice(i, i + pageSize));
+
+                }
+
+                // Update minimum and maximum values
+                $scope.firstPage = 1;
+                $scope.lastPage  = pages.length;
+
+                // Select an appropriate page
+                var adjustedCurrentPage = Math.min($scope.lastPage, Math.max($scope.firstPage, $scope.currentPage));
+                $scope.selectPage(adjustedCurrentPage);
+
+            };
+
+            /**
+             * Selects the page having the given number, assigning that page to
+             * the property bound to the page attribute. If no such page
+             * exists, the property will be set to undefined instead. Valid
+             * page numbers begin at 1.
+             *
+             * @param {Number} page
+             *     The number of the page to select. Valid page numbers begin
+             *     at 1.
+             */
+            $scope.selectPage = function selectPage(page) {
+
+                // Select the chosen page
+                $scope.currentPage = page;
+                $scope.page = pages[page-1];
+
+                // Update next/previous page numbers
+                $scope.nextPage     = Math.min($scope.lastPage,  $scope.currentPage + 1);
+                $scope.previousPage = Math.max($scope.firstPage, $scope.currentPage - 1);
+
+                // Update which page numbers are shown
+                updatePageNumbers();
+
+            };
+
+            /**
+             * Returns whether the given page number can be legally selected
+             * via selectPage(), resulting in a different page being shown.
+             *
+             * @param {Number} page
+             *     The page number to check.
+             *
+             * @returns {Boolean}
+             *     true if the page having the given number can be selected,
+             *     false otherwise.
+             */
+            $scope.canSelectPage = function canSelectPage(page) {
+                return page !== $scope.currentPage
+                    && page >=  $scope.firstPage
+                    && page <=  $scope.lastPage;
+            };
+
+            /**
+             * Returns whether the page having the given number is currently
+             * selected.
+             *
+             * @param {Number} page
+             *     The page number to check.
+             *
+             * @returns {Boolean}
+             *     true if the page having the given number is currently
+             *     selected, false otherwise.
+             */
+            $scope.isSelected = function isSelected(page) {
+                return page === $scope.currentPage;
+            };
+
+            /**
+             * Returns whether pages exist before the first page listed in the
+             * pageNumbers array.
+             *
+             * @returns {Boolean}
+             *     true if pages exist before the first page listed in the
+             *     pageNumbers array, false otherwise.
+             */
+            $scope.hasMorePagesBefore = function hasMorePagesBefore() {
+                var firstPageNumber = $scope.pageNumbers[0];
+                return firstPageNumber !== $scope.firstPage;
+            };
+
+            /**
+             * Returns whether pages exist after the last page listed in the
+             * pageNumbers array.
+             *
+             * @returns {Boolean}
+             *     true if pages exist after the last page listed in the
+             *     pageNumbers array, false otherwise.
+             */
+            $scope.hasMorePagesAfter = function hasMorePagesAfter() {
+                var lastPageNumber = $scope.pageNumbers[$scope.pageNumbers.length - 1];
+                return lastPageNumber !== $scope.lastPage;
+            };
+
+            // Update available pages when available items are changed
+            $scope.$watchCollection($scope.items, function itemsChanged() {
+                updatePages();
+            });
+
+            // Update available pages when page size is changed
+            $scope.$watch($scope.pageSize, function pageSizeChanged() {
+                updatePages();
+            });
+
+            // Update available page numbers when page count is changed
+            $scope.$watch($scope.pageCount, function pageCountChanged() {
+                updatePageNumbers();
+            });
+
+        }]
+
+    };
+}]);
diff --git a/guacamole/src/main/webapp/app/list/directives/guacSortOrder.js b/guacamole/src/main/webapp/app/list/directives/guacSortOrder.js
new file mode 100644
index 0000000..ea5a5c9
--- /dev/null
+++ b/guacamole/src/main/webapp/app/list/directives/guacSortOrder.js
@@ -0,0 +1,104 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Updates the priority of the sorting property given by "guac-sort-property"
+ * within the SortOrder object given by "guac-sort-order". The CSS classes
+ * "sort-primary" and "sort-descending" will be applied to the associated
+ * element depending on the priority and sort direction of the given property.
+ * 
+ * The associated element will automatically be assigned the "sortable" CSS
+ * class.
+ */
+angular.module('list').directive('guacSortOrder', [function guacFocus() {
+
+    return {
+        restrict: 'A',
+
+        link: function linkGuacSortOrder($scope, $element, $attrs) {
+
+            /**
+             * The object defining the sorting order.
+             *
+             * @type SortOrder
+             */
+            var sortOrder = $scope.$eval($attrs.guacSortOrder);
+
+            /**
+             * The name of the property whose priority within the sort order
+             * is controlled by this directive.
+             *
+             * @type String
+             */
+            var sortProperty = $scope.$eval($attrs.guacSortProperty);
+
+            /**
+             * Returns whether the sort property defined via the
+             * "guac-sort-property" attribute is the primary sort property of
+             * the associated sort order.
+             *
+             * @returns {Boolean}
+             *     true if the sort property defined via the
+             *     "guac-sort-property" attribute is the primary sort property,
+             *     false otherwise.
+             */
+            var isPrimary = function isPrimary() {
+                return sortOrder.primary === sortProperty;
+            };
+
+            /**
+             * Returns whether the primary property of the sort order is
+             * sorted in descending order.
+             *
+             * @returns {Boolean}
+             *     true if the primary property of the sort order is sorted in
+             *     descending order, false otherwise.
+             */
+            var isDescending = function isDescending() {
+                return sortOrder.descending;
+            };
+
+            // Assign "sortable" class to associated element
+            $element.addClass('sortable');
+
+            // Add/remove "sort-primary" class depending on sort order
+            $scope.$watch(isPrimary, function primaryChanged(primary) {
+                $element.toggleClass('sort-primary', primary);
+            });
+
+            // Add/remove "sort-descending" class depending on sort order
+            $scope.$watch(isDescending, function descendingChanged(descending) {
+                $element.toggleClass('sort-descending', descending);
+            });
+
+            // Update sort order when clicked
+            $element[0].addEventListener('click', function clicked() {
+                $scope.$evalAsync(function updateSortOrder() {
+                    sortOrder.togglePrimary(sortProperty);
+                });
+            });
+
+        } // end guacSortOrder link function
+
+    };
+
+}]);
diff --git a/guacamole/src/main/webapp/app/list/listModule.js b/guacamole/src/main/webapp/app/list/listModule.js
new file mode 100644
index 0000000..4273aca
--- /dev/null
+++ b/guacamole/src/main/webapp/app/list/listModule.js
@@ -0,0 +1,27 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Module for displaying, sorting, and filtering the contents of a list, split
+ * into multiple pages.
+ */
+angular.module('list', []);
diff --git a/guacamole/src/main/webapp/app/list/styles/filter.css b/guacamole/src/main/webapp/app/list/styles/filter.css
new file mode 100644
index 0000000..b319750
--- /dev/null
+++ b/guacamole/src/main/webapp/app/list/styles/filter.css
@@ -0,0 +1,36 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+.filter {
+    margin: 0.5em 0;
+}
+
+.filter .search-string {
+    background-image: url('images/magnifier.png');
+    background-repeat: no-repeat;
+    background-size: 1.75em;
+    background-position: 0.25em center;
+    padding: 0.5em;
+    padding-left: 2.25em;
+    width: 100%;
+    max-width: none;
+}
diff --git a/guacamole/src/main/webapp/app/list/styles/pager.css b/guacamole/src/main/webapp/app/list/styles/pager.css
new file mode 100644
index 0000000..bb0b922
--- /dev/null
+++ b/guacamole/src/main/webapp/app/list/styles/pager.css
@@ -0,0 +1,90 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+.pager {
+    text-align: center;
+    margin: 1em;
+}
+
+.pager .page-numbers {
+    display: inline-block;
+    margin: 0;
+    padding: 0;
+}
+
+.pager .first-page,
+.pager .prev-page,
+.pager .set-page,
+.pager .next-page,
+.pager .last-page {
+    cursor: pointer;
+    vertical-align: middle;
+}
+
+.pager .first-page.disabled,
+.pager .prev-page.disabled,
+.pager .set-page.disabled,
+.pager .next-page.disabled,
+.pager .last-page.disabled {
+    cursor: auto;
+    opacity: 0.25;
+}
+
+.pager .set-page,
+.pager .more-pages {
+    display: inline-block;
+    padding: 0.25em;
+    text-align: center;
+    min-width: 1.25em;
+}
+
+.pager .set-page {
+    text-decoration: underline;
+}
+
+.pager .set-page.current {
+    cursor: auto;
+    text-decoration: none;
+    font-weight: bold;
+    background: rgba(0, 0, 0, 0.1);
+    border: 1px solid rgba(0, 0, 0, 0.1);
+    -moz-border-radius: 0.2em;
+    -webkit-border-radius: 0.2em;
+    -khtml-border-radius: 0.2em;
+    border-radius: 0.2em;
+}
+
+.pager .icon.first-page {
+    background-image: url('images/action-icons/guac-first-page.png');
+}
+
+.pager .icon.prev-page {
+    background-image: url('images/action-icons/guac-prev-page.png');
+}
+
+.pager .icon.next-page {
+    background-image: url('images/action-icons/guac-next-page.png');
+}
+
+.pager .icon.last-page {
+    background-image: url('images/action-icons/guac-last-page.png');
+}
diff --git a/guacamole/src/main/webapp/app/list/templates/guacFilter.html b/guacamole/src/main/webapp/app/list/templates/guacFilter.html
new file mode 100644
index 0000000..a5eeb0c
--- /dev/null
+++ b/guacamole/src/main/webapp/app/list/templates/guacFilter.html
@@ -0,0 +1,27 @@
+<div class="filter">
+    <!--
+       Copyright (C) 2015 Glyptodon LLC
+
+       Permission is hereby granted, free of charge, to any person obtaining a copy
+       of this software and associated documentation files (the "Software"), to deal
+       in the Software without restriction, including without limitation the rights
+       to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+       copies of the Software, and to permit persons to whom the Software is
+       furnished to do so, subject to the following conditions:
+
+       The above copyright notice and this permission notice shall be included in
+       all copies or substantial portions of the Software.
+
+       THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+       IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+       FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+       AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+       LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+       OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+       THE SOFTWARE.
+    -->
+
+    <!-- Filter string -->
+    <input class="search-string" placeholder="{{placeholder()}}" type="text" ng-model="searchString"/>
+
+</div>
diff --git a/guacamole/src/main/webapp/app/list/templates/guacPager.html b/guacamole/src/main/webapp/app/list/templates/guacPager.html
new file mode 100644
index 0000000..0ebe152
--- /dev/null
+++ b/guacamole/src/main/webapp/app/list/templates/guacPager.html
@@ -0,0 +1,46 @@
+<div class="pager" ng-show="pageNumbers.length > 1">
+    <!--
+       Copyright (C) 2015 Glyptodon LLC
+
+       Permission is hereby granted, free of charge, to any person obtaining a copy
+       of this software and associated documentation files (the "Software"), to deal
+       in the Software without restriction, including without limitation the rights
+       to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+       copies of the Software, and to permit persons to whom the Software is
+       furnished to do so, subject to the following conditions:
+
+       The above copyright notice and this permission notice shall be included in
+       all copies or substantial portions of the Software.
+
+       THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+       IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+       FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+       AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+       LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+       OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+       THE SOFTWARE.
+    -->
+
+    <!-- First / Previous -->
+    <div class="first-page icon" ng-class="{disabled: !canSelectPage(firstPage)}"    ng-click="selectPage(firstPage)"/>
+    <div class="prev-page icon"  ng-class="{disabled: !canSelectPage(previousPage)}" ng-click="selectPage(previousPage)"/>
+
+    <!-- Indicator of the existence of pages before the first page number shown -->
+    <div class="more-pages" ng-show="hasMorePagesBefore()">...</div>
+    
+    <!-- Page numbers -->
+    <ul class="page-numbers">
+        <li class="set-page"
+            ng-class="{current: isSelected(pageNumber)}"
+            ng-repeat="pageNumber in pageNumbers"
+            ng-click="selectPage(pageNumber)">{{pageNumber}}</li>
+    </ul>
+
+    <!-- Indicator of the existence of pages beyond the last page number shown -->
+    <div class="more-pages" ng-show="hasMorePagesAfter()">...</div>
+
+    <!-- Next / Last -->
+    <div class="next-page icon" ng-class="{disabled: !canSelectPage(nextPage)}" ng-click="selectPage(nextPage)"/>
+    <div class="last-page icon" ng-class="{disabled: !canSelectPage(lastPage)}" ng-click="selectPage(lastPage)"/>
+
+</div>
diff --git a/guacamole/src/main/webapp/app/list/types/FilterPattern.js b/guacamole/src/main/webapp/app/list/types/FilterPattern.js
new file mode 100644
index 0000000..fa42e2d
--- /dev/null
+++ b/guacamole/src/main/webapp/app/list/types/FilterPattern.js
@@ -0,0 +1,258 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * A service for defining the FilterPattern class.
+ */
+angular.module('list').factory('FilterPattern', ['$injector',
+    function defineFilterPattern($injector) {
+
+    // Required types
+    var FilterToken = $injector.get('FilterToken');
+    var IPv4Network = $injector.get('IPv4Network');
+    var IPv6Network = $injector.get('IPv6Network');
+
+    // Required services
+    var $parse = $injector.get('$parse');
+
+    /**
+     * Object which handles compilation of filtering predicates as used by
+     * the Angular "filter" filter. Predicates are compiled from a user-
+     * specified search string.
+     *
+     * @constructor
+     * @param {String[]} expressions 
+     *     The Angular expressions whose values are to be filtered.
+     */
+    var FilterPattern = function FilterPattern(expressions) {
+
+        /**
+         * Reference to this instance.
+         *
+         * @type FilterPattern
+         */
+        var filterPattern = this;
+
+        /**
+         * Filter predicate which simply matches everything. This function
+         * always returns true.
+         *
+         * @returns {Boolean}
+         *     true.
+         */
+        var nullPredicate = function nullPredicate() {
+            return true;
+        };
+
+        /**
+         * Array of getters corresponding to the Angular expressions provided
+         * to the constructor of this class. The functions returns are those
+         * produced by the $parse service.
+         *
+         * @type Function[]
+         */
+        var getters = [];
+
+        // Parse all expressions
+        angular.forEach(expressions, function parseExpression(expression) {
+            getters.push($parse(expression));
+        });
+
+        /**
+         * Determines whether the given object contains properties that match
+         * the given string, according to the provided getters.
+         * 
+         * @param {Object} object
+         *     The object to match against.
+         * 
+         * @param {String} str
+         *     The string to match.
+         *
+         * @returns {Boolean}
+         *     true if the object matches the given string, false otherwise. 
+         */
+        var matchesString = function matchesString(object, str) {
+
+            // For each defined getter
+            for (var i=0; i < getters.length; i++) {
+
+                // Retrieve value of current getter
+                var value = getters[i](object);
+
+                // If the value matches the pattern, the whole object matches
+                if (String(value).toLowerCase().indexOf(str) !== -1) 
+                    return true;
+
+            }
+
+            // No matches found
+            return false;
+
+        };
+
+        /**
+         * Determines whether the given object contains properties that match
+         * the given IPv4 network, according to the provided getters.
+         * 
+         * @param {Object} object
+         *     The object to match against.
+         * 
+         * @param {IPv4Network} network
+         *     The IPv4 network to match.
+         *
+         * @returns {Boolean}
+         *     true if the object matches the given network, false otherwise. 
+         */
+        var matchesIPv4 = function matchesIPv4(object, network) {
+
+            // For each defined getter
+            for (var i=0; i < getters.length; i++) {
+
+                // Test value against IPv4 network
+                var value = IPv4Network.parse(String(getters[i](object)));
+                if (value && network.contains(value))
+                    return true;
+
+            }
+
+            // No matches found
+            return false;
+
+        };
+
+        /**
+         * Determines whether the given object contains properties that match
+         * the given IPv6 network, according to the provided getters.
+         * 
+         * @param {Object} object
+         *     The object to match against.
+         * 
+         * @param {IPv6Network} network
+         *     The IPv6 network to match.
+         *
+         * @returns {Boolean}
+         *     true if the object matches the given network, false otherwise. 
+         */
+        var matchesIPv6 = function matchesIPv6(object, network) {
+
+            // For each defined getter
+            for (var i=0; i < getters.length; i++) {
+
+                // Test value against IPv6 network
+                var value = IPv6Network.parse(String(getters[i](object)));
+                if (value && network.contains(value))
+                    return true;
+
+            }
+
+            // No matches found
+            return false;
+
+        };
+
+
+        /**
+         * Determines whether the given object matches the given filter pattern
+         * token.
+         *
+         * @param {Object} object
+         *     The object to match the token against.
+         * 
+         * @param {FilterToken} token
+         *     The token from the tokenized filter pattern to match aginst the
+         *     given object.
+         *
+         * @returns {Boolean}
+         *     true if the object matches the token, false otherwise.
+         */
+        var matchesToken = function matchesToken(object, token) {
+
+            // Match depending on token type
+            switch (token.type) {
+
+                // Simple string literal
+                case 'LITERAL': 
+                    return matchesString(object, token.value);
+
+                // IPv4 network address / subnet
+                case 'IPV4_NETWORK': 
+                    return matchesIPv4(object, token.value);
+
+                // IPv6 network address / subnet
+                case 'IPV6_NETWORK': 
+                    return matchesIPv6(object, token.value);
+
+                // Unsupported token type
+                default:
+                    return false;
+
+            }
+
+        };
+
+        /**
+         * The current filtering predicate.
+         *
+         * @type Function
+         */
+        this.predicate = nullPredicate;
+
+        /**
+         * Compiles the given pattern string, assigning the resulting filter
+         * predicate. The resulting predicate will accept only objects that
+         * match the given pattern.
+         * 
+         * @param {String} pattern
+         *     The pattern to compile.
+         */
+        this.compile = function compile(pattern) {
+
+            // If no pattern provided, everything matches
+            if (!pattern) {
+                filterPattern.predicate = nullPredicate;
+                return;
+            }
+                
+            // Tokenize pattern, converting to lower case for case-insensitive matching
+            var tokens = FilterToken.tokenize(pattern.toLowerCase());
+
+            // Return predicate which matches against the value of any getter in the getters array
+            filterPattern.predicate = function matchesAllTokens(object) {
+
+                // False if any token does not match
+                for (var i=0; i < tokens.length; i++) {
+                    if (!matchesToken(object, tokens[i]))
+                        return false;
+                }
+
+                // True if all tokens matched
+                return true;
+
+            };
+            
+        };
+
+    };
+
+    return FilterPattern;
+
+}]);
\ No newline at end of file
diff --git a/guacamole/src/main/webapp/app/list/types/FilterToken.js b/guacamole/src/main/webapp/app/list/types/FilterToken.js
new file mode 100644
index 0000000..915f072
--- /dev/null
+++ b/guacamole/src/main/webapp/app/list/types/FilterToken.js
@@ -0,0 +1,232 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * A service for defining the FilterToken class.
+ */
+angular.module('list').factory('FilterToken', ['$injector',
+    function defineFilterToken($injector) {
+
+    // Required types
+    var IPv4Network = $injector.get('IPv4Network');
+    var IPv6Network = $injector.get('IPv6Network');
+
+    /**
+     * An arbitrary token having an associated type and value.
+     *
+     * @constructor
+     * @param {String} consumed
+     *     The input string consumed to produce this token.
+     *
+     * @param {String} type
+     *     The type of this token. Each legal type name is a property within
+     *     FilterToken.Types.
+     *
+     * @param {Object} value
+     *     The value of this token. The type of this value is determined by
+     *     the token type.
+     */
+    var FilterToken = function FilterToken(consumed, type, value) {
+
+        /**
+         * The input string that was consumed to produce this token.
+         *
+         * @type String
+         */
+        this.consumed = consumed;
+
+        /**
+         * The type of this token. Each legal type name is a property within
+         * FilterToken.Types.
+         *
+         * @type String
+         */
+        this.type = type;
+
+        /**
+         * The value of this token.
+         *
+         * @type Object
+         */
+        this.value = value;
+
+    };
+
+    /**
+     * All legal token types, and corresponding functions which match them.
+     * Each function returns the parsed token, or null if no such token was
+     * found.
+     *
+     * @type Object.<String, Function>
+     */
+    FilterToken.Types = {
+
+        /**
+         * An IPv4 address or subnet. The value of an IPV4_NETWORK token is an
+         * IPv4Network.
+         */
+        IPV4_NETWORK: function parseIPv4(str) {
+
+            var pattern = /^\S+/;
+
+            // Read first word via regex
+            var matches = pattern.exec(str);
+            if (!matches)
+                return null;
+
+            // Validate and parse as IPv4 address
+            var network = IPv4Network.parse(matches[0]);
+            if (!network)
+                return null;
+
+            return new FilterToken(matches[0], 'IPV4_NETWORK', network);
+
+        },
+
+        /**
+         * An IPv6 address or subnet. The value of an IPV6_NETWORK token is an
+         * IPv6Network.
+         */
+        IPV6_NETWORK: function parseIPv6(str) {
+
+            var pattern = /^\S+/;
+
+            // Read first word via regex
+            var matches = pattern.exec(str);
+            if (!matches)
+                return null;
+
+            // Validate and parse as IPv6 address
+            var network = IPv6Network.parse(matches[0]);
+            if (!network)
+                return null;
+
+            return new FilterToken(matches[0], 'IPV6_NETWORK', network);
+
+        },
+
+        /**
+         * A string literal, which may be quoted. The value of a LITERAL token
+         * is a String.
+         */
+        LITERAL: function parseLiteral(str) {
+
+            var pattern = /^"([^"]*)"|^\S+/;
+
+            // Validate against pattern
+            var matches = pattern.exec(str);
+            if (!matches)
+                return null;
+
+            // If literal is quoted, parse within the quotes
+            if (matches[1])
+                return new FilterToken(matches[0], 'LITERAL', matches[1]);
+
+            //  Otherwise, literal is unquoted
+            return new FilterToken(matches[0], 'LITERAL', matches[0]);
+
+        },
+
+        /**
+         * Arbitrary contiguous whitespace. The value of a WHITESPACE token is
+         * a String.
+         */
+        WHITESPACE: function parseWhitespace(str) {
+
+            var pattern = /^\s+/;
+
+            // Validate against pattern
+            var matches = pattern.exec(str);
+            if (!matches)
+                return null;
+
+            //  Generate token from matching whitespace
+            return new FilterToken(matches[0], 'WHITESPACE', matches[0]);
+
+        }
+
+    };
+
+    /**
+     * Tokenizes the given string, returning an array of tokens. Whitespace
+     * tokens are dropped.
+     *
+     * @param {String} str
+     *     The string to tokenize.
+     *
+     * @returns {FilterToken[]}
+     *     All tokens identified within the given string, in order.
+     */
+    FilterToken.tokenize = function tokenize(str) {
+
+        var tokens = [];
+
+        /**
+         * Returns the first token on the current string, removing the token
+         * from that string.
+         *
+         * @returns FilterToken
+         *     The first token on the string, or null if no tokens match.
+         */
+        var popToken = function popToken() {
+
+            // Attempt to find a matching token
+            for (var type in FilterToken.Types) {
+
+                // Get matching function for current type
+                var matcher = FilterToken.Types[type];
+
+                // If token matches, return the matching group
+                var token = matcher(str);
+                if (token) {
+                    str = str.substring(token.consumed.length);
+                    return token;
+                }
+
+            }
+
+            // No match
+            return null;
+
+        };
+
+        // Tokenize input until no input remains
+        while (str) {
+
+            // Remove first token
+            var token = popToken();
+            if (!token)
+                break;
+
+            // Add token to tokens array, if not whitespace
+            if (token.type !== 'WHITESPACE')
+                tokens.push(token);
+
+        }
+
+        return tokens;
+
+    };
+
+    return FilterToken;
+
+}]);
\ No newline at end of file
diff --git a/guacamole/src/main/webapp/app/list/types/IPv4Network.js b/guacamole/src/main/webapp/app/list/types/IPv4Network.js
new file mode 100644
index 0000000..6ee8ff1
--- /dev/null
+++ b/guacamole/src/main/webapp/app/list/types/IPv4Network.js
@@ -0,0 +1,129 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * A service for defining the IPv4Network class.
+ */
+angular.module('list').factory('IPv4Network', [
+    function defineIPv4Network() {
+
+    /**
+     * Represents an IPv4 network as a pairing of base address and netmask,
+     * both of which are in binary form. To obtain an IPv4Network from
+     * standard CIDR or dot-decimal notation, use IPv4Network.parse().
+     *
+     * @constructor 
+     * @param {Number} address
+     *     The IPv4 address of the network in binary form.
+     *
+     * @param {Number} netmask
+     *     The IPv4 netmask of the network in binary form.
+     */
+    var IPv4Network = function IPv4Network(address, netmask) {
+
+        /**
+         * Reference to this IPv4Network.
+         *
+         * @type IPv4Network
+         */
+        var network = this;
+
+        /**
+         * The binary address of this network. This will be a 32-bit quantity.
+         *
+         * @type Number
+         */
+        this.address = address;
+
+        /**
+         * The binary netmask of this network. This will be a 32-bit quantity.
+         *
+         * @type Number
+         */
+        this.netmask = netmask;
+
+        /**
+         * Tests whether the given network is entirely within this network,
+         * taking into account the base addresses and netmasks of both.
+         *
+         * @param {IPv4Network} other
+         *     The network to test.
+         *
+         * @returns {Boolean}
+         *     true if the other network is entirely within this network, false
+         *     otherwise.
+         */
+        this.contains = function contains(other) {
+            return network.address === (other.address & other.netmask & network.netmask);
+        };
+
+    };
+
+    /**
+     * Parses the given string as an IPv4 address or subnet, returning an
+     * IPv4Network object which describes that address or subnet.
+     *
+     * @param {String} str
+     *     The string to parse.
+     *
+     * @returns {IPv4Network}
+     *     The parsed network, or null if the given string is not valid.
+     */
+    IPv4Network.parse = function parse(str) {
+
+        // Regex which matches the general form of IPv4 addresses
+        var pattern = /^([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})(?:\/([0-9]{1,2}))?$/;
+
+        // Parse IPv4 address via regex
+        var match = pattern.exec(str);
+        if (!match)
+            return null;
+
+        // Parse netmask, if given
+        var netmask = 0xFFFFFFFF;
+        if (match[5]) {
+            var bits = parseInt(match[5]);
+            if (bits > 0 && bits <= 32)
+                netmask = 0xFFFFFFFF << (32 - bits);
+        }
+
+        // Read each octet onto address
+        var address = 0;
+        for (var i=1; i <= 4; i++) {
+
+            // Validate octet range
+            var octet = parseInt(match[i]);
+            if (octet > 255)
+                return null;
+
+            // Shift on octet
+            address = (address << 8) | octet;
+
+        }
+
+        return new IPv4Network(address, netmask);
+
+    };
+
+    return IPv4Network;
+
+}]);
diff --git a/guacamole/src/main/webapp/app/list/types/IPv6Network.js b/guacamole/src/main/webapp/app/list/types/IPv6Network.js
new file mode 100644
index 0000000..e2b4004
--- /dev/null
+++ b/guacamole/src/main/webapp/app/list/types/IPv6Network.js
@@ -0,0 +1,230 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * A service for defining the IPv6Network class.
+ */
+angular.module('list').factory('IPv6Network', [
+    function defineIPv6Network() {
+
+    /**
+     * Represents an IPv6 network as a pairing of base address and netmask,
+     * both of which are in binary form. To obtain an IPv6Network from
+     * standard CIDR notation, use IPv6Network.parse().
+     *
+     * @constructor 
+     * @param {Number[]} addressGroups
+     *     Array of eight IPv6 address groups in binary form, each group being 
+     *     16-bit number.
+     *
+     * @param {Number[]} netmaskGroups
+     *     Array of eight IPv6 netmask groups in binary form, each group being 
+     *     16-bit number.
+     */
+    var IPv6Network = function IPv6Network(addressGroups, netmaskGroups) {
+
+        /**
+         * Reference to this IPv6Network.
+         *
+         * @type IPv6Network
+         */
+        var network = this;
+
+        /**
+         * The 128-bit binary address of this network as an array of eight
+         * 16-bit numbers.
+         *
+         * @type Number[]
+         */
+        this.addressGroups = addressGroups;
+
+        /**
+         * The 128-bit binary netmask of this network as an array of eight
+         * 16-bit numbers.
+         *
+         * @type Number
+         */
+        this.netmaskGroups = netmaskGroups;
+
+        /**
+         * Tests whether the given network is entirely within this network,
+         * taking into account the base addresses and netmasks of both.
+         *
+         * @param {IPv6Network} other
+         *     The network to test.
+         *
+         * @returns {Boolean}
+         *     true if the other network is entirely within this network, false
+         *     otherwise.
+         */
+        this.contains = function contains(other) {
+
+            // Test that each masked 16-bit quantity matches the address
+            for (var i=0; i < 8; i++) {
+                if (network.addressGroups[i] !== (other.addressGroups[i]
+                                                & other.netmaskGroups[i]
+                                                & network.netmaskGroups[i]))
+                    return false;
+            }
+
+            // All 16-bit numbers match
+            return true;
+
+        };
+
+    };
+
+    /**
+     * Generates a netmask having the given number of ones on the left side.
+     * All other bits within the netmask will be zeroes. The resulting netmask
+     * will be an array of eight numbers, where each number corresponds to a
+     * 16-bit group of an IPv6 netmask.
+     *
+     * @param {Number} bits
+     *     The number of ones to include on the left side of the netmask. All
+     *     other bits will be zeroes.
+     *
+     * @returns {Number[]}
+     *     The generated netmask, having the given number of ones.
+     */
+    var generateNetmask = function generateNetmask(bits) {
+
+        var netmask = [];
+
+        // Only generate up to 128 bits
+        bits = Math.min(128, bits);
+
+        // Add any contiguous 16-bit sections of ones
+        while (bits >= 16) {
+            netmask.push(0xFFFF);
+            bits -= 16;
+        }
+
+        // Add remaining ones
+        if (bits > 0 && bits <= 16)
+            netmask.push(0xFFFF & (0xFFFF << (16 - bits)));
+
+        // Add remaining zeroes
+        while (netmask.length < 8)
+            netmask.push(0);
+
+        return netmask;
+
+    };
+
+    /**
+     * Splits the given IPv6 address or partial address into its corresponding
+     * 16-bit groups.
+     *
+     * @param {String} str
+     *     The IPv6 address or partial address to split.
+     * 
+     * @returns Number[]
+     *     The numeric values of all 16-bit groups within the given IPv6
+     *     address.
+     */
+    var splitAddress = function splitAddress(str) {
+
+        var address = [];
+
+        // Split address into groups
+        var groups = str.split(':');
+
+        // Parse the numeric value of each group
+        angular.forEach(groups, function addGroup(group) {
+            var value = parseInt(group || '0', 16);
+            address.push(value);
+        });
+
+        return address;
+
+    };
+
+    /**
+     * Parses the given string as an IPv6 address or subnet, returning an
+     * IPv6Network object which describes that address or subnet.
+     *
+     * @param {String} str
+     *     The string to parse.
+     *
+     * @returns {IPv6Network}
+     *     The parsed network, or null if the given string is not valid.
+     */
+    IPv6Network.parse = function parse(str) {
+
+        // Regex which matches the general form of IPv6 addresses
+        var pattern = /^([0-9a-f]{0,4}(?::[0-9a-f]{0,4}){0,7})(?:\/([0-9]{1,3}))?$/;
+
+        // Parse rudimentary IPv6 address via regex
+        var match = pattern.exec(str);
+        if (!match)
+            return null;
+
+        // Extract address and netmask from parse results
+        var unparsedAddress = match[1];
+        var unparsedNetmask = match[2];
+
+        // Parse netmask
+        var netmask;
+        if (unparsedNetmask)
+            netmask = generateNetmask(parseInt(unparsedNetmask));
+        else
+            netmask = generateNetmask(128);
+
+        var address;
+
+        // Separate based on the double-colon, if present
+        var doubleColon = unparsedAddress.indexOf('::');
+
+        // If no double colon, just split into groups
+        if (doubleColon === -1)
+            address = splitAddress(unparsedAddress);
+
+        // Otherwise, split either side of the double colon and pad with zeroes
+        else {
+
+            // Parse either side of the double colon
+            var leftAddress  = splitAddress(unparsedAddress.substring(0, doubleColon));
+            var rightAddress = splitAddress(unparsedAddress.substring(doubleColon + 2));
+
+            // Pad with zeroes up to address length
+            var remaining = 8 - leftAddress.length - rightAddress.length;
+            while (remaining > 0) {
+                leftAddress.push(0);
+                remaining--;
+            }
+
+            address = leftAddress.concat(rightAddress);
+
+        }
+        
+        // Validate length of address
+        if (address.length !== 8)
+            return null;
+
+        return new IPv6Network(address, netmask);
+
+    };
+
+    return IPv6Network;
+
+}]);
diff --git a/guacamole/src/main/webapp/app/list/types/SortOrder.js b/guacamole/src/main/webapp/app/list/types/SortOrder.js
new file mode 100644
index 0000000..86580b8
--- /dev/null
+++ b/guacamole/src/main/webapp/app/list/types/SortOrder.js
@@ -0,0 +1,149 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * A service for defining the SortOrder class.
+ */
+angular.module('list').factory('SortOrder', [
+    function defineSortOrder() {
+
+    /**
+     * Maintains a sorting predicate as required by the Angular orderBy filter.
+     * The order of properties sorted by the predicate can be altered while
+     * otherwise maintaining the sort order.
+     *
+     * @constructor
+     * @param {String[]} predicate
+     *     The properties to sort by, in order of precidence.
+     */
+    var SortOrder = function SortOrder(predicate) {
+
+        /**
+         * Reference to this instance.
+         *
+         * @type SortOrder
+         */
+        var sortOrder = this;
+
+        /**
+         * The current sorting predicate.
+         *
+         * @type String[]
+         */
+        this.predicate = predicate;
+
+        /**
+         * The name of the highest-precedence sorting property.
+         *
+         * @type String
+         */
+        this.primary = predicate[0];
+
+        /**
+         * Whether the highest-precedence sorting property is sorted in
+         * descending order.
+         *
+         * @type Boolean
+         */
+        this.descending = false;
+
+        // Handle initially-descending primary properties
+        if (this.primary.charAt(0) === '-') {
+            this.primary = this.primary.substring(1);
+            this.descending = true;
+        }
+
+        /**
+         * Reorders the currently-defined predicate such that the named
+         * property takes precidence over all others. The property will be
+         * sorted in ascending order unless otherwise specified.
+         *
+         * @param {String} name
+         *     The name of the property to reorder by.
+         *
+         * @param {Boolean} [descending=false]
+         *     Whether the property should be sorted in descending order. By
+         *     default, all properties are sorted in ascending order.
+         */
+        this.reorder = function reorder(name, descending) {
+
+            // Build ascending and descending predicate components
+            var ascendingName  = name;
+            var descendingName = '-' + name;
+
+            // Remove requested property from current predicate
+            sortOrder.predicate = sortOrder.predicate.filter(function notRequestedProperty(current) {
+                return current !== ascendingName
+                    && current !== descendingName;
+            });
+
+            // Add property to beginning of predicate
+            if (descending)
+                sortOrder.predicate.unshift(descendingName);
+            else
+                sortOrder.predicate.unshift(ascendingName);
+
+            // Update sorted state
+            sortOrder.primary    = name;
+            sortOrder.descending = !!descending;
+
+        };
+
+        /**
+         * Returns whether the sort order is primarily determined by the given
+         * property.
+         *
+         * @param {String} property
+         *     The name of the property to check.
+         *
+         * @returns {Boolean}
+         *     true if the sort order is primarily determined by the given
+         *     property, false otherwise.
+         */
+        this.isSortedBy = function isSortedBy(property) {
+            return sortOrder.primary === property;
+        };
+
+        /**
+         * Sets the primary sorting property to the given property, if not already
+         * set. If already set, the ascending/descending sort order is toggled.
+         *
+         * @param {String} property
+         *     The name of the property to assign as the primary sorting property.
+         */
+        this.togglePrimary = function togglePrimary(property) {
+
+            // Sort in ascending order by new property, if different
+            if (!sortOrder.isSortedBy(property))
+                sortOrder.reorder(property, false);
+
+            // Otherwise, toggle sort order
+            else
+                sortOrder.reorder(property, !sortOrder.descending);
+
+        };
+
+    };
+
+    return SortOrder;
+
+}]);
\ No newline at end of file
diff --git a/guacamole/src/main/webapp/app/locale/localeModule.js b/guacamole/src/main/webapp/app/locale/localeModule.js
new file mode 100644
index 0000000..ef6c16f
--- /dev/null
+++ b/guacamole/src/main/webapp/app/locale/localeModule.js
@@ -0,0 +1,26 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Module for handling common localization-related tasks.
+ */
+angular.module('locale', []);
diff --git a/guacamole/src/main/webapp/app/locale/services/translationLoader.js b/guacamole/src/main/webapp/app/locale/services/translationLoader.js
new file mode 100644
index 0000000..a95f85f
--- /dev/null
+++ b/guacamole/src/main/webapp/app/locale/services/translationLoader.js
@@ -0,0 +1,160 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Service for loading translation definition files, conforming to the
+ * angular-translate documentation for custom translation loaders:
+ * 
+ * https://github.com/angular-translate/angular-translate/wiki/Asynchronous-loading#using-custom-loader-service
+ */
+angular.module('locale').factory('translationLoader', ['$injector', function translationLoader($injector) {
+
+    // Required services
+    var $http           = $injector.get('$http');
+    var $q              = $injector.get('$q');
+    var cacheService    = $injector.get('cacheService');
+    var languageService = $injector.get('languageService');
+
+    /**
+     * Satisfies a translation request for the given key by searching for the
+     * translation files for each key in the given array, in order. The request
+     * fails only if none of the files can be found.
+     *
+     * @param {Deferred} deferred
+     *     The Deferred object to resolve or reject depending on whether at
+     *     least one translation file can be successfully loaded.
+     *
+     * @param {String} requestedKey
+     *     The originally-requested language key.
+     *
+     * @param {String[]} remainingKeys
+     *     The keys of the languages to attempt to load, in order, where the
+     *     first key in this array is the language to try within this function
+     *     call. The first key in the array is not necessarily the originally-
+     *     requested language key.
+     */
+    var satisfyTranslation = function satisfyTranslation(deferred, requestedKey, remainingKeys) {
+
+        // Get current language key
+        var currentKey = remainingKeys.shift();
+
+        // If no languages to try, "succeed" with an empty translation (force fallback)
+        if (!currentKey) {
+            deferred.resolve('{}');
+            return;
+        }
+
+        /**
+         * Continues trying possible translation files until no possibilities
+         * exist.
+         *
+         * @private
+         */
+        var tryNextTranslation = function tryNextTranslation() {
+            satisfyTranslation(deferred, requestedKey, remainingKeys);
+        };
+
+        // Retrieve list of supported languages
+        languageService.getLanguages()
+
+        // Attempt to retrieve translation if language is supported
+        .success(function retrievedLanguages(languages) {
+
+            // Skip retrieval if language is not supported
+            if (!(currentKey in languages)) {
+                tryNextTranslation();
+                return;
+            }
+
+            // Attempt to retrieve language
+            $http({
+                cache   : cacheService.languages,
+                method  : 'GET',
+                url     : 'translations/' + encodeURIComponent(currentKey) + '.json'
+            })
+
+            // Resolve promise if translation retrieved successfully
+            .success(function translationFileRetrieved(translation) {
+                deferred.resolve(translation);
+            })
+
+            // Retry with remaining languages if translation file could not be
+            // retrieved
+            .error(tryNextTranslation);
+
+        })
+
+        // Retry with remaining languages if translation does not exist
+        .error(tryNextTranslation);
+
+    };
+
+    /**
+     * Given a valid language key, returns all possible legal variations of
+     * that key. Currently, this will be the given key and the given key
+     * without the country code. If the key has no country code, only the
+     * given key will be included in the returned array.
+     *
+     * @param {String} key
+     *     The language key to generate variations of.
+     *
+     * @returns {String[]}
+     *     All possible variations of the given language key.
+     */
+    var getKeyVariations = function getKeyVariations(key) {
+
+        var underscore = key.indexOf('_');
+
+        // If no underscore, only one possibility
+        if (underscore === -1)
+            return [key];
+
+        // Otherwise, include the lack of country code as an option
+        return [key, key.substr(0, underscore)];
+
+    };
+
+    /**
+     * Custom loader function for angular-translate which loads the desired
+     * language file dynamically via HTTP. If the language file cannot be
+     * found, the fallback language is used instead.
+     *
+     * @param {Object} options
+     *     Arbitrary options, containing at least a "key" property which
+     *     contains the requested language key.
+     *
+     * @returns {Promise.<Object>}
+     *     A promise which resolves to the requested translation string object.
+     */
+    return function loadTranslationFile(options) {
+
+        var translation = $q.defer();
+
+        // Satisfy the translation request using possible variations of the given key
+        satisfyTranslation(translation, options.key, getKeyVariations(options.key));
+
+        // Return promise which is resolved only after the translation file is loaded
+        return translation.promise;
+
+    };
+
+}]);
diff --git a/guacamole/src/main/webapp/app/locale/services/translationStringService.js b/guacamole/src/main/webapp/app/locale/services/translationStringService.js
new file mode 100644
index 0000000..4a0e566
--- /dev/null
+++ b/guacamole/src/main/webapp/app/locale/services/translationStringService.js
@@ -0,0 +1,49 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Service for manipulating translation strings and translation table
+ * identifiers.
+ */
+angular.module('locale').factory('translationStringService', [function translationStringService() {
+
+    var service = {};
+        
+    /**
+     * Given an arbitrary identifier, returns the corresponding translation
+     * table identifier. Translation table identifiers are uppercase strings,
+     * word components separated by single underscores. For example, the
+     * string "Swap red/blue" would become "SWAP_RED_BLUE".
+     *
+     * @param {String} identifier
+     *     The identifier to transform into a translation table identifier.
+     *
+     * @returns {String}
+     *     The translation table identifier.
+     */
+    service.canonicalize = function canonicalize(identifier) {
+        return identifier.replace(/[^a-zA-Z0-9]+/g, '_').toUpperCase();
+    };
+
+    return service;
+
+}]);
diff --git a/guacamole/src/main/webapp/app/login/directives/login.js b/guacamole/src/main/webapp/app/login/directives/login.js
new file mode 100644
index 0000000..089afee
--- /dev/null
+++ b/guacamole/src/main/webapp/app/login/directives/login.js
@@ -0,0 +1,190 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * A directive for displaying an arbitrary login form.
+ */
+angular.module('login').directive('guacLogin', [function guacLogin() {
+
+    // Login directive
+    var directive = {
+        restrict    : 'E',
+        replace     : true,
+        templateUrl : 'app/login/templates/login.html'
+    };
+
+    // Login directive scope
+    directive.scope = {
+
+        /**
+         * An optional instructional message to display within the login
+         * dialog.
+         *
+         * @type String
+         */
+        helpText : '=',
+
+        /**
+         * The login form or set of fields. This will be displayed to the user
+         * to capture their credentials.
+         *
+         * @type Field[]
+         */
+        form : '=',
+
+        /**
+         * A map of all field name/value pairs that have already been provided.
+         * If not null, the user will be prompted to continue their login
+         * attempt using only the fields which remain.
+         */
+        values : '='
+
+    };
+
+    // Controller for login directive
+    directive.controller = ['$scope', '$injector',
+        function loginController($scope, $injector) {
+        
+        // Required types
+        var Error = $injector.get('Error');
+        var Field = $injector.get('Field');
+
+        // Required services
+        var $route                = $injector.get('$route');
+        var authenticationService = $injector.get('authenticationService');
+
+        /**
+         * A description of the error that occurred during login, if any.
+         *
+         * @type String
+         */
+        $scope.loginError = null;
+
+        /**
+         * All form values entered by the user, as parameter name/value pairs.
+         *
+         * @type Object.<String, String>
+         */
+        $scope.enteredValues = {};
+
+        /**
+         * All form fields which have not yet been filled by the user.
+         *
+         * @type Field[]
+         */
+        $scope.remainingFields = [];
+
+        /**
+         * Returns whether a previous login attempt is continuing.
+         *
+         * @return {Boolean}
+         *     true if a previous login attempt is continuing, false otherwise.
+         */
+        $scope.isContinuation = function isContinuation() {
+
+            // The login is continuing if any parameter values are provided
+            for (var name in $scope.values)
+                return true;
+
+            return false;
+
+        };
+
+        // Ensure provided values are included within entered values, even if
+        // they have no corresponding input fields
+        $scope.$watch('values', function resetEnteredValues(values) {
+            angular.extend($scope.enteredValues, values || {});
+        });
+
+        // Update field information when form is changed
+        $scope.$watch('form', function resetRemainingFields(fields) {
+
+            // If no fields are provided, then no fields remain
+            if (!fields) {
+                $scope.remainingFields = [];
+                return;
+            }
+
+            // Filter provided fields against provided values
+            $scope.remainingFields = fields.filter(function isRemaining(field) {
+                return !(field.name in $scope.values);
+            });
+
+            // Set default values for all unset fields
+            angular.forEach($scope.remainingFields, function setDefault(field) {
+                if (!$scope.enteredValues[field.name])
+                    $scope.enteredValues[field.name] = '';
+            });
+
+        });
+
+        /**
+         * Submits the currently-specified username and password to the
+         * authentication service, redirecting to the main view if successful.
+         */
+        $scope.login = function login() {
+
+            // Start with cleared status
+            $scope.loginError  = null;
+
+            // Attempt login once existing session is destroyed
+            authenticationService.authenticate($scope.enteredValues)
+
+            // Clear and reload upon success
+            .then(function loginSuccessful() {
+                $scope.enteredValues = {};
+                $route.reload();
+            })
+
+            // Reset upon failure
+            ['catch'](function loginFailed(error) {
+
+                // Clear out passwords if the credentials were rejected for any reason
+                if (error.type !== Error.Type.INSUFFICIENT_CREDENTIALS) {
+
+                    // Flag generic error for invalid login
+                    if (error.type === Error.Type.INVALID_CREDENTIALS)
+                        $scope.loginError = 'LOGIN.ERROR_INVALID_LOGIN';
+
+                    // Display error if anything else goes wrong
+                    else
+                        $scope.loginError = error.message;
+
+                    // Clear all visible password fields
+                    angular.forEach($scope.remainingFields, function clearEnteredValueIfPassword(field) {
+
+                        // Remove entered value only if field is a password field
+                        if (field.type === Field.Type.PASSWORD && field.name in $scope.enteredValues)
+                            $scope.enteredValues[field.name] = '';
+
+                    });
+                }
+
+            });
+
+        };
+
+    }];
+
+    return directive;
+
+}]);
diff --git a/guacamole/src/main/webapp/app/login/loginModule.js b/guacamole/src/main/webapp/app/login/loginModule.js
new file mode 100644
index 0000000..aa879f7
--- /dev/null
+++ b/guacamole/src/main/webapp/app/login/loginModule.js
@@ -0,0 +1,30 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * The module for the login functionality.
+ */
+angular.module('login', [
+    'element',
+    'form',
+    'navigation'
+]);
diff --git a/guacamole/src/main/webapp/app/login/styles/animation.css b/guacamole/src/main/webapp/app/login/styles/animation.css
new file mode 100644
index 0000000..bbc842c
--- /dev/null
+++ b/guacamole/src/main/webapp/app/login/styles/animation.css
@@ -0,0 +1,37 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+ at keyframes shake-head {
+    0%   { margin-left:  0.25em; margin-right: -0.25em; }
+    25%  { margin-left: -0.25em; margin-right:  0.25em; }
+    50%  { margin-left:  0.25em; margin-right: -0.25em; }
+    75%  { margin-left: -0.25em; margin-right:  0.25em; }
+    100% { margin-left:  0.00em; margin-right:  0.00em; }
+}
+
+ at -webkit-keyframes shake-head {
+    0%   { margin-left:  0.25em; margin-right: -0.25em; }
+    25%  { margin-left: -0.25em; margin-right:  0.25em; }
+    50%  { margin-left:  0.25em; margin-right: -0.25em; }
+    75%  { margin-left: -0.25em; margin-right:  0.25em; }
+    100% { margin-left:  0.00em; margin-right:  0.00em; }
+}
diff --git a/guacamole/src/main/webapp/app/login/styles/dialog.css b/guacamole/src/main/webapp/app/login/styles/dialog.css
new file mode 100644
index 0000000..8298131
--- /dev/null
+++ b/guacamole/src/main/webapp/app/login/styles/dialog.css
@@ -0,0 +1,106 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+.login-ui.error .login-dialog {
+    animation-name: shake-head;
+    animation-duration: 0.25s;
+    animation-timing-function: linear;
+    -webkit-animation-name: shake-head;
+    -webkit-animation-duration: 0.25s;
+    -webkit-animation-timing-function: linear;
+}
+
+.login-ui div.login-dialog-middle {
+    width: 100%;
+    display: table-cell;
+    vertical-align: middle;
+    text-align: center;
+}
+
+.login-ui div.login-dialog {
+
+    animation: fadein 0.125s linear;
+    -moz-animation: fadein 0.125s linear;
+    -webkit-animation: fadein 0.125s linear;
+
+    width: 100%;
+    max-width: 3in;
+    text-align: left;
+    padding: 1em;
+    border: 1px solid rgba(0, 0, 0, 0.25);
+    box-shadow: 1px 1px 2px rgba(0, 0, 0, 0.25);
+    font-size: 1.25em;
+
+    display: inline-block;
+}
+
+.login-ui .login-dialog h1 {
+    margin-top: 0;
+    margin-bottom: 0em;
+    text-align: center;
+}
+
+.login-ui .login-dialog .buttons {
+    text-align: right;
+    margin: 0;
+    margin-top: 1em;
+}
+
+.login-ui .login-dialog .login-fields {
+    vertical-align: middle;
+}
+
+.login-ui .login-dialog th {
+    text-shadow: 1px 1px white;
+}
+
+.login-ui .login-dialog .version {
+    font-size: 1.25em;
+    font-weight: bold;
+    padding: 0.5em 0;
+    text-transform: uppercase;
+    text-align: center;
+}
+
+.login-ui .login-dialog .logo {
+    display: block;
+    margin: 0.5em auto;
+    width: 3em;
+    height: 3em;
+    background-size:         3em 3em;
+    -moz-background-size:    3em 3em;
+    -webkit-background-size: 3em 3em;
+    -khtml-background-size:  3em 3em;
+    background-image: url("images/guac-tricolor.png");
+}
+
+.login-ui.continuation div.login-dialog {
+    border-right: none;
+    border-left: none;
+    box-shadow: none;
+    max-width: 6in;
+}
+
+.login-ui.continuation .login-dialog .logo,
+.login-ui.continuation .login-dialog .version {
+    display: none;
+}
diff --git a/guacamole/src/main/webapp/app/login/styles/input.css b/guacamole/src/main/webapp/app/login/styles/input.css
new file mode 100644
index 0000000..fd4d705
--- /dev/null
+++ b/guacamole/src/main/webapp/app/login/styles/input.css
@@ -0,0 +1,50 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+.login-ui .login-dialog .login-fields input {
+    display: block;
+    border: 1px solid rgba(0, 0, 0, 0.25);
+    -moz-border-radius: 0.2em;
+    -webkit-border-radius: 0.2em;
+    -khtml-border-radius: 0.2em;
+    font-family: Carlito, FreeSans, Helvetica, Arial, sans-serif;
+    border-radius: 0.2em;
+    width: 100%;
+    margin: 0;
+    margin-bottom: 0.5em;
+    padding: 0.5em 0.75em;
+    max-width: none;
+}
+
+.login-ui .login-dialog .buttons input[type="submit"] {
+    width: 100%;
+    margin: 0;
+}
+
+.login-ui.continuation .login-dialog .buttons input[type="submit"] {
+    width: auto;
+}
+
+.login-ui.initial .login-dialog input.continue-login,
+.login-ui.continuation .login-dialog input.login {
+    display: none;
+}
diff --git a/guacamole/src/main/webapp/app/login/styles/login.css b/guacamole/src/main/webapp/app/login/styles/login.css
new file mode 100644
index 0000000..1e874a1
--- /dev/null
+++ b/guacamole/src/main/webapp/app/login/styles/login.css
@@ -0,0 +1,89 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+div.login-ui {
+    height: 100%;
+    width: 100%;
+    position: fixed;
+    left: 0;
+    top: 0;
+    display: table;
+    background: white;
+    z-index: 20;
+}
+
+.login-ui p.login-error {
+    display: none;
+}
+
+.login-ui.error p.login-error {
+    display: block;
+
+    position: fixed;
+    left: 0;
+    right: 0;
+    top: 0;
+
+    padding: 1em;
+    margin: 0.2em;
+
+    background: #FDD;
+    border: 1px solid #964040;
+    -moz-border-radius: 0.25em;
+    -webkit-border-radius: 0.25em;
+    -khtml-border-radius: 0.25em;
+    text-align: center;
+    color: #964040;
+}
+
+.login-ui .login-fields .form-field .password-field .toggle-password {
+    display: none;
+}
+
+.login-ui .login-fields .labeled-field {
+    display: block;
+    position: relative;
+    z-index: 1;
+}
+
+.login-ui .login-fields .labeled-field .field-header {
+
+  display: block;
+  position: absolute;
+  left: 0;
+  right: 0;
+  overflow: hidden;
+
+  z-index: -1;
+  margin: 0.5em;
+  font-size: 0.9em;
+  opacity: 0.5;
+
+}
+
+.login-ui .login-fields .labeled-field.empty input {
+    background: transparent;
+}
+
+.login-ui .login-fields .labeled-field input:focus {
+    background: white;
+}
diff --git a/guacamole/src/main/webapp/app/login/templates/login.html b/guacamole/src/main/webapp/app/login/templates/login.html
new file mode 100644
index 0000000..5a2c11e
--- /dev/null
+++ b/guacamole/src/main/webapp/app/login/templates/login.html
@@ -0,0 +1,57 @@
+<div class="login-ui" ng-class="{error: loginError, continuation: isContinuation(), initial: !isContinuation()}" >
+    <!--
+    Copyright 2014 Glyptodon LLC.
+
+    Permission is hereby granted, free of charge, to any person obtaining a copy
+    of this software and associated documentation files (the "Software"), to deal
+    in the Software without restriction, including without limitation the rights
+    to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+    copies of the Software, and to permit persons to whom the Software is
+    furnished to do so, subject to the following conditions:
+
+    The above copyright notice and this permission notice shall be included in
+    all copies or substantial portions of the Software.
+
+    THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+    IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+    FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+    AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+    LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+    OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+    THE SOFTWARE.
+    -->
+
+    <!-- Login error message -->
+    <p class="login-error">{{loginError | translate}}</p>
+
+    <div class="login-dialog-middle">
+
+        <div class="login-dialog">
+
+            <form class="login-form" ng-submit="login()">
+
+                <!-- Guacamole version -->
+                <div class="logo"></div>
+                <div class="version">{{'APP.NAME' | translate}}</div>
+
+                <!-- Login message/instructions -->
+                <p ng-show="helpText">{{helpText | translate}}</p>
+
+                <!-- Login fields -->
+                <div class="login-fields">
+                    <guac-form namespace="'LOGIN'" content="remainingFields" model="enteredValues"></guac-form>
+                </div>
+
+                <!-- Submit button -->
+                <div class="buttons">
+                    <input type="submit" name="login" class="login" value="{{'LOGIN.ACTION_LOGIN' | translate}}"/>
+                    <input type="submit" name="login" class="continue-login" value="{{'LOGIN.ACTION_CONTINUE' | translate}}"/>
+                </div>
+
+            </form>
+
+        </div>
+
+    </div>
+
+</div>
diff --git a/guacamole/src/main/webapp/app/manage/controllers/manageConnectionController.js b/guacamole/src/main/webapp/app/manage/controllers/manageConnectionController.js
new file mode 100644
index 0000000..1104741
--- /dev/null
+++ b/guacamole/src/main/webapp/app/manage/controllers/manageConnectionController.js
@@ -0,0 +1,455 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * The controller for editing or creating connections.
+ */
+angular.module('manage').controller('manageConnectionController', ['$scope', '$injector',
+        function manageConnectionController($scope, $injector) {
+
+    // Required types
+    var Connection          = $injector.get('Connection');
+    var ConnectionGroup     = $injector.get('ConnectionGroup');
+    var HistoryEntryWrapper = $injector.get('HistoryEntryWrapper');
+    var PermissionSet       = $injector.get('PermissionSet');
+
+    // Required services
+    var $location                = $injector.get('$location');
+    var $routeParams             = $injector.get('$routeParams');
+    var $translate               = $injector.get('$translate');
+    var authenticationService    = $injector.get('authenticationService');
+    var guacNotification         = $injector.get('guacNotification');
+    var connectionService        = $injector.get('connectionService');
+    var connectionGroupService   = $injector.get('connectionGroupService');
+    var permissionService        = $injector.get('permissionService');
+    var schemaService            = $injector.get('schemaService');
+    var translationStringService = $injector.get('translationStringService');
+
+    /**
+     * An action to be provided along with the object sent to showStatus which
+     * closes the currently-shown status dialog.
+     */
+    var ACKNOWLEDGE_ACTION = {
+        name        : "MANAGE_CONNECTION.ACTION_ACKNOWLEDGE",
+        // Handle action
+        callback    : function acknowledgeCallback() {
+            guacNotification.showStatus(false);
+        }
+    };
+
+    /**
+     * The unique identifier of the data source containing the connection being
+     * edited.
+     *
+     * @type String
+     */
+    $scope.selectedDataSource = $routeParams.dataSource;
+
+    /**
+     * The identifier of the original connection from which this connection is
+     * being cloned. Only valid if this is a new connection.
+     * 
+     * @type String
+     */
+    var cloneSourceIdentifier = $location.search().clone;
+
+    /**
+     * The identifier of the connection being edited. If a new connection is
+     * being created, this will not be defined.
+     *
+     * @type String
+     */
+    var identifier = $routeParams.id;
+
+    /**
+     * All known protocols.
+     *
+     * @type Object.<String, Protocol>
+     */
+    $scope.protocols = null;
+
+    /**
+     * The root connection group of the connection group hierarchy.
+     *
+     * @type ConnectionGroup
+     */
+    $scope.rootGroup = null;
+
+    /**
+     * The connection being modified.
+     * 
+     * @type Connection
+     */
+    $scope.connection = null;
+
+    /**
+     * The parameter name/value pairs associated with the connection being
+     * modified.
+     *
+     * @type Object.<String, String>
+     */
+    $scope.parameters = null;
+
+    /**
+     * The date format for use within the connection history.
+     *
+     * @type String
+     */
+    $scope.historyDateFormat = null;
+
+    /**
+     * The usage history of the connection being modified.
+     *
+     * @type HistoryEntryWrapper[]
+     */
+    $scope.historyEntryWrappers = null;
+    
+    /**
+     * Whether the user can save the connection being edited. This could be
+     * updating an existing connection, or creating a new connection.
+     * 
+     * @type Boolean
+     */
+    $scope.canSaveConnection = null;
+    
+    /**
+     * Whether the user can delete the connection being edited.
+     * 
+     * @type Boolean
+     */
+    $scope.canDeleteConnection = null;
+    
+    /**
+     * Whether the user can clone the connection being edited.
+     * 
+     * @type Boolean
+     */
+    $scope.canCloneConnection = null;
+
+    /**
+     * All permissions associated with the current user, or null if the user's
+     * permissions have not yet been loaded.
+     *
+     * @type PermissionSet
+     */
+    $scope.permissions = null;
+
+    /**
+     * All available connection attributes. This is only the set of attribute
+     * definitions, organized as logical groupings of attributes, not attribute
+     * values.
+     *
+     * @type Form[]
+     */
+    $scope.attributes = null;
+
+    /**
+     * Returns whether critical data has completed being loaded.
+     *
+     * @returns {Boolean}
+     *     true if enough data has been loaded for the user interface to be
+     *     useful, false otherwise.
+     */
+    $scope.isLoaded = function isLoaded() {
+
+        return $scope.protocols            !== null
+            && $scope.rootGroup            !== null
+            && $scope.connection           !== null
+            && $scope.parameters           !== null
+            && $scope.historyDateFormat    !== null
+            && $scope.historyEntryWrappers !== null
+            && $scope.permissions          !== null
+            && $scope.attributes           !== null
+            && $scope.canSaveConnection    !== null
+            && $scope.canDeleteConnection  !== null
+            && $scope.canCloneConnection   !== null;
+
+    };
+
+    // Pull connection attribute schema
+    schemaService.getConnectionAttributes($scope.selectedDataSource)
+    .success(function attributesReceived(attributes) {
+        $scope.attributes = attributes;
+    });
+
+    // Pull connection group hierarchy
+    connectionGroupService.getConnectionGroupTree(
+        $scope.selectedDataSource,
+        ConnectionGroup.ROOT_IDENTIFIER,
+        [PermissionSet.ObjectPermissionType.ADMINISTER]
+    )
+    .success(function connectionGroupReceived(rootGroup) {
+        $scope.rootGroup = rootGroup;
+    });
+    
+    // Query the user's permissions for the current connection
+    permissionService.getPermissions($scope.selectedDataSource, authenticationService.getCurrentUsername())
+    .success(function permissionsReceived(permissions) {
+                
+        $scope.permissions = permissions;
+                        
+        // Check if the connection is new or if the user has UPDATE permission
+        $scope.canSaveConnection =
+               !identifier
+            || PermissionSet.hasSystemPermission(permissions, PermissionSet.SystemPermissionType.ADMINISTER)
+            || PermissionSet.hasConnectionPermission(permissions, PermissionSet.ObjectPermissionType.UPDATE, identifier);
+            
+        // Check if connection is not new and the user has DELETE permission
+        $scope.canDeleteConnection =
+            !!identifier && (
+                   PermissionSet.hasSystemPermission(permissions, PermissionSet.SystemPermissionType.ADMINISTER)
+               ||  PermissionSet.hasConnectionPermission(permissions, PermissionSet.ObjectPermissionType.DELETE, identifier)
+            );
+                
+        // Check if the connection is not new and the user has UPDATE and CREATE_CONNECTION permissions
+        $scope.canCloneConnection =
+            !!identifier && (
+               PermissionSet.hasSystemPermission(permissions, PermissionSet.SystemPermissionType.ADMINISTER) || (
+                       PermissionSet.hasConnectionPermission(permissions, PermissionSet.ObjectPermissionType.UPDATE, identifier)
+                   &&  PermissionSet.hasSystemPermission(permissions, PermissionSet.SystemPermissionType.CREATE_CONNECTION)
+               )
+            );
+    
+    });
+   
+    // Get protocol metadata
+    schemaService.getProtocols($scope.selectedDataSource)
+    .success(function protocolsReceived(protocols) {
+        $scope.protocols = protocols;
+    });
+
+    // Get history date format
+    $translate('MANAGE_CONNECTION.FORMAT_HISTORY_START').then(function historyDateFormatReceived(historyDateFormat) {
+        $scope.historyDateFormat = historyDateFormat;
+    });
+
+    // If we are editing an existing connection, pull its data
+    if (identifier) {
+
+        // Pull data from existing connection
+        connectionService.getConnection($scope.selectedDataSource, identifier)
+        .success(function connectionRetrieved(connection) {
+            $scope.connection = connection;
+        });
+
+        // Pull connection history
+        connectionService.getConnectionHistory($scope.selectedDataSource, identifier)
+        .success(function historyReceived(historyEntries) {
+
+            // Wrap all history entries for sake of display
+            $scope.historyEntryWrappers = [];
+            historyEntries.forEach(function wrapHistoryEntry(historyEntry) {
+               $scope.historyEntryWrappers.push(new HistoryEntryWrapper(historyEntry)); 
+            });
+
+        });
+
+        // Pull connection parameters
+        connectionService.getConnectionParameters($scope.selectedDataSource, identifier)
+        .success(function parametersReceived(parameters) {
+            $scope.parameters = parameters;
+        });
+    }
+    
+    // If we are cloning an existing connection, pull its data instead
+    else if (cloneSourceIdentifier) {
+
+        // Pull data from cloned connection
+        connectionService.getConnection($scope.selectedDataSource, cloneSourceIdentifier)
+        .success(function connectionRetrieved(connection) {
+            $scope.connection = connection;
+            
+            // Clear the identifier field because this connection is new
+            delete $scope.connection.identifier;
+        });
+
+        // Do not pull connection history
+        $scope.historyEntryWrappers = [];
+        
+        // Pull connection parameters from cloned connection
+        connectionService.getConnectionParameters($scope.selectedDataSource, cloneSourceIdentifier)
+        .success(function parametersReceived(parameters) {
+            $scope.parameters = parameters;
+        });
+    }
+
+    // If we are creating a new connection, populate skeleton connection data
+    else {
+        $scope.connection = new Connection({ protocol: 'vnc' });
+        $scope.historyEntryWrappers = [];
+        $scope.parameters = {};
+    }
+
+    /**
+     * Returns the translation string namespace for the protocol having the
+     * given name. The namespace will be of the form:
+     *
+     * <code>PROTOCOL_NAME</code>
+     *
+     * where <code>NAME</code> is the protocol name transformed via
+     * translationStringService.canonicalize().
+     *
+     * @param {String} protocolName
+     *     The name of the protocol.
+     *
+     * @returns {String}
+     *     The translation namespace for the protocol specified, or null if no
+     *     namespace could be generated.
+     */
+    $scope.getNamespace = function getNamespace(protocolName) {
+
+        // Do not generate a namespace if no protocol is selected
+        if (!protocolName)
+            return null;
+
+        return 'PROTOCOL_' + translationStringService.canonicalize(protocolName);
+
+    };
+
+    /**
+     * Given the internal name of a protocol, produces the translation string
+     * for the localized version of that protocol's name. The translation
+     * string will be of the form:
+     *
+     * <code>NAMESPACE.NAME<code>
+     *
+     * where <code>NAMESPACE</code> is the namespace generated from
+     * $scope.getNamespace().
+     *
+     * @param {String} protocolName
+     *     The name of the protocol.
+     * 
+     * @returns {String}
+     *     The translation string which produces the localized name of the
+     *     protocol specified.
+     */
+    $scope.getProtocolName = function getProtocolName(protocolName) {
+        return $scope.getNamespace(protocolName) + '.NAME';
+    };
+
+    /**
+     * Cancels all pending edits, returning to the management page.
+     */
+    $scope.cancel = function cancel() {
+        $location.path('/settings/' + encodeURIComponent($scope.selectedDataSource) + '/connections');
+    };
+    
+    /**
+     * Cancels all pending edits, opening an edit page for a new connection
+     * which is prepopulated with the data from the connection currently being edited. 
+     */
+    $scope.cloneConnection = function cloneConnection() {
+        $location.path('/manage/' + encodeURIComponent($scope.selectedDataSource) + '/connections').search('clone', identifier);
+    };
+            
+    /**
+     * Saves the connection, creating a new connection or updating the existing
+     * connection.
+     */
+    $scope.saveConnection = function saveConnection() {
+
+        $scope.connection.parameters = $scope.parameters;
+
+        // Save the connection
+        connectionService.saveConnection($scope.selectedDataSource, $scope.connection)
+        .success(function savedConnection() {
+            $location.path('/settings/' + encodeURIComponent($scope.selectedDataSource) + '/connections');
+        })
+
+        // Notify of any errors
+        .error(function connectionSaveFailed(error) {
+            guacNotification.showStatus({
+                'className'  : 'error',
+                'title'      : 'MANAGE_CONNECTION.DIALOG_HEADER_ERROR',
+                'text'       : error.message,
+                'actions'    : [ ACKNOWLEDGE_ACTION ]
+            });
+        });
+
+    };
+    
+    /**
+     * An action to be provided along with the object sent to showStatus which
+     * immediately deletes the current connection.
+     */
+    var DELETE_ACTION = {
+        name        : "MANAGE_CONNECTION.ACTION_DELETE",
+        className   : "danger",
+        // Handle action
+        callback    : function deleteCallback() {
+            deleteConnectionImmediately();
+            guacNotification.showStatus(false);
+        }
+    };
+
+    /**
+     * An action to be provided along with the object sent to showStatus which
+     * closes the currently-shown status dialog.
+     */
+    var CANCEL_ACTION = {
+        name        : "MANAGE_CONNECTION.ACTION_CANCEL",
+        // Handle action
+        callback    : function cancelCallback() {
+            guacNotification.showStatus(false);
+        }
+    };
+
+    /**
+     * Immediately deletes the current connection, without prompting the user
+     * for confirmation.
+     */
+    var deleteConnectionImmediately = function deleteConnectionImmediately() {
+
+        // Delete the connection
+        connectionService.deleteConnection($scope.selectedDataSource, $scope.connection)
+        .success(function deletedConnection() {
+            $location.path('/settings/' + encodeURIComponent($scope.selectedDataSource) + '/connections');
+        })
+
+        // Notify of any errors
+        .error(function connectionDeletionFailed(error) {
+            guacNotification.showStatus({
+                'className'  : 'error',
+                'title'      : 'MANAGE_CONNECTION.DIALOG_HEADER_ERROR',
+                'text'       : error.message,
+                'actions'    : [ ACKNOWLEDGE_ACTION ]
+            });
+        });
+
+    };
+
+    /**
+     * Deletes the connection, prompting the user first to confirm that
+     * deletion is desired.
+     */
+    $scope.deleteConnection = function deleteConnection() {
+
+        // Confirm deletion request
+        guacNotification.showStatus({
+            'title'      : 'MANAGE_CONNECTION.DIALOG_HEADER_CONFIRM_DELETE',
+            'text'       : 'MANAGE_CONNECTION.TEXT_CONFIRM_DELETE',
+            'actions'    : [ DELETE_ACTION, CANCEL_ACTION]
+        });
+
+    };
+
+}]);
diff --git a/guacamole/src/main/webapp/app/manage/controllers/manageConnectionGroupController.js b/guacamole/src/main/webapp/app/manage/controllers/manageConnectionGroupController.js
new file mode 100644
index 0000000..5ce4ecf
--- /dev/null
+++ b/guacamole/src/main/webapp/app/manage/controllers/manageConnectionGroupController.js
@@ -0,0 +1,296 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * The controller for editing or creating connection groups.
+ */
+angular.module('manage').controller('manageConnectionGroupController', ['$scope', '$injector', 
+        function manageConnectionGroupController($scope, $injector) {
+            
+    // Required types
+    var ConnectionGroup = $injector.get('ConnectionGroup');
+    var PermissionSet   = $injector.get('PermissionSet');
+
+    // Required services
+    var $location              = $injector.get('$location');
+    var $routeParams           = $injector.get('$routeParams');
+    var authenticationService  = $injector.get('authenticationService');
+    var connectionGroupService = $injector.get('connectionGroupService');
+    var guacNotification       = $injector.get('guacNotification');
+    var permissionService      = $injector.get('permissionService');
+    var schemaService          = $injector.get('schemaService');
+    
+    /**
+     * An action to be provided along with the object sent to showStatus which
+     * closes the currently-shown status dialog.
+     */
+    var ACKNOWLEDGE_ACTION = {
+        name        : "MANAGE_CONNECTION_GROUP.ACTION_ACKNOWLEDGE",
+        // Handle action
+        callback    : function acknowledgeCallback() {
+            guacNotification.showStatus(false);
+        }
+    };
+
+    /**
+     * The unique identifier of the data source containing the connection group
+     * being edited.
+     *
+     * @type String
+     */
+    $scope.selectedDataSource = $routeParams.dataSource;
+
+    /**
+     * The identifier of the connection group being edited. If a new connection
+     * group is being created, this will not be defined.
+     *
+     * @type String
+     */
+    var identifier = $routeParams.id;
+
+    /**
+     * The root connection group of the connection group hierarchy.
+     *
+     * @type ConnectionGroup
+     */
+    $scope.rootGroup = null;
+
+    /**
+     * The connection group being modified.
+     * 
+     * @type ConnectionGroup
+     */
+    $scope.connectionGroup = null;
+    
+    /**
+     * Whether the user has UPDATE permission for the current connection group.
+     * 
+     * @type Boolean
+     */
+    $scope.hasUpdatePermission = null;
+    
+    /**
+     * Whether the user has DELETE permission for the current connection group.
+     * 
+     * @type Boolean
+     */
+    $scope.hasDeletePermission = null;
+
+    /**
+     * All permissions associated with the current user, or null if the user's
+     * permissions have not yet been loaded.
+     *
+     * @type PermissionSet
+     */
+    $scope.permissions = null;
+
+    /**
+     * All available connection group attributes. This is only the set of
+     * attribute definitions, organized as logical groupings of attributes, not
+     * attribute values.
+     *
+     * @type Form[]
+     */
+    $scope.attributes = null;
+
+    /**
+     * Returns whether critical data has completed being loaded.
+     *
+     * @returns {Boolean}
+     *     true if enough data has been loaded for the user interface to be
+     *     useful, false otherwise.
+     */
+    $scope.isLoaded = function isLoaded() {
+
+        return $scope.rootGroup                !== null
+            && $scope.connectionGroup          !== null
+            && $scope.permissions              !== null
+            && $scope.attributes               !== null
+            && $scope.canSaveConnectionGroup   !== null
+            && $scope.canDeleteConnectionGroup !== null;
+
+    };
+    
+    // Pull connection group attribute schema
+    schemaService.getConnectionGroupAttributes($scope.selectedDataSource)
+    .success(function attributesReceived(attributes) {
+        $scope.attributes = attributes;
+    });
+
+    // Query the user's permissions for the current connection group
+    permissionService.getPermissions($scope.selectedDataSource, authenticationService.getCurrentUsername())
+    .success(function permissionsReceived(permissions) {
+                
+        $scope.permissions = permissions;
+                        
+        // Check if the connection group is new or if the user has UPDATE permission
+        $scope.canSaveConnectionGroup =
+              !identifier
+           || PermissionSet.hasSystemPermission(permissions, PermissionSet.SystemPermissionType.ADMINISTER)
+           || PermissionSet.hasConnectionGroupPermission(permissions, PermissionSet.ObjectPermissionType.UPDATE, identifier);
+
+        // Check if connection group is not new and the user has DELETE permission
+        $scope.canDeleteConnectionGroup =
+           !!identifier && (
+                  PermissionSet.hasSystemPermission(permissions, PermissionSet.SystemPermissionType.ADMINISTER)
+              ||  PermissionSet.hasConnectionGroupPermission(permissions, PermissionSet.ObjectPermissionType.DELETE, identifier)
+           );
+    
+    });
+
+
+    // Pull connection group hierarchy
+    connectionGroupService.getConnectionGroupTree(
+        $scope.selectedDataSource,
+        ConnectionGroup.ROOT_IDENTIFIER,
+        [PermissionSet.ObjectPermissionType.ADMINISTER]
+    )
+    .success(function connectionGroupReceived(rootGroup) {
+        $scope.rootGroup = rootGroup;
+    });
+
+    // If we are editing an existing connection group, pull its data
+    if (identifier) {
+        connectionGroupService.getConnectionGroup($scope.selectedDataSource, identifier)
+        .success(function connectionGroupReceived(connectionGroup) {
+            $scope.connectionGroup = connectionGroup;
+        });
+    }
+
+    // If we are creating a new connection group, populate skeleton connection group data
+    else
+        $scope.connectionGroup = new ConnectionGroup();
+
+    /**
+     * Available connection group types, as translation string / internal value
+     * pairs.
+     * 
+     * @type Object[]
+     */
+    $scope.types = [
+        {
+            label: "MANAGE_CONNECTION_GROUP.NAME_TYPE_ORGANIZATIONAL",
+            value: ConnectionGroup.Type.ORGANIZATIONAL
+        },
+        {
+            label: "MANAGE_CONNECTION_GROUP.NAME_TYPE_BALANCING",
+            value : ConnectionGroup.Type.BALANCING
+        }
+    ];
+    
+    /**
+     * Cancels all pending edits, returning to the management page.
+     */
+    $scope.cancel = function cancel() {
+        $location.path('/settings/' + encodeURIComponent($scope.selectedDataSource) + '/connections');
+    };
+   
+    /**
+     * Saves the connection group, creating a new connection group or updating
+     * the existing connection group.
+     */
+    $scope.saveConnectionGroup = function saveConnectionGroup() {
+
+        // Save the connection
+        connectionGroupService.saveConnectionGroup($scope.selectedDataSource, $scope.connectionGroup)
+        .success(function savedConnectionGroup() {
+            $location.path('/settings/' + encodeURIComponent($scope.selectedDataSource) + '/connections');
+        })
+
+        // Notify of any errors
+        .error(function connectionGroupSaveFailed(error) {
+            guacNotification.showStatus({
+                'className'  : 'error',
+                'title'      : 'MANAGE_CONNECTION_GROUP.DIALOG_HEADER_ERROR',
+                'text'       : error.message,
+                'actions'    : [ ACKNOWLEDGE_ACTION ]
+            });
+        });
+
+    };
+    
+    /**
+     * An action to be provided along with the object sent to showStatus which
+     * immediately deletes the current connection group.
+     */
+    var DELETE_ACTION = {
+        name        : "MANAGE_CONNECTION_GROUP.ACTION_DELETE",
+        className   : "danger",
+        // Handle action
+        callback    : function deleteCallback() {
+            deleteConnectionGroupImmediately();
+            guacNotification.showStatus(false);
+        }
+    };
+
+    /**
+     * An action to be provided along with the object sent to showStatus which
+     * closes the currently-shown status dialog.
+     */
+    var CANCEL_ACTION = {
+        name        : "MANAGE_CONNECTION_GROUP.ACTION_CANCEL",
+        // Handle action
+        callback    : function cancelCallback() {
+            guacNotification.showStatus(false);
+        }
+    };
+
+    /**
+     * Immediately deletes the current connection group, without prompting the
+     * user for confirmation.
+     */
+    var deleteConnectionGroupImmediately = function deleteConnectionGroupImmediately() {
+
+        // Delete the connection group
+        connectionGroupService.deleteConnectionGroup($scope.selectedDataSource, $scope.connectionGroup)
+        .success(function deletedConnectionGroup() {
+            $location.path('/settings/' + encodeURIComponent($scope.selectedDataSource) + '/connections');
+        })
+
+        // Notify of any errors
+        .error(function connectionGroupDeletionFailed(error) {
+            guacNotification.showStatus({
+                'className'  : 'error',
+                'title'      : 'MANAGE_CONNECTION_GROUP.DIALOG_HEADER_ERROR',
+                'text'       : error.message,
+                'actions'    : [ ACKNOWLEDGE_ACTION ]
+            });
+        });
+
+    };
+
+    /**
+     * Deletes the connection group, prompting the user first to confirm that
+     * deletion is desired.
+     */
+    $scope.deleteConnectionGroup = function deleteConnectionGroup() {
+
+        // Confirm deletion request
+        guacNotification.showStatus({
+            'title'      : 'MANAGE_CONNECTION_GROUP.DIALOG_HEADER_CONFIRM_DELETE',
+            'text'       : 'MANAGE_CONNECTION_GROUP.TEXT_CONFIRM_DELETE',
+            'actions'    : [ DELETE_ACTION, CANCEL_ACTION]
+        });
+
+    };
+
+}]);
diff --git a/guacamole/src/main/webapp/app/manage/controllers/manageUserController.js b/guacamole/src/main/webapp/app/manage/controllers/manageUserController.js
new file mode 100644
index 0000000..1641091
--- /dev/null
+++ b/guacamole/src/main/webapp/app/manage/controllers/manageUserController.js
@@ -0,0 +1,962 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * The controller for editing users.
+ */
+angular.module('manage').controller('manageUserController', ['$scope', '$injector', 
+        function manageUserController($scope, $injector) {
+            
+    // Required types
+    var ConnectionGroup   = $injector.get('ConnectionGroup');
+    var PageDefinition    = $injector.get('PageDefinition');
+    var PermissionFlagSet = $injector.get('PermissionFlagSet');
+    var PermissionSet     = $injector.get('PermissionSet');
+    var User              = $injector.get('User');
+
+    // Required services
+    var $location                = $injector.get('$location');
+    var $routeParams             = $injector.get('$routeParams');
+    var authenticationService    = $injector.get('authenticationService');
+    var connectionGroupService   = $injector.get('connectionGroupService');
+    var dataSourceService        = $injector.get('dataSourceService');
+    var guacNotification         = $injector.get('guacNotification');
+    var permissionService        = $injector.get('permissionService');
+    var schemaService            = $injector.get('schemaService');
+    var translationStringService = $injector.get('translationStringService');
+    var userService              = $injector.get('userService');
+
+    /**
+     * An action to be provided along with the object sent to showStatus which
+     * closes the currently-shown status dialog.
+     */
+    var ACKNOWLEDGE_ACTION = {
+        name        : "MANAGE_USER.ACTION_ACKNOWLEDGE",
+        // Handle action
+        callback    : function acknowledgeCallback() {
+            guacNotification.showStatus(false);
+        }
+    };
+
+    /**
+     * The identifiers of all data sources currently available to the
+     * authenticated user.
+     *
+     * @type String[]
+     */
+    var dataSources = authenticationService.getAvailableDataSources();
+
+    /**
+     * The username of the current, authenticated user.
+     *
+     * @type String
+     */
+    var currentUsername = authenticationService.getCurrentUsername();
+
+    /**
+     * The unique identifier of the data source containing the user being
+     * edited.
+     *
+     * @type String
+     */
+    var selectedDataSource = $routeParams.dataSource;
+
+    /**
+     * The username of the user being edited. If a new user is
+     * being created, this will not be defined.
+     *
+     * @type String
+     */
+    var username = $routeParams.id;
+
+    /**
+     * All user accounts associated with the same username as the account being
+     * created or edited, as a map of data source identifier to the User object
+     * within that data source.
+     *
+     * @type Object.<String, User>
+     */
+    $scope.users = null;
+
+    /**
+     * The user being modified.
+     *
+     * @type User
+     */
+    $scope.user = null;
+
+    /**
+     * All permissions associated with the user being modified.
+     * 
+     * @type PermissionFlagSet
+     */
+    $scope.permissionFlags = null;
+
+    /**
+     * A map of data source identifiers to the root connection groups within
+     * thost data sources. As only one data source is applicable to any one
+     * user being edited/created, this will only contain a single key.
+     *
+     * @type Object.<String, ConnectionGroup>
+     */
+    $scope.rootGroups = null;
+
+    /**
+     * Array of all connection properties that are filterable.
+     *
+     * @type String[]
+     */
+    $scope.filteredConnectionProperties = [
+        'name',
+        'protocol'
+    ];
+
+    /**
+     * Array of all connection group properties that are filterable.
+     *
+     * @type String[]
+     */
+    $scope.filteredConnectionGroupProperties = [
+        'name'
+    ];
+
+    /**
+     * A map of data source identifiers to the set of all permissions
+     * associated with the current user under that data source, or null if the
+     * user's permissions have not yet been loaded.
+     *
+     * @type Object.<String, PermissionSet>
+     */
+    $scope.permissions = null;
+
+    /**
+     * All available user attributes. This is only the set of attribute
+     * definitions, organized as logical groupings of attributes, not attribute
+     * values.
+     *
+     * @type Form[]
+     */
+    $scope.attributes = null;
+
+    /**
+     * The pages associated with each user account having the given username.
+     * Each user account will be associated with a particular data source.
+     *
+     * @type PageDefinition[]
+     */
+    $scope.accountPages = [];
+
+    /**
+     * Returns whether critical data has completed being loaded.
+     *
+     * @returns {Boolean}
+     *     true if enough data has been loaded for the user interface to be
+     *     useful, false otherwise.
+     */
+    $scope.isLoaded = function isLoaded() {
+
+        return $scope.users               !== null
+            && $scope.permissionFlags     !== null
+            && $scope.rootGroups          !== null
+            && $scope.permissions         !== null
+            && $scope.attributes          !== null;
+
+    };
+
+    /**
+     * Returns whether the user being edited already exists within the data
+     * source specified.
+     *
+     * @param {String} [dataSource]
+     *     The identifier of the data source to check. If omitted, this will
+     *     default to the currently-selected data source.
+     *
+     * @returns {Boolean}
+     *     true if the user being edited already exists, false otherwise.
+     */
+    $scope.userExists = function userExists(dataSource) {
+
+        // Do not check if users are not yet loaded
+        if (!$scope.users)
+            return false;
+
+        // Use currently-selected data source if unspecified
+        dataSource = dataSource || selectedDataSource;
+
+        // Account exists only if it was successfully retrieved
+        return (dataSource in $scope.users);
+
+    };
+
+    /**
+     * Returns whether the current user can change attributes associated with
+     * the user being edited within the given data source.
+     *
+     * @param {String} [dataSource]
+     *     The identifier of the data source to check. If omitted, this will
+     *     default to the currently-selected data source.
+     *
+     * @returns {Boolean}
+     *     true if the current user can change attributes associated with the
+     *     user being edited, false otherwise.
+     */
+    $scope.canChangeAttributes = function canChangeAttributes(dataSource) {
+
+        // Do not check if permissions are not yet loaded
+        if (!$scope.permissions)
+            return false;
+
+        // Use currently-selected data source if unspecified
+        dataSource = dataSource || selectedDataSource;
+
+        // Attributes can always be set if we are creating the user
+        if (!$scope.userExists(dataSource))
+            return true;
+
+        // The administrator can always change attributes
+        if (PermissionSet.hasSystemPermission($scope.permissions[dataSource],
+                PermissionSet.SystemPermissionType.ADMINISTER))
+            return true;
+
+        // Otherwise, can change attributes if we have permission to update this user
+        return PermissionSet.hasUserPermission($scope.permissions[dataSource],
+            PermissionSet.ObjectPermissionType.UPDATE, username);
+
+    };
+
+    /**
+     * Returns whether the current user can change permissions of any kind
+     * which are associated with the user being edited within the given data
+     * source.
+     *
+     * @param {String} [dataSource]
+     *     The identifier of the data source to check. If omitted, this will
+     *     default to the currently-selected data source.
+     *
+     * @returns {Boolean}
+     *     true if the current user can grant or revoke permissions of any kind
+     *     which are associated with the user being edited, false otherwise.
+     */
+    $scope.canChangePermissions = function canChangePermissions(dataSource) {
+
+        // Do not check if permissions are not yet loaded
+        if (!$scope.permissions)
+            return false;
+
+        // Use currently-selected data source if unspecified
+        dataSource = dataSource || selectedDataSource;
+
+        // Permissions can always be set if we are creating the user
+        if (!$scope.userExists(dataSource))
+            return true;
+
+        // The administrator can always modify permissions
+        if (PermissionSet.hasSystemPermission($scope.permissions[dataSource],
+                PermissionSet.SystemPermissionType.ADMINISTER))
+            return true;
+
+        // Otherwise, can only modify permissions if we have explicit
+        // ADMINISTER permission
+        return PermissionSet.hasUserPermission($scope.permissions[dataSource],
+            PermissionSet.ObjectPermissionType.ADMINISTER, username);
+
+    };
+
+    /**
+     * Returns whether the current user can change the system permissions
+     * granted to the user being edited within the given data source.
+     *
+     * @param {String} [dataSource]
+     *     The identifier of the data source to check. If omitted, this will
+     *     default to the currently-selected data source.
+     *
+     * @returns {Boolean}
+     *     true if the current user can grant or revoke system permissions to
+     *     the user being edited, false otherwise.
+     */
+    $scope.canChangeSystemPermissions = function canChangeSystemPermissions(dataSource) {
+
+        // Do not check if permissions are not yet loaded
+        if (!$scope.permissions)
+            return false;
+
+        // Use currently-selected data source if unspecified
+        dataSource = dataSource || selectedDataSource;
+
+        // Only the administrator can modify system permissions
+        return PermissionSet.hasSystemPermission($scope.permissions[dataSource],
+            PermissionSet.SystemPermissionType.ADMINISTER);
+
+    };
+
+    /**
+     * Returns whether the current user can edit the username of the user being
+     * edited within the given data source.
+     *
+     * @param {String} [dataSource]
+     *     The identifier of the data source to check. If omitted, this will
+     *     default to the currently-selected data source.
+     *
+     * @returns {Boolean}
+     *     true if the current user can edit the username of the user being
+     *     edited, false otherwise.
+     */
+    $scope.canEditUsername = function canEditUsername(dataSource) {
+        return !username;
+    };
+
+    /**
+     * Returns whether the current user can save the user being edited within
+     * the given data source. Saving will create or update that user depending
+     * on whether the user already exists.
+     *
+     * @param {String} [dataSource]
+     *     The identifier of the data source to check. If omitted, this will
+     *     default to the currently-selected data source.
+     *
+     * @returns {Boolean}
+     *     true if the current user can save changes to the user being edited,
+     *     false otherwise.
+     */
+    $scope.canSaveUser = function canSaveUser(dataSource) {
+
+        // Do not check if permissions are not yet loaded
+        if (!$scope.permissions)
+            return false;
+
+        // Use currently-selected data source if unspecified
+        dataSource = dataSource || selectedDataSource;
+
+        // The administrator can always save users
+        if (PermissionSet.hasSystemPermission($scope.permissions[dataSource],
+                PermissionSet.SystemPermissionType.ADMINISTER))
+            return true;
+
+        // If user does not exist, can only save if we have permission to create users
+        if (!$scope.userExists(dataSource))
+           return PermissionSet.hasSystemPermission($scope.permissions[dataSource],
+               PermissionSet.SystemPermissionType.CREATE_USER);
+
+        // Otherwise, can only save if we have permission to update this user
+        return PermissionSet.hasUserPermission($scope.permissions[dataSource],
+            PermissionSet.ObjectPermissionType.UPDATE, username);
+
+    };
+
+    /**
+     * Returns whether the current user can delete the user being edited from
+     * the given data source.
+     *
+     * @param {String} [dataSource]
+     *     The identifier of the data source to check. If omitted, this will
+     *     default to the currently-selected data source.
+     *
+     * @returns {Boolean}
+     *     true if the current user can delete the user being edited, false
+     *     otherwise.
+     */
+    $scope.canDeleteUser = function canDeleteUser(dataSource) {
+
+        // Do not check if permissions are not yet loaded
+        if (!$scope.permissions)
+            return false;
+
+        // Use currently-selected data source if unspecified
+        dataSource = dataSource || selectedDataSource;
+
+        // Can't delete what doesn't exist
+        if (!$scope.userExists(dataSource))
+            return false;
+
+        // The administrator can always delete users
+        if (PermissionSet.hasSystemPermission($scope.permissions[dataSource],
+                PermissionSet.SystemPermissionType.ADMINISTER))
+            return true;
+
+        // Otherwise, require explicit DELETE permission on the user
+        return PermissionSet.hasUserPermission($scope.permissions[dataSource],
+            PermissionSet.ObjectPermissionType.DELETE, username);
+
+    };
+
+    /**
+     * Returns whether the user being edited within the given data source is
+     * read-only, and thus cannot be modified by the current user.
+     *
+     * @param {String} [dataSource]
+     *     The identifier of the data source to check. If omitted, this will
+     *     default to the currently-selected data source.
+     *
+     * @returns {Boolean}
+     *     true if the user being edited is actually read-only and cannot be
+     *     edited at all, false otherwise.
+     */
+    $scope.isReadOnly = function isReadOnly(dataSource) {
+
+        // Use currently-selected data source if unspecified
+        dataSource = dataSource || selectedDataSource;
+
+        // User is read-only if they cannot be saved
+        return !$scope.canSaveUser(dataSource);
+
+    };
+
+    // Update visible account pages whenever available users/permissions changes
+    $scope.$watchGroup(['users', 'permissions'], function updateAccountPages() {
+
+        // Generate pages for each applicable data source
+        $scope.accountPages = [];
+        angular.forEach(dataSources, function addAccountPage(dataSource) {
+
+            // Determine whether data source contains this user
+            var linked   = $scope.userExists(dataSource);
+            var readOnly = $scope.isReadOnly(dataSource);
+
+            // Account is not relevant if it does not exist and cannot be
+            // created
+            if (!linked && readOnly)
+                return;
+
+            // Determine class name based on read-only / linked status
+            var className;
+            if (readOnly)    className = 'read-only';
+            else if (linked) className = 'linked';
+            else             className = 'unlinked';
+
+            // Add page entry
+            $scope.accountPages.push(new PageDefinition({
+                name      : translationStringService.canonicalize('DATA_SOURCE_' + dataSource) + '.NAME',
+                url       : '/manage/' + encodeURIComponent(dataSource) + '/users/' + encodeURIComponent(username || ''),
+                className : className
+            }));
+
+        });
+
+    });
+
+    // Pull user attribute schema
+    schemaService.getUserAttributes(selectedDataSource).success(function attributesReceived(attributes) {
+        $scope.attributes = attributes;
+    });
+
+    // Pull user data and permissions if we are editing an existing user
+    if (username) {
+
+        // Pull user data
+        dataSourceService.apply(userService.getUser, dataSources, username)
+        .then(function usersReceived(users) {
+
+            // Get user for currently-selected data source
+            $scope.users = users;
+            $scope.user  = users[selectedDataSource];
+
+            // Create skeleton user if user does not exist
+            if (!$scope.user)
+                $scope.user = new User({
+                    'username' : username
+                });
+
+        });
+
+        // Pull user permissions
+        permissionService.getPermissions(selectedDataSource, username).success(function gotPermissions(permissions) {
+            $scope.permissionFlags = PermissionFlagSet.fromPermissionSet(permissions);
+        })
+
+        // If permissions cannot be retrieved, use empty permissions
+        .error(function permissionRetrievalFailed() {
+            $scope.permissionFlags = new PermissionFlagSet();
+        });
+    }
+
+    // Use skeleton data if we are creating a new user
+    else {
+
+        // No users exist regardless of data source if there is no username
+        $scope.users = {};
+
+        // Use skeleton user object with no associated permissions
+        $scope.user = new User();
+        $scope.permissionFlags = new PermissionFlagSet();
+
+    }
+
+    // Retrieve all connections for which we have ADMINISTER permission
+    dataSourceService.apply(
+        connectionGroupService.getConnectionGroupTree,
+        [selectedDataSource],
+        ConnectionGroup.ROOT_IDENTIFIER,
+        [PermissionSet.ObjectPermissionType.ADMINISTER]
+    )
+    .then(function connectionGroupReceived(rootGroups) {
+        $scope.rootGroups = rootGroups;
+    });
+    
+    // Query the user's permissions for the current user
+    dataSourceService.apply(
+        permissionService.getPermissions,
+        dataSources,
+        currentUsername
+    )
+    .then(function permissionsReceived(permissions) {
+        $scope.permissions = permissions;
+    });
+
+    /**
+     * Available system permission types, as translation string / internal
+     * value pairs.
+     * 
+     * @type Object[]
+     */
+    $scope.systemPermissionTypes = [
+        {
+            label: "MANAGE_USER.FIELD_HEADER_ADMINISTER_SYSTEM",
+            value: PermissionSet.SystemPermissionType.ADMINISTER
+        },
+        {
+            label: "MANAGE_USER.FIELD_HEADER_CREATE_NEW_USERS",
+            value: PermissionSet.SystemPermissionType.CREATE_USER
+        },
+        {
+            label: "MANAGE_USER.FIELD_HEADER_CREATE_NEW_CONNECTIONS",
+            value: PermissionSet.SystemPermissionType.CREATE_CONNECTION
+        },
+        {
+            label: "MANAGE_USER.FIELD_HEADER_CREATE_NEW_CONNECTION_GROUPS",
+            value: PermissionSet.SystemPermissionType.CREATE_CONNECTION_GROUP
+        }
+    ];
+
+    /**
+     * The set of permissions that will be added to the user when the user is
+     * saved. Permissions will only be present in this set if they are
+     * manually added, and not later manually removed before saving.
+     *
+     * @type PermissionSet
+     */
+    var permissionsAdded = new PermissionSet();
+
+    /**
+     * The set of permissions that will be removed from the user when the user 
+     * is saved. Permissions will only be present in this set if they are
+     * manually removed, and not later manually added before saving.
+     *
+     * @type PermissionSet
+     */
+    var permissionsRemoved = new PermissionSet();
+
+    /**
+     * Updates the permissionsAdded and permissionsRemoved permission sets to
+     * reflect the addition of the given system permission.
+     * 
+     * @param {String} type
+     *     The system permission to add, as defined by
+     *     PermissionSet.SystemPermissionType.
+     */
+    var addSystemPermission = function addSystemPermission(type) {
+
+        // If permission was previously removed, simply un-remove it
+        if (PermissionSet.hasSystemPermission(permissionsRemoved, type))
+            PermissionSet.removeSystemPermission(permissionsRemoved, type);
+
+        // Otherwise, explicitly add the permission
+        else
+            PermissionSet.addSystemPermission(permissionsAdded, type);
+
+    };
+
+    /**
+     * Updates the permissionsAdded and permissionsRemoved permission sets to
+     * reflect the removal of the given system permission.
+     *
+     * @param {String} type
+     *     The system permission to remove, as defined by
+     *     PermissionSet.SystemPermissionType.
+     */
+    var removeSystemPermission = function removeSystemPermission(type) {
+
+        // If permission was previously added, simply un-add it
+        if (PermissionSet.hasSystemPermission(permissionsAdded, type))
+            PermissionSet.removeSystemPermission(permissionsAdded, type);
+
+        // Otherwise, explicitly remove the permission
+        else
+            PermissionSet.addSystemPermission(permissionsRemoved, type);
+
+    };
+
+    /**
+     * Notifies the controller that a change has been made to the given
+     * system permission for the user being edited.
+     *
+     * @param {String} type
+     *     The system permission that was changed, as defined by
+     *     PermissionSet.SystemPermissionType.
+     */
+    $scope.systemPermissionChanged = function systemPermissionChanged(type) {
+
+        // Determine current permission setting
+        var value = $scope.permissionFlags.systemPermissions[type];
+
+        // Add/remove permission depending on flag state
+        if (value)
+            addSystemPermission(type);
+        else
+            removeSystemPermission(type);
+
+    };
+
+    /**
+     * Updates the permissionsAdded and permissionsRemoved permission sets to
+     * reflect the addition of the given user permission.
+     * 
+     * @param {String} type
+     *     The user permission to add, as defined by
+     *     PermissionSet.ObjectPermissionType.
+     *
+     * @param {String} identifier
+     *     The identifier of the user affected by the permission being added.
+     */
+    var addUserPermission = function addUserPermission(type, identifier) {
+
+        // If permission was previously removed, simply un-remove it
+        if (PermissionSet.hasUserPermission(permissionsRemoved, type, identifier))
+            PermissionSet.removeUserPermission(permissionsRemoved, type, identifier);
+
+        // Otherwise, explicitly add the permission
+        else
+            PermissionSet.addUserPermission(permissionsAdded, type, identifier);
+
+    };
+
+    /**
+     * Updates the permissionsAdded and permissionsRemoved permission sets to
+     * reflect the removal of the given user permission.
+     *
+     * @param {String} type
+     *     The user permission to remove, as defined by
+     *     PermissionSet.ObjectPermissionType.
+     *
+     * @param {String} identifier
+     *     The identifier of the user affected by the permission being removed.
+     */
+    var removeUserPermission = function removeUserPermission(type, identifier) {
+
+        // If permission was previously added, simply un-add it
+        if (PermissionSet.hasUserPermission(permissionsAdded, type, identifier))
+            PermissionSet.removeUserPermission(permissionsAdded, type, identifier);
+
+        // Otherwise, explicitly remove the permission
+        else
+            PermissionSet.addUserPermission(permissionsRemoved, type, identifier);
+
+    };
+
+    /**
+     * Notifies the controller that a change has been made to the given user
+     * permission for the user being edited.
+     *
+     * @param {String} type
+     *     The user permission that was changed, as defined by
+     *     PermissionSet.ObjectPermissionType.
+     *
+     * @param {String} identifier
+     *     The identifier of the user affected by the changed permission.
+     */
+    $scope.userPermissionChanged = function userPermissionChanged(type, identifier) {
+
+        // Determine current permission setting
+        var value = $scope.permissionFlags.userPermissions[type][identifier];
+
+        // Add/remove permission depending on flag state
+        if (value)
+            addUserPermission(type, identifier);
+        else
+            removeUserPermission(type, identifier);
+
+    };
+
+    /**
+     * Updates the permissionsAdded and permissionsRemoved permission sets to
+     * reflect the addition of the given connection permission.
+     * 
+     * @param {String} identifier
+     *     The identifier of the connection to add READ permission for.
+     */
+    var addConnectionPermission = function addConnectionPermission(identifier) {
+
+        // If permission was previously removed, simply un-remove it
+        if (PermissionSet.hasConnectionPermission(permissionsRemoved, PermissionSet.ObjectPermissionType.READ, identifier))
+            PermissionSet.removeConnectionPermission(permissionsRemoved, PermissionSet.ObjectPermissionType.READ, identifier);
+
+        // Otherwise, explicitly add the permission
+        else
+            PermissionSet.addConnectionPermission(permissionsAdded, PermissionSet.ObjectPermissionType.READ, identifier);
+
+    };
+
+    /**
+     * Updates the permissionsAdded and permissionsRemoved permission sets to
+     * reflect the removal of the given connection permission.
+     *
+     * @param {String} identifier
+     *     The identifier of the connection to remove READ permission for.
+     */
+    var removeConnectionPermission = function removeConnectionPermission(identifier) {
+
+        // If permission was previously added, simply un-add it
+        if (PermissionSet.hasConnectionPermission(permissionsAdded, PermissionSet.ObjectPermissionType.READ, identifier))
+            PermissionSet.removeConnectionPermission(permissionsAdded, PermissionSet.ObjectPermissionType.READ, identifier);
+
+        // Otherwise, explicitly remove the permission
+        else
+            PermissionSet.addConnectionPermission(permissionsRemoved, PermissionSet.ObjectPermissionType.READ, identifier);
+
+    };
+
+    /**
+     * Updates the permissionsAdded and permissionsRemoved permission sets to
+     * reflect the addition of the given connection group permission.
+     * 
+     * @param {String} identifier
+     *     The identifier of the connection group to add READ permission for.
+     */
+    var addConnectionGroupPermission = function addConnectionGroupPermission(identifier) {
+
+        // If permission was previously removed, simply un-remove it
+        if (PermissionSet.hasConnectionGroupPermission(permissionsRemoved, PermissionSet.ObjectPermissionType.READ, identifier))
+            PermissionSet.removeConnectionGroupPermission(permissionsRemoved, PermissionSet.ObjectPermissionType.READ, identifier);
+
+        // Otherwise, explicitly add the permission
+        else
+            PermissionSet.addConnectionGroupPermission(permissionsAdded, PermissionSet.ObjectPermissionType.READ, identifier);
+
+    };
+
+    /**
+     * Updates the permissionsAdded and permissionsRemoved permission sets to
+     * reflect the removal of the given connection permission.
+     *
+     * @param {String} identifier
+     *     The identifier of the connection to remove READ permission for.
+     */
+    var removeConnectionGroupPermission = function removeConnectionGroupPermission(identifier) {
+
+        // If permission was previously added, simply un-add it
+        if (PermissionSet.hasConnectionGroupPermission(permissionsAdded, PermissionSet.ObjectPermissionType.READ, identifier))
+            PermissionSet.removeConnectionGroupPermission(permissionsAdded, PermissionSet.ObjectPermissionType.READ, identifier);
+
+        // Otherwise, explicitly remove the permission
+        else
+            PermissionSet.addConnectionGroupPermission(permissionsRemoved, PermissionSet.ObjectPermissionType.READ, identifier);
+
+    };
+
+    // Expose permission query and modification functions to group list template
+    $scope.groupListContext = {
+
+        /**
+         * Returns the PermissionFlagSet that contains the current state of
+         * granted permissions.
+         *
+         * @returns {PermissionFlagSet}
+         *     The PermissionFlagSet describing the current state of granted
+         *     permissions for the user being edited.
+         */
+        getPermissionFlags : function getPermissionFlags() {
+            return $scope.permissionFlags;
+        },
+
+        /**
+         * Notifies the controller that a change has been made to the given
+         * connection permission for the user being edited. This only applies
+         * to READ permissions.
+         *
+         * @param {String} identifier
+         *     The identifier of the connection affected by the changed
+         *     permission.
+         */
+        connectionPermissionChanged : function connectionPermissionChanged(identifier) {
+
+            // Determine current permission setting
+            var value = $scope.permissionFlags.connectionPermissions.READ[identifier];
+
+            // Add/remove permission depending on flag state
+            if (value)
+                addConnectionPermission(identifier);
+            else
+                removeConnectionPermission(identifier);
+
+        },
+
+        /**
+         * Notifies the controller that a change has been made to the given
+         * connection group permission for the user being edited. This only
+         * applies to READ permissions.
+         *
+         * @param {String} identifier
+         *     The identifier of the connection group affected by the changed
+         *     permission.
+         */
+        connectionGroupPermissionChanged : function connectionGroupPermissionChanged(identifier) {
+
+            // Determine current permission setting
+            var value = $scope.permissionFlags.connectionGroupPermissions.READ[identifier];
+
+            // Add/remove permission depending on flag state
+            if (value)
+                addConnectionGroupPermission(identifier);
+            else
+                removeConnectionGroupPermission(identifier);
+
+        }
+
+    };
+
+    /**
+     * Cancels all pending edits, returning to the management page.
+     */
+    $scope.cancel = function cancel() {
+        $location.path('/settings/users');
+    };
+            
+    /**
+     * Saves the user, updating the existing user only.
+     */
+    $scope.saveUser = function saveUser() {
+
+        // Verify passwords match
+        if ($scope.passwordMatch !== $scope.user.password) {
+            guacNotification.showStatus({
+                'className'  : 'error',
+                'title'      : 'MANAGE_USER.DIALOG_HEADER_ERROR',
+                'text'       : 'MANAGE_USER.ERROR_PASSWORD_MISMATCH',
+                'actions'    : [ ACKNOWLEDGE_ACTION ]
+            });
+            return;
+        }
+
+        // Save or create the user, depending on whether the user exists
+        var saveUserPromise;
+        if ($scope.userExists(selectedDataSource))
+            saveUserPromise = userService.saveUser(selectedDataSource, $scope.user);
+        else
+            saveUserPromise = userService.createUser(selectedDataSource, $scope.user);
+
+        saveUserPromise.success(function savedUser() {
+
+            // Upon success, save any changed permissions
+            permissionService.patchPermissions(selectedDataSource, $scope.user.username, permissionsAdded, permissionsRemoved)
+            .success(function patchedUserPermissions() {
+                $location.path('/settings/users');
+            })
+
+            // Notify of any errors
+            .error(function userPermissionsPatchFailed(error) {
+                guacNotification.showStatus({
+                    'className'  : 'error',
+                    'title'      : 'MANAGE_USER.DIALOG_HEADER_ERROR',
+                    'text'       : error.message,
+                    'actions'    : [ ACKNOWLEDGE_ACTION ]
+                });
+            });
+
+        })
+
+        // Notify of any errors
+        .error(function userSaveFailed(error) {
+            guacNotification.showStatus({
+                'className'  : 'error',
+                'title'      : 'MANAGE_USER.DIALOG_HEADER_ERROR',
+                'text'       : error.message,
+                'actions'    : [ ACKNOWLEDGE_ACTION ]
+            });
+        });
+
+    };
+    
+    /**
+     * An action to be provided along with the object sent to showStatus which
+     * immediately deletes the current user.
+     */
+    var DELETE_ACTION = {
+        name        : "MANAGE_USER.ACTION_DELETE",
+        className   : "danger",
+        // Handle action
+        callback    : function deleteCallback() {
+            deleteUserImmediately();
+            guacNotification.showStatus(false);
+        }
+    };
+
+    /**
+     * An action to be provided along with the object sent to showStatus which
+     * closes the currently-shown status dialog.
+     */
+    var CANCEL_ACTION = {
+        name        : "MANAGE_USER.ACTION_CANCEL",
+        // Handle action
+        callback    : function cancelCallback() {
+            guacNotification.showStatus(false);
+        }
+    };
+
+    /**
+     * Immediately deletes the current user, without prompting the user for
+     * confirmation.
+     */
+    var deleteUserImmediately = function deleteUserImmediately() {
+
+        // Delete the user 
+        userService.deleteUser(selectedDataSource, $scope.user)
+        .success(function deletedUser() {
+            $location.path('/settings/users');
+        })
+
+        // Notify of any errors
+        .error(function userDeletionFailed(error) {
+            guacNotification.showStatus({
+                'className'  : 'error',
+                'title'      : 'MANAGE_USER.DIALOG_HEADER_ERROR',
+                'text'       : error.message,
+                'actions'    : [ ACKNOWLEDGE_ACTION ]
+            });
+        });
+
+    };
+
+    /**
+     * Deletes the user, prompting the user first to confirm that deletion is
+     * desired.
+     */
+    $scope.deleteUser = function deleteUser() {
+
+        // Confirm deletion request
+        guacNotification.showStatus({
+            'title'      : 'MANAGE_USER.DIALOG_HEADER_CONFIRM_DELETE',
+            'text'       : 'MANAGE_USER.TEXT_CONFIRM_DELETE',
+            'actions'    : [ DELETE_ACTION, CANCEL_ACTION]
+        });
+
+    };
+
+}]);
diff --git a/guacamole/src/main/webapp/app/manage/directives/locationChooser.js b/guacamole/src/main/webapp/app/manage/directives/locationChooser.js
new file mode 100644
index 0000000..97dca1f
--- /dev/null
+++ b/guacamole/src/main/webapp/app/manage/directives/locationChooser.js
@@ -0,0 +1,172 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+
+/**
+ * A directive for choosing the location of a connection or connection group.
+ */
+angular.module('manage').directive('locationChooser', [function locationChooser() {
+    
+    return {
+        // Element only
+        restrict: 'E',
+        replace: true,
+
+        scope: {
+
+            /**
+             * The identifier of the data source from which the given root
+             * connection group was retrieved.
+             *
+             * @type String
+             */
+            dataSource : '=',
+
+            /**
+             * The root connection group of the connection group hierarchy to
+             * display.
+             *
+             * @type ConnectionGroup
+             */
+            rootGroup : '=',
+
+            /**
+             * The unique identifier of the currently-selected connection
+             * group. If not specified, the root group will be used.
+             *
+             * @type String
+             */
+            value : '='
+
+        },
+
+        templateUrl: 'app/manage/templates/locationChooser.html',
+        controller: ['$scope', function locationChooserController($scope) {
+
+            /**
+             * Map of unique identifiers to their corresponding connection
+             * groups.
+             *
+             * @type Object.<String, GroupListItem>
+             */
+            var connectionGroups = {};
+
+            /**
+             * Recursively traverses the given connection group and all
+             * children, storing each encountered connection group within the
+             * connectionGroups map by its identifier.
+             *
+             * @param {GroupListItem} group
+             *     The connection group to traverse.
+             */
+            var mapConnectionGroups = function mapConnectionGroups(group) {
+
+                // Map given group
+                connectionGroups[group.identifier] = group;
+
+                // Map all child groups
+                if (group.childConnectionGroups)
+                    group.childConnectionGroups.forEach(mapConnectionGroups);
+
+            };
+
+            /**
+             * Whether the group list menu is currently open.
+             * 
+             * @type Boolean
+             */
+            $scope.menuOpen = false;
+            
+            /**
+             * The human-readable name of the currently-chosen connection
+             * group.
+             * 
+             * @type String
+             */
+            $scope.chosenConnectionGroupName = null;
+            
+            /**
+             * Toggle the current state of the menu listing connection groups.
+             * If the menu is currently open, it will be closed. If currently
+             * closed, it will be opened.
+             */
+            $scope.toggleMenu = function toggleMenu() {
+                $scope.menuOpen = !$scope.menuOpen;
+            };
+
+            // Update the root group map when data source or root group change
+            $scope.$watchGroup(['dataSource', 'rootGroup'], function updateRootGroups() {
+
+                // Abort if the root group is not set
+                if (!$scope.dataSource || !$scope.rootGroup)
+                    return null;
+
+                // Wrap root group in map
+                $scope.rootGroups = {};
+                $scope.rootGroups[$scope.dataSource] = $scope.rootGroup;
+
+            });
+
+            // Expose selection function to group list template
+            $scope.groupListContext = {
+                
+                /**
+                 * Selects the given group item.
+                 *
+                 * @param {GroupListItem} item
+                 *     The chosen item.
+                 */
+                chooseGroup : function chooseGroup(item) {
+
+                    // Record new parent
+                    $scope.value = item.identifier;
+                    $scope.chosenConnectionGroupName = item.name;
+
+                    // Close menu
+                    $scope.menuOpen = false;
+
+                }
+
+            };
+
+            $scope.$watch('rootGroup', function setRootGroup(rootGroup) {
+
+                connectionGroups = {};
+
+                if (!rootGroup)
+                    return;
+
+                // Map all known groups
+                mapConnectionGroups(rootGroup);
+
+                // If no value is specified, default to the root identifier
+                if (!$scope.value || !($scope.value in connectionGroups))
+                    $scope.value = rootGroup.identifier;
+
+                $scope.chosenConnectionGroupName = connectionGroups[$scope.value].name; 
+
+            });
+
+        }]
+    };
+    
+}]);
\ No newline at end of file
diff --git a/guacamole/src/main/webapp/app/manage/manageModule.js b/guacamole/src/main/webapp/app/manage/manageModule.js
new file mode 100644
index 0000000..5fde985
--- /dev/null
+++ b/guacamole/src/main/webapp/app/manage/manageModule.js
@@ -0,0 +1,34 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * The module for the administration functionality.
+ */
+angular.module('manage', [
+    'form',
+    'groupList',
+    'list',
+    'locale',
+    'navigation',
+    'notification',
+    'rest'
+]);
diff --git a/guacamole/src/main/webapp/app/manage/styles/attributes.css b/guacamole/src/main/webapp/app/manage/styles/attributes.css
new file mode 100644
index 0000000..9b3e826
--- /dev/null
+++ b/guacamole/src/main/webapp/app/manage/styles/attributes.css
@@ -0,0 +1,65 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/* Do not stretch attributes to fit available area */
+.attributes input[type=text],
+.attributes input[type=password],
+.attributes input[type=number] {
+    width: auto;
+}
+
+.attributes .form .fields {
+    display: table;
+    margin: 1em;
+}
+
+.attributes .form .fields .labeled-field {
+    display: table-row;
+}
+
+.attributes .form .fields .field-header,
+.attributes .form .fields .form-field {
+    display: table-cell;
+    padding: 0.125em;
+    vertical-align: top;
+}
+
+.attributes .form .fields .field-header {
+    padding-right: 1em;
+}
+
+.attributes .form h3 {
+
+    font-size: 1.25em;
+    font-weight: bold;
+    text-transform: uppercase;
+    padding: 0.75em 0.5em;
+    margin: 1em 0;
+
+    border-bottom: 1px solid rgba(0, 0, 0, 0.125);
+    border-top: 1px solid rgba(0, 0, 0, 0.125);
+    box-shadow: 0 1px 2px rgba(0, 0, 0, 0.125);
+    background: rgba(0, 0, 0, 0.04);
+
+    width: 100%;
+
+}
diff --git a/guacamole/src/main/webapp/app/manage/styles/connection-parameter.css b/guacamole/src/main/webapp/app/manage/styles/connection-parameter.css
new file mode 100644
index 0000000..14b6f3a
--- /dev/null
+++ b/guacamole/src/main/webapp/app/manage/styles/connection-parameter.css
@@ -0,0 +1,49 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/* Do not stretch connection parameters to fit available area */
+.connection-parameters input[type=text],
+.connection-parameters input[type=password],
+.connection-parameters input[type=number] {
+    width: auto;
+}
+
+.connection-parameters .form .fields {
+    display: table;
+    padding-left: .5em;
+    border-left: 3px solid rgba(0,0,0,0.125);
+}
+
+.connection-parameters .form .fields .labeled-field {
+    display: table-row;
+}
+
+.connection-parameters .form .fields .field-header,
+.connection-parameters .form .fields .form-field {
+    display: table-cell;
+    padding: 0.125em;
+    vertical-align: top;
+}
+
+.connection-parameters .form .fields .field-header {
+    padding-right: 1em;
+}
diff --git a/guacamole/src/main/webapp/app/manage/styles/forms.css b/guacamole/src/main/webapp/app/manage/styles/forms.css
new file mode 100644
index 0000000..0724562
--- /dev/null
+++ b/guacamole/src/main/webapp/app/manage/styles/forms.css
@@ -0,0 +1,32 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+.manage table.properties th {
+    text-align: left;
+    font-weight: normal;
+    padding-right: 1em;
+}
+
+.manage .action-buttons {
+    text-align: center;
+    margin-bottom: 1em;
+}
diff --git a/guacamole/src/main/webapp/app/manage/styles/locationChooser.css b/guacamole/src/main/webapp/app/manage/styles/locationChooser.css
new file mode 100644
index 0000000..8361422
--- /dev/null
+++ b/guacamole/src/main/webapp/app/manage/styles/locationChooser.css
@@ -0,0 +1,38 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+.location-chooser .dropdown {
+
+    position: absolute;
+    z-index: 2;
+    margin-top: -1px;
+
+    width: 3in;
+    max-height: 2in;
+    overflow: auto;
+
+    border: 1px solid rgba(0, 0, 0, 0.5);
+    background: white;
+
+    font-size: 10pt;
+
+}
diff --git a/guacamole/src/main/webapp/app/manage/styles/manage-user.css b/guacamole/src/main/webapp/app/manage/styles/manage-user.css
new file mode 100644
index 0000000..2a117d6
--- /dev/null
+++ b/guacamole/src/main/webapp/app/manage/styles/manage-user.css
@@ -0,0 +1,78 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+.manage-user .username.header {
+    margin-bottom: 0;
+}
+
+.manage-user .page-tabs .page-list li.read-only a[href],
+.manage-user .page-tabs .page-list li.unlinked  a[href],
+.manage-user .page-tabs .page-list li.linked    a[href] {
+    padding-right: 2.5em;
+    position: relative;
+}
+
+.manage-user .page-tabs .page-list li.read-only a[href]:before,
+.manage-user .page-tabs .page-list li.unlinked  a[href]:before,
+.manage-user .page-tabs .page-list li.linked    a[href]:before {
+    content: ' ';
+    position: absolute;
+    right: 0;
+    bottom: 0;
+    top: 0;
+    width: 2.5em;
+    background-size: 1.25em;
+    background-repeat: no-repeat;
+    background-position: center;
+}
+
+.manage-user .page-tabs .page-list li.read-only a[href]:before {
+    background-image: url('images/lock.png');
+}
+
+.manage-user .page-tabs .page-list li.unlinked a[href]:before {
+    background-image: url('images/plus.png');
+}
+
+.manage-user .page-tabs .page-list li.unlinked a[href] {
+    opacity: 0.5;
+}
+
+.manage-user .page-tabs .page-list li.unlinked a[href]:hover,
+.manage-user .page-tabs .page-list li.unlinked a[href].current {
+    opacity: 1;
+}
+
+.manage-user .page-tabs .page-list li.linked a[href]:before {
+    background-image: url('images/checkmark.png');
+}
+
+.manage-user .notice.read-only {
+
+    background: #FDA;
+    border: 1px solid rgba(0, 0, 0, 0.125);
+    border-radius: 0.25em;
+
+    text-align: center;
+    padding: 1em;
+
+}
\ No newline at end of file
diff --git a/guacamole/src/main/webapp/app/manage/templates/connectionGroupPermission.html b/guacamole/src/main/webapp/app/manage/templates/connectionGroupPermission.html
new file mode 100644
index 0000000..86ebcaf
--- /dev/null
+++ b/guacamole/src/main/webapp/app/manage/templates/connectionGroupPermission.html
@@ -0,0 +1,28 @@
+<div class="choice">
+    <!--
+       Copyright (C) 2014 Glyptodon LLC
+
+       Permission is hereby granted, free of charge, to any person obtaining a copy
+       of this software and associated documentation files (the "Software"), to deal
+       in the Software without restriction, including without limitation the rights
+       to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+       copies of the Software, and to permit persons to whom the Software is
+       furnished to do so, subject to the following conditions:
+
+       The above copyright notice and this permission notice shall be included in
+       all copies or substantial portions of the Software.
+
+       THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+       IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+       FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+       AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+       LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+       OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+       THE SOFTWARE.
+    -->
+
+    <input type="checkbox" ng-model="context.getPermissionFlags().connectionGroupPermissions.READ[item.identifier]"
+                           ng-change="context.connectionGroupPermissionChanged(item.identifier)"/>
+
+    <span class="name">{{item.name}}</span>
+</div>
diff --git a/guacamole/src/main/webapp/app/manage/templates/connectionPermission.html b/guacamole/src/main/webapp/app/manage/templates/connectionPermission.html
new file mode 100644
index 0000000..37985d7
--- /dev/null
+++ b/guacamole/src/main/webapp/app/manage/templates/connectionPermission.html
@@ -0,0 +1,36 @@
+<div class="choice">
+    <!--
+       Copyright (C) 2014 Glyptodon LLC
+
+       Permission is hereby granted, free of charge, to any person obtaining a copy
+       of this software and associated documentation files (the "Software"), to deal
+       in the Software without restriction, including without limitation the rights
+       to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+       copies of the Software, and to permit persons to whom the Software is
+       furnished to do so, subject to the following conditions:
+
+       The above copyright notice and this permission notice shall be included in
+       all copies or substantial portions of the Software.
+
+       THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+       IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+       FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+       AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+       LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+       OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+       THE SOFTWARE.
+    -->
+
+    <!-- Connection icon -->
+    <div class="protocol">
+        <div class="icon type" ng-class="item.protocol"></div>
+    </div>
+
+    <!-- Checkbox -->
+    <input type="checkbox" ng-model="context.getPermissionFlags().connectionPermissions.READ[item.identifier]"
+                           ng-change="context.connectionPermissionChanged(item.identifier)"/>
+
+    <!-- Connection name -->
+    <span class="name">{{item.name}}</span>
+
+</div>
diff --git a/guacamole/src/main/webapp/app/manage/templates/locationChooser.html b/guacamole/src/main/webapp/app/manage/templates/locationChooser.html
new file mode 100644
index 0000000..0075f9a
--- /dev/null
+++ b/guacamole/src/main/webapp/app/manage/templates/locationChooser.html
@@ -0,0 +1,36 @@
+<div class="location-chooser">
+    <!--
+    Copyright 2014 Glyptodon LLC.
+
+    Permission is hereby granted, free of charge, to any person obtaining a copy
+    of this software and associated documentation files (the "Software"), to deal
+    in the Software without restriction, including without limitation the rights
+    to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+    copies of the Software, and to permit persons to whom the Software is
+    furnished to do so, subject to the following conditions:
+
+    The above copyright notice and this permission notice shall be included in
+    all copies or substantial portions of the Software.
+
+    THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+    IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+    FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+    AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+    LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+    OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+    THE SOFTWARE.
+    -->
+
+    <!-- Chosen group name -->
+    <div ng-click="toggleMenu()" class="location">{{chosenConnectionGroupName}}</div>
+
+    <!-- Dropdown hierarchical menu of groups -->
+    <div ng-show="menuOpen" class="dropdown">
+        <guac-group-list
+            context="groupListContext"
+            show-root-group="true"
+            connection-groups="rootGroups"
+            connection-group-template="'app/manage/templates/locationChooserConnectionGroup.html'"/>
+    </div>
+
+</div>
diff --git a/guacamole/src/main/webapp/app/manage/templates/locationChooserConnectionGroup.html b/guacamole/src/main/webapp/app/manage/templates/locationChooserConnectionGroup.html
new file mode 100644
index 0000000..fefaa7e
--- /dev/null
+++ b/guacamole/src/main/webapp/app/manage/templates/locationChooserConnectionGroup.html
@@ -0,0 +1,25 @@
+<span class="name" ng-click="context.chooseGroup(item.wrappedItem)">
+    <!--
+       Copyright (C) 2014 Glyptodon LLC
+
+       Permission is hereby granted, free of charge, to any person obtaining a copy
+       of this software and associated documentation files (the "Software"), to deal
+       in the Software without restriction, including without limitation the rights
+       to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+       copies of the Software, and to permit persons to whom the Software is
+       furnished to do so, subject to the following conditions:
+
+       The above copyright notice and this permission notice shall be included in
+       all copies or substantial portions of the Software.
+
+       THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+       IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+       FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+       AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+       LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+       OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+       THE SOFTWARE.
+    -->
+
+    {{item.name}}
+</span>
diff --git a/guacamole/src/main/webapp/app/manage/templates/manageConnection.html b/guacamole/src/main/webapp/app/manage/templates/manageConnection.html
new file mode 100644
index 0000000..a38e705
--- /dev/null
+++ b/guacamole/src/main/webapp/app/manage/templates/manageConnection.html
@@ -0,0 +1,113 @@
+<!--
+Copyright 2014 Glyptodon LLC.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+-->
+
+<div class="view" ng-class="{loading: !isLoaded()}">
+
+    <!-- Main property editor -->
+    <div class="header">
+        <h2>{{'MANAGE_CONNECTION.SECTION_HEADER_EDIT_CONNECTION' | translate}}</h2>
+        <guac-user-menu></guac-user-menu>
+    </div>
+    <div class="section">
+        <table class="properties">
+            
+            <!-- Edit connection name -->
+            <tr>
+                <th>{{'MANAGE_CONNECTION.FIELD_HEADER_NAME' | translate}}</th>
+              
+                <td><input type="text" ng-model="connection.name" autocorrect="off" autocapitalize="off"/></td>
+            </tr>
+            
+            <!-- Edit connection location -->
+            <tr>
+                <th>{{'MANAGE_CONNECTION.FIELD_HEADER_LOCATION' | translate}}</th>
+              
+                <td>
+                    <location-chooser
+                        data-data-source="selectedDataSource" root-group="rootGroup"
+                        value="connection.parentIdentifier"></location-chooser>
+                </td>
+            </tr>
+            
+            
+            <!-- Edit connection protocol -->
+            <tr>
+                <th>{{'MANAGE_CONNECTION.FIELD_HEADER_PROTOCOL' | translate}}</th>
+                <td>
+                    <select ng-model="connection.protocol" ng-options="name as getProtocolName(protocol.name) | translate for (name, protocol) in protocols | orderBy: name"></select>
+                </td>
+            </tr>
+        </table>
+    </div>
+
+    <!-- Connection attributes section -->
+    <div class="attributes">
+        <guac-form namespace="'CONNECTION_ATTRIBUTES'" content="attributes" model="connection.attributes"></guac-form>
+    </div>
+
+    <!-- Connection parameters -->
+    <h2 class="header">{{'MANAGE_CONNECTION.SECTION_HEADER_PARAMETERS' | translate}}</h2>
+    <div class="section connection-parameters" ng-class="{loading: !parameters}">
+        <guac-form namespace="getNamespace(connection.protocol)"
+                   content="protocols[connection.protocol].forms"
+                   model="parameters"></guac-form>
+    </div>
+
+    <!-- Form action buttons -->
+    <div class="action-buttons">
+        <button ng-show="canSaveConnection" ng-click="saveConnection()">{{'MANAGE_CONNECTION.ACTION_SAVE' | translate}}</button>
+        <button ng-show="canCloneConnection" ng-click="cloneConnection()">{{'MANAGE_CONNECTION.ACTION_CLONE' | translate}}</button>
+        <button ng-click="cancel()">{{'MANAGE_CONNECTION.ACTION_CANCEL' | translate}}</button>
+        <button ng-show="canDeleteConnection" ng-click="deleteConnection()" class="danger">{{'MANAGE_CONNECTION.ACTION_DELETE' | translate}}</button>
+    </div>
+
+    <!-- Connection history -->
+    <h2 class="header">{{'MANAGE_CONNECTION.SECTION_HEADER_HISTORY' | translate}}</h2>
+    <div class="history section" ng-class="{loading: !historyEntryWrappers}">
+        <p ng-hide="historyEntryWrappers.length">{{'MANAGE_CONNECTION.INFO_CONNECTION_NOT_USED' | translate}}</p>
+
+        <!-- History list -->
+        <table ng-show="historyEntryWrappers.length">
+            <thead>
+                <tr>
+                    <th>{{'MANAGE_CONNECTION.TABLE_HEADER_HISTORY_USERNAME' | translate}}</th>
+                    <th>{{'MANAGE_CONNECTION.TABLE_HEADER_HISTORY_START' | translate}}</th>
+                    <th>{{'MANAGE_CONNECTION.TABLE_HEADER_HISTORY_DURATION' | translate}}</th>
+                </tr>
+            </thead>
+            <tbody>
+                <tr ng-repeat="wrapper in wrapperPage">
+                    <td class="username">{{wrapper.entry.username}}</td>
+                    <td class="start">{{wrapper.entry.startDate | date:historyDateFormat}}</td>
+                    <td class="duration"
+                        translate="{{wrapper.durationText}}"
+                        translate-values="{VALUE: wrapper.duration.value, UNIT: wrapper.duration.unit}"></td>
+                </tr>
+            </tbody>
+        </table>
+
+        <!-- Pager controls for history list -->
+        <guac-pager page="wrapperPage" items="historyEntryWrappers"></guac-pager>
+
+    </div>
+
+</div>
diff --git a/guacamole/src/main/webapp/app/manage/templates/manageConnectionGroup.html b/guacamole/src/main/webapp/app/manage/templates/manageConnectionGroup.html
new file mode 100644
index 0000000..956b898
--- /dev/null
+++ b/guacamole/src/main/webapp/app/manage/templates/manageConnectionGroup.html
@@ -0,0 +1,74 @@
+<!--
+Copyright 2014 Glyptodon LLC.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+-->
+
+<div class="view" ng-class="{loading: !isLoaded()}">
+
+    <!-- Main property editor -->
+    <div class="header">
+        <h2>{{'MANAGE_CONNECTION_GROUP.SECTION_HEADER_EDIT_CONNECTION_GROUP' | translate}}</h2>
+        <guac-user-menu></guac-user-menu>
+    </div>
+    <div class="section">
+        <table class="properties">
+                        
+            <!-- Edit connection group name -->
+            <tr>
+                <th>{{'MANAGE_CONNECTION_GROUP.FIELD_HEADER_NAME' | translate}}</th>
+                          
+                <td><input type="text" ng-model="connectionGroup.name" autocorrect="off" autocapitalize="off"/></td>
+            </tr>
+                        
+            <!-- Edit connection group location -->
+            <tr>
+                <th>{{'MANAGE_CONNECTION_GROUP.FIELD_HEADER_LOCATION' | translate}}</th>
+                          
+                <td>
+                    <location-chooser
+                        data-data-source="selectedDataSource" root-group="rootGroup"
+                        value="connectionGroup.parentIdentifier"></location-chooser>
+                </td>
+            </tr>
+                        
+                        
+            <!-- Edit connection group type -->
+            <tr>
+                <th>{{'MANAGE_CONNECTION_GROUP.FIELD_HEADER_TYPE' | translate}}</th>
+                <td>
+                    <select ng-model="connectionGroup.type" ng-options="type.value as type.label | translate for type in types | orderBy: name"></select>
+                </td>
+            </tr>
+        </table>
+    </div>
+
+    <!-- Connection group attributes section -->
+    <div class="attributes">
+        <guac-form namespace="'CONNECTION_GROUP_ATTRIBUTES'" content="attributes" model="connectionGroup.attributes"></guac-form>
+    </div>
+
+    <!-- Form action buttons -->
+    <div class="action-buttons">
+        <button ng-show="canSaveConnectionGroup" ng-click="saveConnectionGroup()">{{'MANAGE_CONNECTION_GROUP.ACTION_SAVE' | translate}}</button>
+        <button ng-click="cancel()">{{'MANAGE_CONNECTION_GROUP.ACTION_CANCEL' | translate}}</button>
+        <button ng-show="canDeleteConnectionGroup" ng-click="deleteConnectionGroup()" class="danger">{{'MANAGE_CONNECTION_GROUP.ACTION_DELETE' | translate}}</button>
+    </div>
+
+</div>
diff --git a/guacamole/src/main/webapp/app/manage/templates/manageUser.html b/guacamole/src/main/webapp/app/manage/templates/manageUser.html
new file mode 100644
index 0000000..6d275e5
--- /dev/null
+++ b/guacamole/src/main/webapp/app/manage/templates/manageUser.html
@@ -0,0 +1,117 @@
+<!--
+Copyright 2015 Glyptodon LLC.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+-->
+
+<div class="manage-user view" ng-class="{loading: !isLoaded()}">
+
+    <!-- User header and data source tabs -->
+    <div class="username header">
+        <h2>{{'MANAGE_USER.SECTION_HEADER_EDIT_USER' | translate}}</h2>
+        <guac-user-menu></guac-user-menu>
+    </div>
+    <div class="page-tabs">
+        <guac-page-list pages="accountPages"></guac-page-list>
+    </div>
+
+    <!-- Warn if user is read-only -->
+    <div class="section" ng-show="isReadOnly()">
+        <p class="notice read-only">{{'MANAGE_USER.INFO_READ_ONLY' | translate}}</p>
+    </div>
+
+    <!-- Sections applicable to non-read-only users -->
+    <div ng-show="!isReadOnly()">
+
+        <!-- User password section -->
+        <div class="section">
+            <table class="properties">
+                <tr>
+                    <th>{{'MANAGE_USER.FIELD_HEADER_USERNAME' | translate}}</th>
+                    <td>
+                        <input ng-show="canEditUsername()" ng-model="user.username" type="text"/>
+                        <span  ng-hide="canEditUsername()">{{user.username}}</span>
+                    </td>
+                </tr>
+                <tr>
+                    <th>{{'MANAGE_USER.FIELD_HEADER_PASSWORD' | translate}}</th>
+                    <td><input ng-model="user.password" type="password" /></td>
+                </tr>
+                <tr>
+                    <th>{{'MANAGE_USER.FIELD_HEADER_PASSWORD_AGAIN' | translate}}</th>
+                    <td><input ng-model="passwordMatch" type="password" /></td>
+                </tr>
+            </table>
+        </div>
+
+        <!-- User attributes section -->
+        <div class="attributes" ng-show="canChangeAttributes()">
+            <guac-form namespace="'USER_ATTRIBUTES'" content="attributes" model="user.attributes"></guac-form>
+        </div>
+
+        <!-- System permissions section -->
+        <div class="system-permissions" ng-show="canChangePermissions()">
+            <h2 class="header">{{'MANAGE_USER.SECTION_HEADER_PERMISSIONS' | translate}}</h2>
+            <div class="section">
+                <table class="properties">
+                    <tr ng-repeat="systemPermissionType in systemPermissionTypes"
+                        ng-show="canChangeSystemPermissions()">
+                        <th>{{systemPermissionType.label | translate}}</th>
+                        <td><input type="checkbox" ng-model="permissionFlags.systemPermissions[systemPermissionType.value]"
+                                                   ng-change="systemPermissionChanged(systemPermissionType.value)"/></td>
+                    </tr>
+                    <tr>
+                        <th>{{'MANAGE_USER.FIELD_HEADER_CHANGE_OWN_PASSWORD' | translate}}</th>
+                        <td><input type="checkbox" ng-model="permissionFlags.userPermissions.UPDATE[user.username]"
+                                                   ng-change="userPermissionChanged('UPDATE', user.username)"/></td>
+                    </tr>
+                </table>
+            </div>
+        </div>
+
+        <!-- Connection permissions section -->
+        <div class="connection-permissions" ng-show="canChangePermissions()">
+            <div class="header">
+                <h2>{{'MANAGE_USER.SECTION_HEADER_CONNECTIONS' | translate}}</h2>
+                <guac-group-list-filter connection-groups="rootGroups"
+                    filtered-connection-groups="filteredRootGroups"
+                    placeholder="'MANAGE_USER.FIELD_PLACEHOLDER_FILTER' | translate"
+                    connection-properties="filteredConnectionProperties"
+                    connection-group-properties="filteredConnectionGroupProperties"></guac-group-list-filter>
+            </div>
+            <div class="section">
+                <guac-group-list
+                    context="groupListContext"
+                    connection-groups="filteredRootGroups"
+                    connection-template="'app/manage/templates/connectionPermission.html'"
+                    connection-group-template="'app/manage/templates/connectionGroupPermission.html'"
+                    page-size="20"/>
+            </div>
+        </div>
+
+        <!-- Form action buttons -->
+        <div class="action-buttons">
+            <button ng-show="canSaveUser()" ng-click="saveUser()">{{'MANAGE_USER.ACTION_SAVE' | translate}}</button>
+            <button ng-click="cancel()">{{'MANAGE_USER.ACTION_CANCEL' | translate}}</button>
+            <button ng-show="canDeleteUser()" ng-click="deleteUser()" class="danger">{{'MANAGE_USER.ACTION_DELETE' | translate}}</button>
+        </div>
+
+    </div>
+
+</div>
diff --git a/guacamole/src/main/webapp/app/manage/types/HistoryEntryWrapper.js b/guacamole/src/main/webapp/app/manage/types/HistoryEntryWrapper.js
new file mode 100644
index 0000000..f8f8aae
--- /dev/null
+++ b/guacamole/src/main/webapp/app/manage/types/HistoryEntryWrapper.js
@@ -0,0 +1,83 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * A service for defining the HistoryEntryWrapper class.
+ */
+angular.module('manage').factory('HistoryEntryWrapper', ['$injector',
+    function defineHistoryEntryWrapper($injector) {
+
+    // Required types
+    var ConnectionHistoryEntry = $injector.get('ConnectionHistoryEntry');
+
+    /**
+     * Wrapper for ConnectionHistoryEntry which adds display-specific
+     * properties, such as the connection duration.
+     * 
+     * @constructor
+     * @param {ConnectionHistoryEntry} historyEntry
+     *     The history entry to wrap.
+     */
+    var HistoryEntryWrapper = function HistoryEntryWrapper(historyEntry) {
+
+        /**
+         * The wrapped ConnectionHistoryEntry.
+         *
+         * @type ConnectionHistoryEntry
+         */
+        this.entry = historyEntry;
+
+        /**
+         * An object providing value and unit properties, denoting the duration
+         * and its corresponding units.
+         *
+         * @type ConnectionHistoryEntry.Duration
+         */
+        this.duration = null;
+
+        /**
+         * The string to display as the duration of this history entry. If a
+         * duration is available, its value and unit will be exposed to any
+         * given translation string as the VALUE and UNIT substitution
+         * variables respectively.
+         * 
+         * @type String
+         */
+        this.durationText = 'MANAGE_CONNECTION.TEXT_HISTORY_DURATION';
+
+        // Notify if connection is active right now
+        if (historyEntry.active)
+            this.durationText = 'MANAGE_CONNECTION.INFO_CONNECTION_ACTIVE_NOW';
+
+        // If connection is not active, inform user if end date is not known
+        else if (!historyEntry.endDate)
+            this.durationText = 'MANAGE_CONNECTION.INFO_CONNECTION_DURATION_UNKNOWN';
+
+        // Set the duration if the necessary information is present
+        if (historyEntry.endDate && historyEntry.startDate)
+            this.duration = new ConnectionHistoryEntry.Duration(historyEntry.endDate - historyEntry.startDate);
+
+    };
+
+    return HistoryEntryWrapper;
+
+}]);
\ No newline at end of file
diff --git a/guacamole/src/main/webapp/app/manage/types/ManageableUser.js b/guacamole/src/main/webapp/app/manage/types/ManageableUser.js
new file mode 100644
index 0000000..8f2e43a
--- /dev/null
+++ b/guacamole/src/main/webapp/app/manage/types/ManageableUser.js
@@ -0,0 +1,56 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * A service for defining the ManageableUser class.
+ */
+angular.module('manage').factory('ManageableUser', [function defineManageableUser() {
+
+    /**
+     * A pairing of an @link{User} with the identifier of its corresponding
+     * data source.
+     *
+     * @constructor
+     * @param {Object|ManageableUser} template
+     */
+    var ManageableUser = function ManageableUser(template) {
+
+        /**
+         * The unique identifier of the data source containing this user.
+         *
+         * @type String
+         */
+        this.dataSource = template.dataSource;
+
+        /**
+         * The @link{User} object represented by this ManageableUser and
+         * contained within the associated data source.
+         *
+         * @type User
+         */
+        this.user = template.user;
+
+    };
+
+    return ManageableUser;
+
+}]);
diff --git a/guacamole/src/main/webapp/app/navigation/directives/guacPageList.js b/guacamole/src/main/webapp/app/navigation/directives/guacPageList.js
new file mode 100644
index 0000000..cc4d8fb
--- /dev/null
+++ b/guacamole/src/main/webapp/app/navigation/directives/guacPageList.js
@@ -0,0 +1,249 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * A directive which provides a list of links to specific pages.
+ */
+angular.module('navigation').directive('guacPageList', [function guacPageList() {
+
+    return {
+        restrict: 'E',
+        replace: true,
+        scope: {
+
+            /**
+             * The array of pages to display.
+             *
+             * @type PageDefinition[]
+             */
+            pages : '='
+
+        },
+
+        templateUrl: 'app/navigation/templates/guacPageList.html',
+        controller: ['$scope', '$injector', function guacPageListController($scope, $injector) {
+
+            // Required types
+            var PageDefinition = $injector.get('PageDefinition');
+
+            // Required services
+            var $location = $injector.get('$location');
+
+            /**
+             * The URL of the currently-displayed page.
+             *
+             * @type String
+             */
+            var currentURL = $location.url();
+
+            /**
+             * The names associated with the current page, if the current page
+             * is known. The value of this property corresponds to the value of
+             * PageDefinition.name. Though PageDefinition.name may be a String,
+             * this will always be an Array.
+             *
+             * @type String[]
+             */
+            var currentPageName = [];
+
+            /**
+             * Array of each level of the page list, where a level is defined
+             * by a mapping of names (translation strings) to the
+             * PageDefinitions corresponding to those names.
+             *
+             * @type Object.<String, PageDefinition>[]
+             */
+            $scope.levels = [];
+
+            /**
+             * Returns the names associated with the given page, in
+             * hierarchical order. If the page is only associated with a single
+             * name, and that name is not stored as an array, it will be still
+             * be returned as an array containing a single item.
+             *
+             * @param {PageDefinition} page
+             *     The page to return the names of.
+             *
+             * @return {String[]}
+             *     An array of all names associated with the given page, in
+             *     hierarchical order.
+             */
+            var getPageNames = function getPageNames(page) {
+
+                // If already an array, simply return the name
+                if (angular.isArray(page.name))
+                    return page.name;
+
+                // Otherwise, transform into array
+                return [page.name];
+
+            };
+
+            /**
+             * Adds the given PageDefinition to the overall set of pages
+             * displayed by this guacPageList, automatically updating the
+             * available levels ($scope.levels) and the contents of those
+             * levels.
+             *
+             * @param {PageDefinition} page
+             *     The PageDefinition to add.
+             *
+             * @param {Number} weight
+             *     The sorting weight to use for the page if it does not
+             *     already have an associated weight.
+             */
+            var addPage = function addPage(page, weight) {
+
+                // Pull all names for page
+                var names = getPageNames(page);
+
+                // Copy the hierarchy of this page into the displayed levels
+                // as far as is relevant for the currently-displayed page
+                for (var i = 0; i < names.length; i++) {
+
+                    // Create current level, if it doesn't yet exist
+                    var pages = $scope.levels[i];
+                    if (!pages)
+                        pages = $scope.levels[i] = {};
+
+                    // Get the name at the current level
+                    var name = names[i];
+
+                    // Determine whether this page definition is part of the
+                    // hierarchy containing the current page
+                    var isCurrentPage = (currentPageName[i] === name);
+
+                    // Store new page if it doesn't yet exist at this level
+                    if (!pages[name]) {
+                        pages[name] = new PageDefinition({
+                            name      : name,
+                            url       : isCurrentPage ? currentURL : page.url,
+                            className : page.className,
+                            weight    : page.weight || (weight + i)
+                        });
+                    }
+
+                    // If the name at this level no longer matches the
+                    // hierarchy of the current page, do not go any deeper
+                    if (currentPageName[i] !== name)
+                        break;
+
+                }
+
+            };
+
+            /**
+             * Navigate to the given page.
+             * 
+             * @param {PageDefinition} page
+             *     The page to navigate to.
+             */
+            $scope.navigateToPage = function navigateToPage(page) {
+                $location.path(page.url);
+            };
+            
+            /**
+             * Tests whether the given page is the page currently being viewed.
+             *
+             * @param {PageDefinition} page
+             *     The page to test.
+             *
+             * @returns {Boolean}
+             *     true if the given page is the current page, false otherwise.
+             */
+            $scope.isCurrentPage = function isCurrentPage(page) {
+                return currentURL === page.url;
+            };
+
+            /**
+             * Given an arbitrary map of PageDefinitions, returns an array of
+             * those PageDefinitions, sorted by weight.
+             *
+             * @param {Object.<*, PageDefinition>} level
+             *     A map of PageDefinitions with arbitrary keys. The value of
+             *     each key is ignored.
+             *
+             * @returns {PageDefinition[]}
+             *     An array of all PageDefinitions in the given map, sorted by
+             *     weight.
+             */
+            $scope.getPages = function getPages(level) {
+
+                var pages = [];
+
+                // Convert contents of level to a flat array of pages
+                angular.forEach(level, function addPageFromLevel(page) {
+                    pages.push(page);
+                });
+
+                // Sort page array by weight
+                pages.sort(function comparePages(a, b) {
+                    return a.weight - b.weight;
+                });
+
+                return pages;
+
+            };
+
+            // Update page levels whenever pages changes
+            $scope.$watch('pages', function setPages(pages) {
+
+                // Determine current page name
+                currentPageName = [];
+                angular.forEach(pages, function findCurrentPageName(page) {
+
+                    // If page is current page, store its names
+                    if ($scope.isCurrentPage(page))
+                        currentPageName = getPageNames(page);
+
+                });
+
+                // Reset contents of levels
+                $scope.levels = [];
+
+                // Add all page definitions
+                angular.forEach(pages, addPage);
+
+                // Filter to only relevant levels
+                $scope.levels = $scope.levels.filter(function isRelevant(level) {
+
+                    // Determine relevancy by counting the number of pages
+                    var pageCount = 0;
+                    for (var name in level) {
+
+                        // Level is relevant if it has two or more pages
+                        if (++pageCount === 2)
+                            return true;
+
+                    }
+
+                    // Otherwise, the level is not relevant
+                    return false;
+
+                });
+
+            });
+
+        }] // end controller
+
+    };
+}]);
diff --git a/guacamole/src/main/webapp/app/navigation/directives/guacUserMenu.js b/guacamole/src/main/webapp/app/navigation/directives/guacUserMenu.js
new file mode 100644
index 0000000..6cc80c8
--- /dev/null
+++ b/guacamole/src/main/webapp/app/navigation/directives/guacUserMenu.js
@@ -0,0 +1,146 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * A directive which provides a user-oriented menu containing options for
+ * navigation and configuration.
+ */
+angular.module('navigation').directive('guacUserMenu', [function guacUserMenu() {
+
+    return {
+        restrict: 'E',
+        replace: true,
+        scope: {
+
+            /**
+             * Optional array of actions which are specific to this particular
+             * location, as these actions may not be appropriate for other
+             * locations which contain the user menu.
+             *
+             * @type MenuAction[]
+             */
+            localActions : '='
+
+        },
+
+        templateUrl: 'app/navigation/templates/guacUserMenu.html',
+        controller: ['$scope', '$injector', '$element', function guacUserMenuController($scope, $injector, $element) {
+
+            // Get required services
+            var $document             = $injector.get('$document');
+            var $location             = $injector.get('$location');
+            var $route                = $injector.get('$route');
+            var authenticationService = $injector.get('authenticationService');
+            var userPageService       = $injector.get('userPageService');
+
+            /**
+             * The outermost element of the user menu directive.
+             *
+             * @type Element
+             */
+            var element = $element[0];
+
+            /**
+             * The main document object.
+             *
+             * @type Document
+             */
+            var document = $document[0];
+
+            /**
+             * Whether the contents of the user menu are currently shown.
+             *
+             * @type Boolean
+             */
+            $scope.menuShown = false;
+
+            /**
+             * The username of the current user.
+             *
+             * @type String
+             */
+            $scope.username = authenticationService.getCurrentUsername();
+            
+            /**
+             * The available main pages for the current user.
+             * 
+             * @type Page[]
+             */
+            $scope.pages = null;
+
+            // Retrieve the main pages from the user page service
+            userPageService.getMainPages()
+            .then(function retrievedMainPages(pages) {
+                $scope.pages = pages;
+            });
+            
+            /**
+             * Toggles visibility of the user menu.
+             */
+            $scope.toggleMenu = function toggleMenu() {
+                $scope.menuShown = !$scope.menuShown;
+            };
+
+            /**
+             * Logs out the current user, redirecting them to back to the root
+             * after logout completes.
+             */
+            $scope.logout = function logout() {
+                authenticationService.logout()['finally'](function logoutComplete() {
+                    if ($location.path() !== '/')
+                        $location.url('/');
+                    else
+                        $route.reload();
+                });
+            };
+
+            /**
+             * Action which logs out the current user, redirecting them to back
+             * to the login screen after logout completes.
+             */
+            var LOGOUT_ACTION = {
+                name      : 'USER_MENU.ACTION_LOGOUT',
+                className : 'logout',
+                callback  : $scope.logout
+            };
+
+            /**
+             * All available actions for the current user.
+             */
+            $scope.actions = [ LOGOUT_ACTION ];
+
+            // Close menu when use clicks anywhere else
+            document.body.addEventListener('click', function clickOutsideMenu() {
+                $scope.$apply(function closeMenu() {
+                    $scope.menuShown = false;
+                });
+            }, false);
+
+            // Prevent click within menu from triggering the outside-menu handler
+            element.addEventListener('click', function clickInsideMenu(e) {
+                e.stopPropagation();
+            }, false);
+
+        }] // end controller
+
+    };
+}]);
diff --git a/guacamole/src/main/webapp/app/navigation/navigationModule.js b/guacamole/src/main/webapp/app/navigation/navigationModule.js
new file mode 100644
index 0000000..8e3964a
--- /dev/null
+++ b/guacamole/src/main/webapp/app/navigation/navigationModule.js
@@ -0,0 +1,30 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Module for generating and implementing user navigation options.
+ */
+angular.module('navigation', [
+    'auth',
+    'notification',
+    'rest'
+]);
diff --git a/guacamole/src/main/webapp/app/navigation/services/userPageService.js b/guacamole/src/main/webapp/app/navigation/services/userPageService.js
new file mode 100644
index 0000000..933f89f
--- /dev/null
+++ b/guacamole/src/main/webapp/app/navigation/services/userPageService.js
@@ -0,0 +1,423 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * A service for generating all the important pages a user can visit.
+ */
+angular.module('navigation').factory('userPageService', ['$injector',
+        function userPageService($injector) {
+
+    // Get required types
+    var ClientIdentifier = $injector.get('ClientIdentifier');
+    var ConnectionGroup  = $injector.get('ConnectionGroup');
+    var PageDefinition   = $injector.get('PageDefinition');
+    var PermissionSet    = $injector.get('PermissionSet');
+
+    // Get required services
+    var $q                       = $injector.get('$q');
+    var authenticationService    = $injector.get('authenticationService');
+    var connectionGroupService   = $injector.get('connectionGroupService');
+    var dataSourceService        = $injector.get('dataSourceService');
+    var permissionService        = $injector.get('permissionService');
+    var translationStringService = $injector.get('translationStringService');
+    
+    var service = {};
+    
+    /**
+     * The home page to assign to a user if they can navigate to more than one
+     * page.
+     * 
+     * @type PageDefinition
+     */
+    var SYSTEM_HOME_PAGE = new PageDefinition({
+        name : 'USER_MENU.ACTION_NAVIGATE_HOME',
+        url  : '/'
+    });
+
+    /**
+     * Returns an appropriate home page for the current user.
+     *
+     * @param {Object.<String, ConnectionGroup>} rootGroups
+     *     A map of all root connection groups visible to the current user,
+     *     where each key is the identifier of the corresponding data source.
+     *
+     * @returns {PageDefinition}
+     *     The user's home page.
+     */
+    var generateHomePage = function generateHomePage(rootGroups) {
+
+        var homePage = null;
+
+        // Determine whether a connection or balancing group should serve as
+        // the home page
+        for (var dataSource in rootGroups) {
+
+            // Get corresponding root group
+            var rootGroup = rootGroups[dataSource];
+
+            // Get children
+            var connections      = rootGroup.childConnections      || [];
+            var connectionGroups = rootGroup.childConnectionGroups || [];
+
+            // Calculate total number of root-level objects
+            var totalRootObjects = connections.length + connectionGroups.length;
+
+            // If exactly one connection or balancing group is available, use
+            // that as the home page
+            if (homePage === null && totalRootObjects === 1) {
+
+                var connection      = connections[0];
+                var connectionGroup = connectionGroups[0];
+
+                // Only one connection present, use as home page
+                if (connection) {
+                    homePage = new PageDefinition({
+                        name : connection.name,
+                        url  : '/client/' + ClientIdentifier.toString({
+                            dataSource : dataSource,
+                            type       : ClientIdentifier.Types.CONNECTION,
+                            id         : connection.identifier
+                        })
+                    });
+                }
+
+                // Only one balancing group present, use as home page
+                if (connectionGroup
+                        && connectionGroup.type === ConnectionGroup.Type.BALANCING
+                        && _.isEmpty(connectionGroup.childConnections)
+                        && _.isEmpty(connectionGroup.childConnectionGroups)) {
+                    homePage = new PageDefinition({
+                        name : connectionGroup.name,
+                        url  : '/client/' + ClientIdentifier.toString({
+                            dataSource : dataSource,
+                            type       : ClientIdentifier.Types.CONNECTION_GROUP,
+                            id         : connectionGroup.identifier
+                        })
+                    });
+                }
+
+            }
+
+            // Otherwise, a connection or balancing group cannot serve as the
+            // home page
+            else if (totalRootObjects >= 1) {
+                homePage = null;
+                break;
+            }
+
+        } // end for each data source
+
+        // Use default home page if no other is available
+        return homePage || SYSTEM_HOME_PAGE;
+
+    };
+
+    /**
+     * Returns a promise which resolves with an appropriate home page for the
+     * current user.
+     *
+     * @returns {Promise.<Page>}
+     *     A promise which resolves with the user's default home page.
+     */
+    service.getHomePage = function getHomePage() {
+
+        var deferred = $q.defer();
+
+        // Resolve promise using home page derived from root connection groups
+        dataSourceService.apply(
+            connectionGroupService.getConnectionGroupTree,
+            authenticationService.getAvailableDataSources(),
+            ConnectionGroup.ROOT_IDENTIFIER
+        )
+        .then(function rootConnectionGroupsRetrieved(rootGroups) {
+            deferred.resolve(generateHomePage(rootGroups));
+        });
+
+        return deferred.promise;
+
+    };
+
+    /**
+     * Returns all settings pages that the current user can visit. This can
+     * include any of the various manage pages.
+     * 
+     * @param {Object.<String, PermissionSet>} permissionSets
+     *     A map of all permissions granted to the current user, where each
+     *     key is the identifier of the corresponding data source.
+     * 
+     * @returns {Page[]} 
+     *     An array of all settings pages that the current user can visit.
+     */
+    var generateSettingsPages = function generateSettingsPages(permissionSets) {
+        
+        var pages = [];
+        
+        var canManageUsers = [];
+        var canManageConnections = [];
+        var canViewConnectionRecords = [];
+        var canManageSessions = [];
+
+        // Inspect the contents of each provided permission set
+        angular.forEach(authenticationService.getAvailableDataSources(), function inspectPermissions(dataSource) {
+
+            // Get permissions for current data source, skipping if non-existent
+            var permissions = permissionSets[dataSource];
+            if (!permissions)
+                return;
+
+            // Do not modify original object
+            permissions = angular.copy(permissions);
+
+            // Ignore permission to update root group
+            PermissionSet.removeConnectionGroupPermission(permissions,
+                PermissionSet.ObjectPermissionType.UPDATE,
+                ConnectionGroup.ROOT_IDENTIFIER);
+
+            // Ignore permission to update self
+            PermissionSet.removeUserPermission(permissions,
+                PermissionSet.ObjectPermissionType.UPDATE,
+                authenticationService.getCurrentUsername());
+
+            // Determine whether the current user needs access to the user management UI
+            if (
+                    // System permissions
+                       PermissionSet.hasSystemPermission(permissions, PermissionSet.SystemPermissionType.ADMINISTER)
+                    || PermissionSet.hasSystemPermission(permissions, PermissionSet.SystemPermissionType.CREATE_USER)
+
+                    // Permission to update users
+                    || PermissionSet.hasUserPermission(permissions, PermissionSet.ObjectPermissionType.UPDATE)
+
+                    // Permission to delete users
+                    || PermissionSet.hasUserPermission(permissions, PermissionSet.ObjectPermissionType.DELETE)
+
+                    // Permission to administer users
+                    || PermissionSet.hasUserPermission(permissions, PermissionSet.ObjectPermissionType.ADMINISTER)
+            ) {
+                canManageUsers.push(dataSource);
+            }
+
+            // Determine whether the current user needs access to the connection management UI
+            if (
+                    // System permissions
+                       PermissionSet.hasSystemPermission(permissions, PermissionSet.SystemPermissionType.ADMINISTER)
+                    || PermissionSet.hasSystemPermission(permissions, PermissionSet.SystemPermissionType.CREATE_CONNECTION)
+                    || PermissionSet.hasSystemPermission(permissions, PermissionSet.SystemPermissionType.CREATE_CONNECTION_GROUP)
+
+                    // Permission to update connections or connection groups
+                    || PermissionSet.hasConnectionPermission(permissions,      PermissionSet.ObjectPermissionType.UPDATE)
+                    || PermissionSet.hasConnectionGroupPermission(permissions, PermissionSet.ObjectPermissionType.UPDATE)
+
+                    // Permission to delete connections or connection groups
+                    || PermissionSet.hasConnectionPermission(permissions,      PermissionSet.ObjectPermissionType.DELETE)
+                    || PermissionSet.hasConnectionGroupPermission(permissions, PermissionSet.ObjectPermissionType.DELETE)
+
+                    // Permission to administer connections or connection groups
+                    || PermissionSet.hasConnectionPermission(permissions,      PermissionSet.ObjectPermissionType.ADMINISTER)
+                    || PermissionSet.hasConnectionGroupPermission(permissions, PermissionSet.ObjectPermissionType.ADMINISTER)
+            ) {
+                canManageConnections.push(dataSource);
+            }
+
+            // Determine whether the current user needs access to the session management UI or view connection history
+            if (
+                    // A user must be a system administrator to manage sessions
+                    PermissionSet.hasSystemPermission(permissions, PermissionSet.SystemPermissionType.ADMINISTER)
+            ) {
+                canManageSessions.push(dataSource);
+                canViewConnectionRecords.push(dataSource);
+            }
+
+        });
+
+        // If user can manage sessions, add link to sessions management page
+        if (canManageSessions.length) {
+            pages.push(new PageDefinition({
+                name : 'USER_MENU.ACTION_MANAGE_SESSIONS',
+                url  : '/settings/sessions'
+            }));
+        }
+
+        // If user can manage connections, add links for connection management pages
+        angular.forEach(canViewConnectionRecords, function addConnectionHistoryLink(dataSource) {
+            pages.push(new PageDefinition({
+                name : [
+                    'USER_MENU.ACTION_VIEW_HISTORY',
+                    translationStringService.canonicalize('DATA_SOURCE_' + dataSource) + '.NAME'
+                ],
+                url  : '/settings/' + encodeURIComponent(dataSource) + '/history'
+            }));
+        });
+
+        // If user can manage users, add link to user management page
+        if (canManageUsers.length) {
+            pages.push(new PageDefinition({
+                name : 'USER_MENU.ACTION_MANAGE_USERS',
+                url  : '/settings/users'
+            }));
+        }
+
+        // If user can manage connections, add links for connection management pages
+        angular.forEach(canManageConnections, function addConnectionManagementLink(dataSource) {
+            pages.push(new PageDefinition({
+                name : [
+                    'USER_MENU.ACTION_MANAGE_CONNECTIONS',
+                    translationStringService.canonicalize('DATA_SOURCE_' + dataSource) + '.NAME'
+                ],
+                url  : '/settings/' + encodeURIComponent(dataSource) + '/connections'
+            }));
+        });
+
+        // Add link to user preferences (always accessible)
+        pages.push(new PageDefinition({
+            name : 'USER_MENU.ACTION_MANAGE_PREFERENCES',
+            url  : '/settings/preferences'
+        }));
+
+        return pages;
+    };
+
+    /**
+     * Returns a promise which resolves to an array of all settings pages that
+     * the current user can visit. This can include any of the various manage
+     * pages.
+     *
+     * @returns {Promise.<Page[]>} 
+     *     A promise which resolves to an array of all settings pages that the
+     *     current user can visit.
+     */
+    service.getSettingsPages = function getSettingsPages() {
+
+        var deferred = $q.defer();
+
+        // Retrieve current permissions
+        dataSourceService.apply(
+            permissionService.getPermissions,
+            authenticationService.getAvailableDataSources(),
+            authenticationService.getCurrentUsername() 
+        )
+
+        // Resolve promise using settings pages derived from permissions
+        .then(function permissionsRetrieved(permissions) {
+            deferred.resolve(generateSettingsPages(permissions));
+        });
+        
+        return deferred.promise;
+
+    };
+   
+    /**
+     * Returns all the main pages that the current user can visit. This can 
+     * include the home page, manage pages, etc. In the case that there are no 
+     * applicable pages of this sort, it may return a client page.
+     * 
+     * @param {Object.<String, ConnectionGroup>} rootGroups
+     *     A map of all root connection groups visible to the current user,
+     *     where each key is the identifier of the corresponding data source.
+     *     
+     * @param {Object.<String, PermissionSet>} permissions
+     *     A map of all permissions granted to the current user, where each
+     *     key is the identifier of the corresponding data source.
+     * 
+     * @returns {Page[]} 
+     *     An array of all main pages that the current user can visit.
+     */
+    var generateMainPages = function generateMainPages(rootGroups, permissions) {
+        
+        var pages = [];
+
+        // Get home page and settings pages
+        var homePage = generateHomePage(rootGroups);
+        var settingsPages = generateSettingsPages(permissions);
+
+        // Only include the home page in the list of main pages if the user
+        // can navigate elsewhere.
+        if (homePage === SYSTEM_HOME_PAGE || settingsPages.length)
+            pages.push(homePage);
+
+        // Add generic link to the first-available settings page
+        if (settingsPages.length) {
+            pages.push(new PageDefinition({
+                name : 'USER_MENU.ACTION_MANAGE_SETTINGS',
+                url  : settingsPages[0].url
+            }));
+        }
+        
+        return pages;
+    };
+
+    /**
+     * Returns a promise which resolves to an array of all main pages that the
+     * current user can visit. This can include the home page, manage pages,
+     * etc. In the case that there are no applicable pages of this sort, it may
+     * return a client page.
+     *
+     * @returns {Promise.<Page[]>} 
+     *     A promise which resolves to an array of all main pages that the
+     *     current user can visit.
+     */
+    service.getMainPages = function getMainPages() {
+
+        var deferred = $q.defer();
+
+        var rootGroups  = null;
+        var permissions = null;
+
+        /**
+         * Resolves the main pages retrieval promise, if possible. If
+         * insufficient data is available, this function does nothing.
+         */
+        var resolveMainPages = function resolveMainPages() {
+            if (rootGroups && permissions)
+                deferred.resolve(generateMainPages(rootGroups, permissions));
+        };
+
+        // Retrieve root group, resolving main pages if possible
+        dataSourceService.apply(
+            connectionGroupService.getConnectionGroupTree,
+            authenticationService.getAvailableDataSources(),
+            ConnectionGroup.ROOT_IDENTIFIER
+        )
+        .then(function rootConnectionGroupsRetrieved(retrievedRootGroups) {
+            rootGroups = retrievedRootGroups;
+            resolveMainPages();
+        });
+
+        // Retrieve current permissions
+        dataSourceService.apply(
+            permissionService.getPermissions,
+            authenticationService.getAvailableDataSources(),
+            authenticationService.getCurrentUsername()
+        )
+
+        // Resolving main pages if possible
+        .then(function permissionsRetrieved(retrievedPermissions) {
+            permissions = retrievedPermissions;
+            resolveMainPages();
+        });
+        
+        return deferred.promise;
+
+    };
+   
+    return service;
+    
+}]);
\ No newline at end of file
diff --git a/guacamole/src/main/webapp/app/navigation/styles/page-tabs.css b/guacamole/src/main/webapp/app/navigation/styles/page-tabs.css
new file mode 100644
index 0000000..28b4de2
--- /dev/null
+++ b/guacamole/src/main/webapp/app/navigation/styles/page-tabs.css
@@ -0,0 +1,58 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+.page-tabs .page-list ul {
+    margin: 0;
+    padding: 0;
+    background: rgba(0, 0, 0, 0.0125);
+    border-bottom: 1px solid rgba(0, 0, 0, 0.05);
+}
+
+.page-tabs .page-list ul + ul {
+    font-size: 0.75em;
+}
+
+.page-tabs .page-list li {
+    display: inline-block;
+    list-style: none;
+}
+
+.page-tabs .page-list li a[href] {
+    display: block;
+    color: black;
+    text-decoration: none;
+    padding: 0.75em 1em;
+}
+
+.page-tabs .page-list li a[href]:visited {
+    color: black;
+}
+
+.page-tabs .page-list li a[href]:hover {
+    background-color: #CDA;
+}
+
+.page-tabs .page-list li a[href].current,
+.page-tabs .page-list li a[href].current:hover {
+    background: rgba(0,0,0,0.3);
+    cursor: default;
+}
diff --git a/guacamole/src/main/webapp/app/navigation/styles/user-menu.css b/guacamole/src/main/webapp/app/navigation/styles/user-menu.css
new file mode 100644
index 0000000..d5dd379
--- /dev/null
+++ b/guacamole/src/main/webapp/app/navigation/styles/user-menu.css
@@ -0,0 +1,216 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+.user-menu {
+
+    /* IE10 */
+    display: -ms-flexbox;
+    -ms-flex-align: stretch;
+    -ms-flex-direction: row;
+
+    /* Ancient Mozilla */
+    display: -moz-box;
+    -moz-box-align: stretch;
+    -moz-box-orient: horizontal;
+    
+    /* Ancient WebKit */
+    display: -webkit-box;
+    -webkit-box-align: stretch;
+    -webkit-box-orient: horizontal;
+
+    /* Old WebKit */
+    display: -webkit-flex;
+    -webkit-align-items: stretch;
+    -webkit-flex-direction: row;
+
+    /* W3C */
+    display: flex;
+    align-items: stretch;
+    flex-direction: row;
+
+}
+
+.user-menu .user-menu-dropdown {
+
+    /* IE10 */
+    display: -ms-flexbox;
+    -ms-flex-align: center;
+    -ms-flex-direction: row;
+
+    /* Ancient Mozilla */
+    display: -moz-box;
+    -moz-box-align: center;
+    -moz-box-orient: horizontal;
+    
+    /* Ancient WebKit */
+    display: -webkit-box;
+    -webkit-box-align: center;
+    -webkit-box-orient: horizontal;
+
+    /* Old WebKit */
+    display: -webkit-flex;
+    -webkit-align-items: center;
+    -webkit-flex-direction: row;
+
+    /* W3C */
+    display: flex;
+    align-items: center;
+    flex-direction: row;
+
+}
+
+.user-menu .user-menu-dropdown {
+    position: relative;
+    border-left: 1px solid rgba(0,0,0,0.125);
+    background: rgba(0,0,0,0.04);
+}
+
+.user-menu .user-menu-dropdown:hover {
+    background: rgba(0,0,0,0.01);
+}
+
+.user-menu .user-menu-dropdown.open,
+.user-menu .user-menu-dropdown.open:hover {
+    background: rgba(0,0,0,0.3);
+}
+
+.user-menu .username {
+
+    cursor: default;
+    margin: 0;
+    min-width: 2in;
+
+    font-size: 1.25em;
+    font-weight: bold;
+    padding: 0.5em 2em;
+
+    background-repeat: no-repeat;
+    background-size: 1em;
+    background-position: 0.5em center;
+    background-image: url('images/user-icons/guac-user.png');
+
+    -ms-flex: 0 0 auto;
+    -moz-box-flex: 0;
+    -webkit-box-flex: 0;
+    -webkit-flex: 0 0 auto;
+    flex: 0 0 auto;
+   
+}
+
+.user-menu .menu-indicator {
+
+    position: absolute;
+    right: 0;
+    top: 0;
+    bottom: 0;
+
+    width: 2em;
+    background-repeat: no-repeat;
+    background-size: 1em;
+    background-position: center center;
+    background-image: url('images/arrows/down.png');
+
+}
+
+.user-menu .options {
+
+    visibility: hidden;
+
+    position: absolute;
+    top: 100%;
+    right: 0;
+    left: -1px;
+
+    background: #EEE;
+    box-shadow: 0 2px 2px rgba(0, 0, 0, 0.125);
+    border-left: 1px solid rgba(0,0,0,0.125);
+    border-bottom: 1px solid rgba(0,0,0,0.125);
+
+    z-index: 5;
+    
+}
+
+.user-menu .options ul {
+    margin: 0;
+    padding: 0;
+}
+
+.user-menu .user-menu-dropdown.open .options {
+    visibility: visible;
+}
+
+.user-menu .options li {
+    padding: 0;
+    list-style-type: none;
+}
+
+.user-menu .options li a {
+
+    display: block;
+    cursor: pointer;
+    color: black;
+    text-decoration: none;
+    padding: 0.75em;
+
+    background-repeat: no-repeat;
+    background-size: 1em;
+    background-position: 0.75em center;
+    padding-left: 2.5em;
+    background-image: url('images/protocol-icons/guac-monitor.png');
+
+}
+
+.user-menu .options li a:hover {
+    background-color: #CDA;
+}
+
+.user-menu .options li a.current,
+.user-menu .options li a.current:hover {
+    background-color: transparent;
+    cursor: default;
+    opacity: 0.25;
+}
+
+.user-menu .options li a[href="#/"] {
+    background-image: url('images/action-icons/guac-home-dark.png');
+}
+
+.user-menu .options li a[href="#/settings/users"],
+.user-menu .options li a[href="#/settings/connections"],
+.user-menu .options li a[href="#/settings/sessions"],
+.user-menu .options li a[href="#/settings/preferences"] {
+    background-image: url('images/action-icons/guac-config-dark.png');
+}
+
+.user-menu .options li a.logout {
+    background-image: url('images/action-icons/guac-logout-dark.png');
+}
+
+.user-menu .options li a.danger {
+    color: white;
+    font-weight: bold;
+    background-color: #A43;
+}
+
+.user-menu .options li a.danger:hover {
+    background-color: #C54;
+}
diff --git a/guacamole/src/main/webapp/app/navigation/templates/guacPageList.html b/guacamole/src/main/webapp/app/navigation/templates/guacPageList.html
new file mode 100644
index 0000000..2f3b8aa
--- /dev/null
+++ b/guacamole/src/main/webapp/app/navigation/templates/guacPageList.html
@@ -0,0 +1,34 @@
+<div class="page-list" ng-show="levels.length">
+    <!--
+       Copyright (C) 2015 Glyptodon LLC
+
+       Permission is hereby granted, free of charge, to any person obtaining a copy
+       of this software and associated documentation files (the "Software"), to deal
+       in the Software without restriction, including without limitation the rights
+       to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+       copies of the Software, and to permit persons to whom the Software is
+       furnished to do so, subject to the following conditions:
+
+       The above copyright notice and this permission notice shall be included in
+       all copies or substantial portions of the Software.
+
+       THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+       IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+       FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+       AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+       LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+       OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+       THE SOFTWARE.
+    -->
+
+    <!-- Navigation links -->
+    <ul class="page-list-level" ng-repeat="level in levels track by $index">
+        <li ng-repeat="page in getPages(level)" class="{{page.className}}">
+            <a class="home" ng-click="navigateToPage(page)"
+               ng-class="{current: isCurrentPage(page)}" href="#{{page.url}}">
+                {{page.name | translate}}
+            </a>
+        </li>
+    </ul>
+
+</div>
diff --git a/guacamole/src/main/webapp/app/navigation/templates/guacUserMenu.html b/guacamole/src/main/webapp/app/navigation/templates/guacUserMenu.html
new file mode 100644
index 0000000..8df34a5
--- /dev/null
+++ b/guacamole/src/main/webapp/app/navigation/templates/guacUserMenu.html
@@ -0,0 +1,55 @@
+<div class="user-menu">
+    <!--
+       Copyright (C) 2015 Glyptodon LLC
+
+       Permission is hereby granted, free of charge, to any person obtaining a copy
+       of this software and associated documentation files (the "Software"), to deal
+       in the Software without restriction, including without limitation the rights
+       to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+       copies of the Software, and to permit persons to whom the Software is
+       furnished to do so, subject to the following conditions:
+
+       The above copyright notice and this permission notice shall be included in
+       all copies or substantial portions of the Software.
+
+       THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+       IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+       FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+       AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+       LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+       OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+       THE SOFTWARE.
+    -->
+
+    <div class="user-menu-dropdown" ng-class="{open: menuShown}" ng-click="toggleMenu()">
+        <div class="username">{{username}}</div>
+        <div class="menu-indicator"></div>
+        
+        <!-- Menu options -->
+        <div class="options">
+            
+            <!-- Local actions -->
+            <ul class="action-list">
+                <li ng-repeat="action in localActions">
+                    <a ng-class="action.className" ng-click="action.callback()">
+                        {{action.name | translate}}
+                    </a>
+                </li>
+            </ul>
+
+            <!-- Navigation links -->
+            <guac-page-list pages="pages"></guac-page-list>
+
+            <!-- Actions -->
+            <ul class="action-list">
+                <li ng-repeat="action in actions">
+                    <a ng-class="action.className" ng-click="action.callback()">
+                        {{action.name | translate}}
+                    </a>
+                </li>
+            </ul>
+
+        </div>
+    </div>
+
+</div>
diff --git a/guacamole/src/main/webapp/app/navigation/types/ClientIdentifier.js b/guacamole/src/main/webapp/app/navigation/types/ClientIdentifier.js
new file mode 100644
index 0000000..028a21f
--- /dev/null
+++ b/guacamole/src/main/webapp/app/navigation/types/ClientIdentifier.js
@@ -0,0 +1,157 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Provides the ClientIdentifier class definition.
+ */
+angular.module('client').factory('ClientIdentifier', ['$injector',
+    function defineClientIdentifier($injector) {
+
+    // Required services
+    var authenticationService = $injector.get('authenticationService');
+    var $window               = $injector.get('$window');
+
+    /**
+     * Object which uniquely identifies a particular connection or connection
+     * group within Guacamole. This object can be converted to/from a string to
+     * generate a guaranteed-unique, deterministic identifier for client URLs.
+     * 
+     * @constructor
+     * @param {ClientIdentifier|Object} [template={}]
+     *     The object whose properties should be copied within the new
+     *     ClientIdentifier.
+     */
+    var ClientIdentifier = function ClientIdentifier(template) {
+
+        // Use empty object by default
+        template = template || {};
+
+        /**
+         * The identifier of the data source associated with the object to
+         * which the client will connect. This identifier will be the
+         * identifier of an AuthenticationProvider within the Guacamole web
+         * application.
+         *
+         * @type String
+         */
+        this.dataSource = template.dataSource;
+
+        /**
+         * The type of object to which the client will connect. Possible values
+         * are defined within ClientIdentifier.Types.
+         *
+         * @type String
+         */
+        this.type = template.type;
+
+        /**
+         * The unique identifier of the object to which the client will
+         * connect.
+         *
+         * @type String
+         */
+        this.id = template.id;
+
+    };
+
+    /**
+     * All possible ClientIdentifier types.
+     *
+     * @type Object.<String, String>
+     */
+    ClientIdentifier.Types = {
+
+        /**
+         * The type string for a Guacamole connection.
+         *
+         * @type String
+         */
+        CONNECTION : 'c',
+
+        /**
+         * The type string for a Guacamole connection group.
+         *
+         * @type String
+         */
+        CONNECTION_GROUP : 'g'
+
+    };
+
+    /**
+     * Converts the given ClientIdentifier or ClientIdentifier-like object to
+     * a String representation. Any object having the same properties as
+     * ClientIdentifier may be used, but only those properties will be taken
+     * into account when producing the resulting String.
+     *
+     * @param {ClientIdentifier|Object} id
+     *     The ClientIdentifier or ClientIdentifier-like object to convert to
+     *     a String representation.
+     *
+     * @returns {String}
+     *     A deterministic String representation of the given ClientIdentifier
+     *     or ClientIdentifier-like object.
+     */
+    ClientIdentifier.toString = function toString(id) {
+        return $window.btoa([
+            id.id,
+            id.type,
+            id.dataSource
+        ].join('\0'));
+    };
+
+    /**
+     * Converts the given String into the corresponding ClientIdentifier. If
+     * the provided String is not a valid identifier, it will be interpreted
+     * as the identifier of a connection within the data source that
+     * authenticated the current user.
+     *
+     * @param {String} str
+     *     The String to convert to a ClientIdentifier.
+     *
+     * @returns {ClientIdentifier}
+     *     The ClientIdentifier represented by the given String.
+     */
+    ClientIdentifier.fromString = function fromString(str) {
+
+        try {
+            var values = $window.atob(str).split('\0');
+            return new ClientIdentifier({
+                id         : values[0],
+                type       : values[1],
+                dataSource : values[2]
+            });
+        }
+
+        // If the provided string is invalid, transform into a reasonable guess
+        catch (e) {
+            return new ClientIdentifier({
+                id         : str,
+                type       : ClientIdentifier.Types.CONNECTION,
+                dataSource : authenticationService.getDataSource() || 'default'
+            });
+        }
+
+    };
+
+    return ClientIdentifier;
+
+}]);
diff --git a/guacamole/src/main/webapp/app/navigation/types/MenuAction.js b/guacamole/src/main/webapp/app/navigation/types/MenuAction.js
new file mode 100644
index 0000000..eb8c7b7
--- /dev/null
+++ b/guacamole/src/main/webapp/app/navigation/types/MenuAction.js
@@ -0,0 +1,77 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Provides the MenuAction class definition.
+ */
+angular.module('navigation').factory('MenuAction', [function defineMenuAction() {
+
+    /**
+     * Creates a new MenuAction, which pairs an arbitrary callback with
+     * an action name. The name of this action will ultimately be presented to
+     * the user when the user when this action's associated menu is open.
+     *
+     * @constructor
+     * @param {String} name
+     *     The name of this action.
+     *
+     * @param {Function} callback
+     *     The callback to call when the user elects to perform this action.
+     * 
+     * @param {String} className
+     *     The CSS class to associate with this action, if any.
+     */
+    var MenuAction = function MenuAction(name, callback, className) {
+
+        /**
+         * Reference to this MenuAction.
+         *
+         * @type MenuAction
+         */
+        var action = this;
+
+        /**
+         * The CSS class associated with this action.
+         * 
+         * @type String
+         */
+        this.className = className;
+
+        /**
+         * The name of this action.
+         *
+         * @type String
+         */
+        this.name = name;
+
+        /**
+         * The callback to call when this action is performed.
+         *
+         * @type Function
+         */
+        this.callback = callback;
+
+    };
+
+    return MenuAction;
+
+}]);
diff --git a/guacamole/src/main/webapp/app/navigation/types/PageDefinition.js b/guacamole/src/main/webapp/app/navigation/types/PageDefinition.js
new file mode 100644
index 0000000..ba702b0
--- /dev/null
+++ b/guacamole/src/main/webapp/app/navigation/types/PageDefinition.js
@@ -0,0 +1,78 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Provides the PageDefinition class definition.
+ */
+angular.module('navigation').factory('PageDefinition', [function definePageDefinition() {
+
+    /**
+     * Creates a new PageDefinition object which pairs the URL of a page with
+     * an arbitrary, human-readable name.
+     *
+     * @constructor
+     * @param {PageDefinition|Object} template
+     *     The object whose properties should be copied within the new
+     *     PageDefinition.
+     */
+    var PageDefinition = function PageDefinition(template) {
+
+        /**
+         * The the name of the page, which should be a translation table key.
+         * Alternatively, this may also be a list of names, where the final
+         * name represents the page and earlier names represent categorization.
+         * Those categorical names may be rendered hierarchically as a system
+         * of menus, tabs, etc.
+         *
+         * @type String|String[]
+         */
+        this.name = template.name;
+
+        /**
+         * The URL of the page.
+         *
+         * @type String
+         */
+        this.url = template.url;
+
+        /**
+         * The CSS class name to associate with this page, if any. This will be
+         * an empty string by default.
+         *
+         * @type String
+         */
+        this.className = template.className || '';
+
+        /**
+         * A numeric value denoting the relative sort order when compared to
+         * other sibling PageDefinitions. If unspecified, sort order is
+         * determined by the system using the PageDefinition.
+         *
+         * @type Number
+         */
+        this.weight = template.weight;
+
+    };
+
+    return PageDefinition;
+
+}]);
diff --git a/guacamole/src/main/webapp/app/notification/directives/guacNotification.js b/guacamole/src/main/webapp/app/notification/directives/guacNotification.js
new file mode 100644
index 0000000..a73703a
--- /dev/null
+++ b/guacamole/src/main/webapp/app/notification/directives/guacNotification.js
@@ -0,0 +1,89 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * A directive for the guacamole client.
+ */
+angular.module('notification').directive('guacNotification', [function guacNotification() {
+
+    return {
+        restrict: 'E',
+        replace: true,
+        scope: {
+
+            /**
+             * The notification to display.
+             *
+             * @type Notification|Object 
+             */
+            notification : '='
+
+        },
+
+        templateUrl: 'app/notification/templates/guacNotification.html',
+        controller: ['$scope', '$interval', function guacNotificationController($scope, $interval) {
+
+            // Update progress bar if end known
+            $scope.$watch("notification.progress.ratio", function updateProgress(ratio) {
+                $scope.progressPercent = ratio * 100;
+            });
+
+            $scope.$watch("notification", function resetTimeRemaining(notification) {
+
+                var countdown = notification.countdown;
+
+                // Clean up any existing interval
+                if ($scope.interval)
+                    $interval.cancel($scope.interval);
+
+                // Update and handle countdown, if provided
+                if (countdown) {
+
+                    $scope.timeRemaining = countdown.remaining;
+
+                    $scope.interval = $interval(function updateTimeRemaining() {
+
+                        // Update time remaining
+                        $scope.timeRemaining--;
+
+                        // Call countdown callback when time remaining expires
+                        if ($scope.timeRemaining === 0 && countdown.callback)
+                            countdown.callback();
+
+                    }, 1000, $scope.timeRemaining);
+
+                }
+
+            });
+
+            // Clean up interval upon destruction
+            $scope.$on("$destroy", function destroyNotification() {
+
+                if ($scope.interval)
+                    $interval.cancel($scope.interval);
+
+            });
+
+        }]
+
+    };
+}]);
diff --git a/guacamole/src/main/webapp/app/notification/notificationModule.js b/guacamole/src/main/webapp/app/notification/notificationModule.js
new file mode 100644
index 0000000..d400820
--- /dev/null
+++ b/guacamole/src/main/webapp/app/notification/notificationModule.js
@@ -0,0 +1,28 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * The module for code used to display arbitrary notifications.
+ */
+angular.module('notification', [
+    'storage'
+]);
diff --git a/guacamole/src/main/webapp/app/notification/services/guacNotification.js b/guacamole/src/main/webapp/app/notification/services/guacNotification.js
new file mode 100644
index 0000000..9c66014
--- /dev/null
+++ b/guacamole/src/main/webapp/app/notification/services/guacNotification.js
@@ -0,0 +1,90 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Service for displaying notifications and modal status dialogs.
+ */
+angular.module('notification').factory('guacNotification', ['$injector',
+        function guacNotification($injector) {
+
+    // Required services
+    var $rootScope            = $injector.get('$rootScope');
+    var sessionStorageFactory = $injector.get('sessionStorageFactory');
+
+    var service = {};
+
+    /**
+     * Getter/setter which retrieves or sets the current status notification,
+     * which may simply be false if no status is currently shown.
+     * 
+     * @type Function
+     */
+    var storedStatus = sessionStorageFactory.create(false);
+
+    /**
+     * Retrieves the current status notification, which may simply be false if
+     * no status is currently shown.
+     * 
+     * @type Notification|Boolean
+     */
+    service.getStatus = function getStatus() {
+        return storedStatus();
+    };
+
+    /**
+     * Shows or hides the given notification as a modal status. If a status
+     * notification is currently shown, no further statuses will be shown
+     * until the current status is hidden.
+     *
+     * @param {Notification|Boolean|Object} status
+     *     The status notification to show.
+     *
+     * @example
+     * 
+     * // To show a status message with actions
+     * guacNotification.showStatus({
+     *     'title'      : 'Disconnected',
+     *     'text'       : 'You have been disconnected!',
+     *     'actions'    : {
+     *         'name'       : 'reconnect',
+     *         'callback'   : function () {
+     *             // Reconnection code goes here
+     *         }
+     *     }
+     * });
+     * 
+     * // To hide the status message
+     * guacNotification.showStatus(false);
+     */
+    service.showStatus = function showStatus(status) {
+        if (!storedStatus() || !status)
+            storedStatus(status);
+    };
+
+    // Hide status upon navigation
+    $rootScope.$on('$routeChangeSuccess', function() {
+        service.showStatus(false);
+    });
+
+    return service;
+
+}]);
diff --git a/guacamole/src/main/webapp/app/notification/styles/notification.css b/guacamole/src/main/webapp/app/notification/styles/notification.css
new file mode 100644
index 0000000..6b750d1
--- /dev/null
+++ b/guacamole/src/main/webapp/app/notification/styles/notification.css
@@ -0,0 +1,106 @@
+/*
+ * Copyright (C) 2013 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+.notification {
+    border: 1px solid rgba(0, 0, 0, 0.125);
+    box-shadow: 1px 1px 2px rgba(0, 0, 0, 0.125);
+    background: white;
+    color: black;
+}
+
+.notification.error {
+    background: #FDD;
+}
+
+.notification .body {
+    margin: 0.5em;
+}
+
+.notification .buttons {
+    margin: 0.5em;
+}
+
+ at keyframes notification-progress {
+    from {background-position: 0px  0px;}
+    to   {background-position: 64px 0px;}
+}
+
+ at -webkit-keyframes notification-progress {
+    from {background-position: 0px  0px;}
+    to   {background-position: 64px 0px;}
+}
+
+.notification .title-bar {
+    font-size: 1.25em;
+    font-weight: bold;
+
+    text-transform: uppercase;
+    border-bottom: 1px solid rgba(0, 0, 0, 0.125);
+    box-shadow: 0 1px 2px rgba(0, 0, 0, 0.125);
+    background: rgba(0, 0, 0, 0.04);
+
+    padding: 0.5em;
+    margin-bottom: 1em;
+}
+
+.notification .progress .bar {
+    background: #A3D655;
+    position: absolute;
+    top: 0;
+    left: 0;
+    height: 100%;
+    width: 0;
+    box-shadow: inset  1px  1px 0 rgba(255, 255, 255, 0.5),
+                inset -1px -1px 0 rgba(  0,   0,   0, 0.1),
+                       1px 1px  0 gray;
+}
+
+.notification .progress {
+
+    width: 100%;
+    background: #C2C2C2 url('images/progress.png');
+    background-size: 16px 16px;
+    -moz-background-size: 16px 16px;
+    -webkit-background-size: 16px 16px;
+    -khtml-background-size: 16px 16px;
+
+    animation-name: notification-progress;
+    animation-duration: 2s;
+    animation-timing-function: linear;
+    animation-iteration-count: infinite;
+
+    -webkit-animation-name: notification-progress;
+    -webkit-animation-duration: 2s;
+    -webkit-animation-timing-function: linear;
+    -webkit-animation-iteration-count: infinite;
+
+    padding: 0.25em;
+    
+    border: 1px solid gray;
+
+    position: relative;
+    
+}
+
+.notification .progress .text {
+    position: relative;
+}
\ No newline at end of file
diff --git a/guacamole/src/main/webapp/app/notification/templates/guacNotification.html b/guacamole/src/main/webapp/app/notification/templates/guacNotification.html
new file mode 100644
index 0000000..1b1cae6
--- /dev/null
+++ b/guacamole/src/main/webapp/app/notification/templates/guacNotification.html
@@ -0,0 +1,53 @@
+<div class="notification" ng-class="notification.className">
+    <!--
+       Copyright (C) 2014 Glyptodon LLC
+
+       Permission is hereby granted, free of charge, to any person obtaining a copy
+       of this software and associated documentation files (the "Software"), to deal
+       in the Software without restriction, including without limitation the rights
+       to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+       copies of the Software, and to permit persons to whom the Software is
+       furnished to do so, subject to the following conditions:
+
+       The above copyright notice and this permission notice shall be included in
+       all copies or substantial portions of the Software.
+
+       THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+       IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+       FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+       AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+       LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+       OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+       THE SOFTWARE.
+    -->
+
+    <!-- Notification title -->
+    <div ng-show="notification.title" class="title-bar">
+        <div class="title">{{notification.title | translate}}</div>
+    </div>
+
+    <div class="body">
+
+        <!-- Notification text -->
+        <p ng-show="notification.text" class="text">{{notification.text | translate}}</p>
+
+        <!-- Current progress -->
+        <div class="progress" ng-show="notification.progress"><div class="bar" ng-show="progressPercent" ng-style="{'width': progressPercent + '%'}"></div><div
+                ng-show="notification.progress.text"
+                translate="{{notification.progress.text}}"
+                translate-values="{PROGRESS: notification.progress.value, UNIT: notification.progress.unit}"></div></div>
+
+        <!-- Default action countdown text -->
+        <p class="countdown-text"
+           ng-show="notification.countdown.text"
+           translate="{{notification.countdown.text}}"
+           translate-values="{REMAINING: timeRemaining}"></p>
+
+    </div>
+
+    <!-- Buttons -->
+    <div ng-show="notification.actions.length" class="buttons">
+        <button ng-repeat="action in notification.actions" ng-click="action.callback()" ng-class="action.className">{{action.name | translate}}</button>
+    </div>
+
+</div>
diff --git a/guacamole/src/main/webapp/app/notification/types/Notification.js b/guacamole/src/main/webapp/app/notification/types/Notification.js
new file mode 100644
index 0000000..dfb5ef9
--- /dev/null
+++ b/guacamole/src/main/webapp/app/notification/types/Notification.js
@@ -0,0 +1,91 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Provides the Notification class definition.
+ */
+angular.module('notification').factory('Notification', [function defineNotification() {
+
+    /**
+     * Creates a new Notification, initializing the properties of that
+     * Notification with the corresponding properties of the given template.
+     *
+     * @constructor
+     * @param {Notification|Object} [template={}]
+     *     The object whose properties should be copied within the new
+     *     Notification.
+     */
+    var Notification = function Notification(template) {
+
+        // Use empty object by default
+        template = template || {};
+
+        /**
+         * The CSS class to associate with the notification, if any.
+         *
+         * @type String
+         */
+        this.className = template.className;
+
+        /**
+         * The title of the notification.
+         *
+         * @type String
+         */
+        this.title = template.title;
+
+        /**
+         * The body text of the notification.
+         *
+         * @type String
+         */
+        this.text = template.text;
+
+        /**
+         * An array of all actions available to the user in response to this
+         * notification.
+         *
+         * @type NotificationAction[]
+         */
+        this.actions = template.actions || [];
+
+        /**
+         * The current progress state of the ongoing action associated with this
+         * notification.
+         *
+         * @type NotificationProgress
+         */
+        this.progress = template.progress;
+
+        /**
+         * The countdown and corresponding default action which applies to
+         * this notification, if any.
+         *
+         * @type NotificationCountdown
+         */
+        this.countdown = template.countdown;
+
+    };
+
+    return Notification;
+
+}]);
diff --git a/guacamole/src/main/webapp/app/notification/types/NotificationAction.js b/guacamole/src/main/webapp/app/notification/types/NotificationAction.js
new file mode 100644
index 0000000..215eb76
--- /dev/null
+++ b/guacamole/src/main/webapp/app/notification/types/NotificationAction.js
@@ -0,0 +1,76 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Provides the NotificationAction class definition.
+ */
+angular.module('notification').factory('NotificationAction', [function defineNotificationAction() {
+
+    /**
+     * Creates a new NotificationAction, which pairs an arbitrary callback with
+     * an action name. The name of this action will ultimately be presented to
+     * the user when the user is prompted to choose among available actions.
+     *
+     * @constructor
+     * @param {String} name The name of this action.
+     *
+     * @param {Function} callback
+     *     The callback to call when the user elects to perform this action.
+     * 
+     * @param {String} className
+     *     The CSS class to associate with this action, if any.
+     */
+    var NotificationAction = function NotificationAction(name, callback, className) {
+
+        /**
+         * Reference to this NotificationAction.
+         *
+         * @type NotificationAction
+         */
+        var action = this;
+
+        /**
+         * The CSS class associated with this action.
+         * 
+         * @type String
+         */
+        this.className = className;
+
+        /**
+         * The name of this action.
+         *
+         * @type String
+         */
+        this.name = name;
+
+        /**
+         * The callback to call when this action is performed.
+         *
+         * @type Function
+         */
+        this.callback = callback;
+
+    };
+
+    return NotificationAction;
+
+}]);
diff --git a/guacamole/src/main/webapp/app/notification/types/NotificationCountdown.js b/guacamole/src/main/webapp/app/notification/types/NotificationCountdown.js
new file mode 100644
index 0000000..3db11cc
--- /dev/null
+++ b/guacamole/src/main/webapp/app/notification/types/NotificationCountdown.js
@@ -0,0 +1,79 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Provides the NotificationCountdown class definition.
+ */
+angular.module('notification').factory('NotificationCountdown', [function defineNotificationCountdown() {
+
+    /**
+     * Creates a new NotificationCountdown which describes an action that
+     * should be performed after a specific number of seconds has elapsed.
+     *
+     * @constructor
+     * @param {String} text The body text of the notification countdown.
+     *
+     * @param {Number} remaining
+     *     The number of seconds remaining in the countdown.
+     *
+     * @param {Function} [callback]
+     *     The callback to call when the countdown elapses.
+     */
+    var NotificationCountdown = function NotificationCountdown(text, remaining, callback) {
+
+        /**
+         * Reference to this NotificationCountdown.
+         *
+         * @type NotificationCountdown
+         */
+        var countdown = this;
+
+        /**
+         * The body text of the notification countdown. For the sake of i18n,
+         * the variable REMAINING should be applied within the translation
+         * string for formatting plurals, etc.
+         *
+         * @type String
+         */
+        this.text = text;
+
+        /**
+         * The number of seconds remaining in the countdown. After this number
+         * of seconds elapses, the callback associated with this
+         * NotificationCountdown will be called.
+         *
+         * @type Number
+         */
+        this.remaining = remaining;
+
+        /**
+         * The callback to call when this countdown expires.
+         *
+         * @type Function
+         */
+        this.callback = callback;
+
+    };
+
+    return NotificationCountdown;
+
+}]);
diff --git a/guacamole/src/main/webapp/app/notification/types/NotificationProgress.js b/guacamole/src/main/webapp/app/notification/types/NotificationProgress.js
new file mode 100644
index 0000000..5c3a45c
--- /dev/null
+++ b/guacamole/src/main/webapp/app/notification/types/NotificationProgress.js
@@ -0,0 +1,87 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Provides the NotificationProgress class definition.
+ */
+angular.module('notification').factory('NotificationProgress', [function defineNotificationProgress() {
+
+    /**
+     * Creates a new NotificationProgress which describes the current status
+     * of an operation, and how much of that operation remains to be performed.
+     *
+     * @constructor
+     * @param {String} text The text describing the operation progress.
+     *
+     * @param {Number} value
+     *     The current state of operation progress, as an arbitrary number
+     *     which increases as the operation continues.
+     *
+     * @param {String} [unit]
+     *     The unit of the arbitrary value, if that value has an associated
+     *     unit.
+     *
+     * @param {Number} [ratio]
+     *     If known, the current status of the operation as a value between 0
+     *     and 1 inclusive, where 0 is not yet started, and 1 is complete.
+     */
+    var NotificationProgress = function NotificationProgress(text, value, unit, ratio) {
+
+        /**
+         * The text describing the operation progress. For the sake of i18n,
+         * the variable PROGRESS should be applied within the translation
+         * string for formatting plurals, etc., while UNIT should be used
+         * for the progress unit, if any.
+         *
+         * @type String
+         */
+        this.text = text;
+
+        /**
+         * The current state of operation progress, as an arbitrary number which
+         * increases as the operation continues.
+         *
+         * @type Number
+         */
+        this.value = value;
+
+        /**
+         * The unit of the arbitrary value, if that value has an associated
+         * unit.
+         *
+         * @type String
+         */
+        this.unit = unit;
+
+        /**
+         * If known, the current status of the operation as a value between 0
+         * and 1 inclusive, where 0 is not yet started, and 1 is complete.
+         *
+         * @type String
+         */
+        this.ratio = ratio;
+
+    };
+
+    return NotificationProgress;
+
+}]);
diff --git a/guacamole/src/main/webapp/app/osk/directives/guacOsk.js b/guacamole/src/main/webapp/app/osk/directives/guacOsk.js
new file mode 100644
index 0000000..bca9bc9
--- /dev/null
+++ b/guacamole/src/main/webapp/app/osk/directives/guacOsk.js
@@ -0,0 +1,126 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * A directive which displays the Guacamole on-screen keyboard.
+ */
+angular.module('osk').directive('guacOsk', [function guacOsk() {
+
+    return {
+        restrict: 'E',
+        replace: true,
+        scope: {
+
+            /**
+             * The URL for the Guacamole on-screen keyboard layout to use.
+             *
+             * @type String
+             */
+            layout : '='
+
+        },
+
+        templateUrl: 'app/osk/templates/guacOsk.html',
+        controller: ['$scope', '$injector', '$element',
+            function guacOsk($scope, $injector, $element) {
+
+            // Required services
+            var $http        = $injector.get('$http');
+            var $rootScope   = $injector.get('$rootScope');
+            var cacheService = $injector.get('cacheService');
+
+            /**
+             * The current on-screen keyboard, if any.
+             *
+             * @type Guacamole.OnScreenKeyboard
+             */
+            var keyboard = null;
+
+            /**
+             * The main containing element for the entire directive.
+             * 
+             * @type Element
+             */
+            var main = $element[0];
+
+            // Size keyboard to same size as main element
+            $scope.keyboardResized = function keyboardResized() {
+
+                // Resize keyboard, if defined
+                if (keyboard)
+                    keyboard.resize(main.offsetWidth);
+
+            };
+
+            // Set layout whenever URL changes
+            $scope.$watch("layout", function setLayout(url) {
+
+                // Remove current keyboard
+                if (keyboard) {
+                    main.removeChild(keyboard.getElement());
+                    keyboard = null;
+                }
+
+                // Load new keyboard
+                if (url) {
+
+                    // Retrieve layout JSON
+                    $http({
+                        cache   : cacheService.languages,
+                        method  : 'GET',
+                        url     : url
+                    })
+
+                    // Build OSK with retrieved layout
+                    .success(function layoutRetrieved(layout) {
+
+                        // Abort if the layout changed while we were waiting for a response
+                        if ($scope.layout !== url)
+                            return;
+
+                        // Add OSK element
+                        keyboard = new Guacamole.OnScreenKeyboard(layout);
+                        main.appendChild(keyboard.getElement());
+
+                        // Init size
+                        keyboard.resize(main.offsetWidth);
+
+                        // Broadcast keydown for each key pressed
+                        keyboard.onkeydown = function(keysym) {
+                            $rootScope.$broadcast('guacSyntheticKeydown', keysym);
+                        };
+                        
+                        // Broadcast keydown for each key released 
+                        keyboard.onkeyup = function(keysym) {
+                            $rootScope.$broadcast('guacSyntheticKeyup', keysym);
+                        };
+
+                    });
+
+                }
+
+            }); // end layout scope watch
+
+        }]
+
+    };
+}]);
diff --git a/guacamole/src/main/webapp/app/osk/oskModule.js b/guacamole/src/main/webapp/app/osk/oskModule.js
new file mode 100644
index 0000000..36cc8ea
--- /dev/null
+++ b/guacamole/src/main/webapp/app/osk/oskModule.js
@@ -0,0 +1,26 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Module for displaying the Guacamole on-screen keyboard.
+ */
+angular.module('osk', []);
diff --git a/guacamole/src/main/webapp/app/osk/styles/osk.css b/guacamole/src/main/webapp/app/osk/styles/osk.css
new file mode 100644
index 0000000..c684003
--- /dev/null
+++ b/guacamole/src/main/webapp/app/osk/styles/osk.css
@@ -0,0 +1,217 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+.osk {
+    position: relative;
+}
+
+.guac-keyboard {
+    display: inline-block;
+    width: 100%;
+    
+    margin: 0;
+    padding: 0;
+    cursor: default;
+
+    text-align: left;
+    vertical-align: middle;
+}
+
+.guac-keyboard,
+.guac-keyboard * {
+    overflow: hidden;
+    white-space: nowrap;
+}
+
+.guac-keyboard .guac-keyboard-key-container {
+    display: inline-block;
+    margin: 0.05em;
+    position: relative;
+}
+
+.guac-keyboard .guac-keyboard-key {
+
+    position: absolute;
+    left:   0;
+    right:  0;
+    top:    0;
+    bottom: 0;
+
+    background: #444;
+
+    border: 0.125em solid #666;
+    -moz-border-radius:    0.25em;
+    -webkit-border-radius: 0.25em;
+    -khtml-border-radius:  0.25em;
+    border-radius:         0.25em;
+
+    color: white;
+    font-size: 40%;
+    font-weight: lighter;
+    text-align: center;
+    white-space: pre;
+
+    text-shadow:  1px  1px 0 rgba(0, 0, 0, 0.25),
+                  1px -1px 0 rgba(0, 0, 0, 0.25),
+                 -1px  1px 0 rgba(0, 0, 0, 0.25),
+                 -1px -1px 0 rgba(0, 0, 0, 0.25);
+
+}
+
+.guac-keyboard .guac-keyboard-key:hover {
+    cursor: pointer;
+}
+
+.guac-keyboard .guac-keyboard-key.highlight {
+    background: #666;
+    border-color: #666;
+}
+
+/* Align some keys to the left */
+.guac-keyboard .guac-keyboard-key-caps,
+.guac-keyboard .guac-keyboard-key-enter,
+.guac-keyboard .guac-keyboard-key-tab,
+.guac-keyboard .guac-keyboard-key-lalt,
+.guac-keyboard .guac-keyboard-key-ralt,
+.guac-keyboard .guac-keyboard-key-alt-gr,
+.guac-keyboard .guac-keyboard-key-lctrl,
+.guac-keyboard .guac-keyboard-key-rctrl,
+.guac-keyboard .guac-keyboard-key-lshift,
+.guac-keyboard .guac-keyboard-key-rshift {
+    text-align: left;
+    padding-left: 0.75em;
+}
+
+/* Active shift */
+.guac-keyboard.guac-keyboard-modifier-shift .guac-keyboard-key-rshift,
+.guac-keyboard.guac-keyboard-modifier-shift .guac-keyboard-key-lshift,
+
+/* Active ctrl */
+.guac-keyboard.guac-keyboard-modifier-control .guac-keyboard-key-rctrl,
+.guac-keyboard.guac-keyboard-modifier-control .guac-keyboard-key-lctrl,
+
+/* Active alt */
+.guac-keyboard.guac-keyboard-modifier-alt .guac-keyboard-key-ralt,
+.guac-keyboard.guac-keyboard-modifier-alt .guac-keyboard-key-lalt,
+
+/* Active alt-gr */
+.guac-keyboard.guac-keyboard-modifier-alt-gr .guac-keyboard-key-alt-gr,
+
+/* Active caps */
+.guac-keyboard.guac-keyboard-modifier-caps .guac-keyboard-key-caps,
+
+/* Active super */
+.guac-keyboard.guac-keyboard-modifier-super .guac-keyboard-key-super {
+    background: #882;
+    border-color: #DD4;
+}
+
+.guac-keyboard .guac-keyboard-key.guac-keyboard-pressed {
+    background: #822;
+    border-color: #D44;
+}
+
+.guac-keyboard .guac-keyboard-group {
+    line-height: 0;
+}
+
+.guac-keyboard .guac-keyboard-group.guac-keyboard-alpha,
+.guac-keyboard .guac-keyboard-group.guac-keyboard-movement {
+    display: inline-block;
+    text-align: center;
+    vertical-align: top;
+}
+
+.guac-keyboard .guac-keyboard-group.guac-keyboard-main {
+
+    /* IE10 */
+    display: -ms-flexbox;
+    -ms-flex-align: stretch;
+    -ms-flex-direction: row;
+
+    /* Ancient Mozilla */
+    display: -moz-box;
+    -moz-box-align: stretch;
+    -moz-box-orient: horizontal;
+    
+    /* Ancient WebKit */
+    display: -webkit-box;
+    -webkit-box-align: stretch;
+    -webkit-box-orient: horizontal;
+
+    /* Old WebKit */
+    display: -webkit-flex;
+    -webkit-align-items: stretch;
+    -webkit-flex-direction: row;
+
+    /* W3C */
+    display: flex;
+    align-items: stretch;
+    flex-direction: row;
+
+}
+
+.guac-keyboard .guac-keyboard-group.guac-keyboard-movement {
+    -ms-flex: 1 1 auto;
+    -moz-box-flex: 1;
+    -webkit-box-flex: 1;
+    -webkit-flex: 1 1 auto;
+    flex: 1 1 auto;
+}
+
+.guac-keyboard .guac-keyboard-gap {
+    display: inline-block;
+}
+
+/* Hide keycaps requiring modifiers which are NOT currently active. */
+.guac-keyboard:not(.guac-keyboard-modifier-caps)
+.guac-keyboard-cap.guac-keyboard-requires-caps,
+
+.guac-keyboard:not(.guac-keyboard-modifier-shift)
+.guac-keyboard-cap.guac-keyboard-requires-shift,
+
+.guac-keyboard:not(.guac-keyboard-modifier-alt-gr)
+.guac-keyboard-cap.guac-keyboard-requires-alt-gr,
+
+/* Hide keycaps NOT requiring modifiers which ARE currently active, where that
+   modifier is used to determine which cap is displayed for the current key. */
+.guac-keyboard.guac-keyboard-modifier-shift
+.guac-keyboard-key.guac-keyboard-uses-shift
+.guac-keyboard-cap:not(.guac-keyboard-requires-shift),
+
+.guac-keyboard.guac-keyboard-modifier-caps
+.guac-keyboard-key.guac-keyboard-uses-caps
+.guac-keyboard-cap:not(.guac-keyboard-requires-caps),
+
+.guac-keyboard.guac-keyboard-modifier-alt-gr
+.guac-keyboard-key.guac-keyboard-uses-alt-gr
+.guac-keyboard-cap:not(.guac-keyboard-requires-alt-gr) {
+
+    display: none;
+    
+}
+
+/* Fade out keys which do not use AltGr if AltGr is active */
+.guac-keyboard.guac-keyboard-modifier-alt-gr
+.guac-keyboard-key:not(.guac-keyboard-uses-alt-gr):not(.guac-keyboard-key-alt-gr) {
+    opacity: 0.5;
+}
diff --git a/guacamole/src/main/webapp/app/osk/templates/guacOsk.html b/guacamole/src/main/webapp/app/osk/templates/guacOsk.html
new file mode 100644
index 0000000..097d1b3
--- /dev/null
+++ b/guacamole/src/main/webapp/app/osk/templates/guacOsk.html
@@ -0,0 +1,23 @@
+<div class="osk" guac-resize="keyboardResized">
+    <!--
+       Copyright (C) 2014 Glyptodon LLC
+
+       Permission is hereby granted, free of charge, to any person obtaining a copy
+       of this software and associated documentation files (the "Software"), to deal
+       in the Software without restriction, including without limitation the rights
+       to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+       copies of the Software, and to permit persons to whom the Software is
+       furnished to do so, subject to the following conditions:
+
+       The above copyright notice and this permission notice shall be included in
+       all copies or substantial portions of the Software.
+
+       THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+       IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+       FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+       AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+       LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+       OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+       THE SOFTWARE.
+    -->
+</div>
diff --git a/guacamole/src/main/webapp/app/rest/restModule.js b/guacamole/src/main/webapp/app/rest/restModule.js
new file mode 100644
index 0000000..d5a3a27
--- /dev/null
+++ b/guacamole/src/main/webapp/app/rest/restModule.js
@@ -0,0 +1,27 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * The module for code relating to communication with the REST API of the
+ * Guacamole web application.
+ */
+angular.module('rest', ['auth']);
diff --git a/guacamole/src/main/webapp/app/rest/services/activeConnectionService.js b/guacamole/src/main/webapp/app/rest/services/activeConnectionService.js
new file mode 100644
index 0000000..99428a9
--- /dev/null
+++ b/guacamole/src/main/webapp/app/rest/services/activeConnectionService.js
@@ -0,0 +1,174 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Service for operating on active connections via the REST API.
+ */
+angular.module('rest').factory('activeConnectionService', ['$injector',
+        function activeConnectionService($injector) {
+
+    // Required services
+    var $http                 = $injector.get('$http');
+    var $q                    = $injector.get('$q');
+    var authenticationService = $injector.get('authenticationService');
+
+    var service = {};
+
+    /**
+     * Makes a request to the REST API to get the list of active tunnels,
+     * returning a promise that provides a map of @link{ActiveConnection}
+     * objects if successful.
+     *
+     * @param {String[]} [permissionTypes]
+     *     The set of permissions to filter with. A user must have one or more
+     *     of these permissions for an active connection to appear in the
+     *     result.  If null, no filtering will be performed. Valid values are
+     *     listed within PermissionSet.ObjectType.
+     *                          
+     * @returns {Promise.<Object.<String, ActiveConnection>>}
+     *     A promise which will resolve with a map of @link{ActiveConnection}
+     *     objects, where each key is the identifier of the corresponding
+     *     active connection.
+     */
+    service.getActiveConnections = function getActiveConnections(dataSource, permissionTypes) {
+
+        // Build HTTP parameters set
+        var httpParameters = {
+            token : authenticationService.getCurrentToken()
+        };
+
+        // Add permission filter if specified
+        if (permissionTypes)
+            httpParameters.permission = permissionTypes;
+
+        // Retrieve tunnels
+        return $http({
+            method  : 'GET',
+            url     : 'api/data/' + encodeURIComponent(dataSource) + '/activeConnections',
+            params  : httpParameters
+        });
+
+    };
+
+    /**
+     * Returns a promise which resolves with all active connections accessible
+     * by the current user, as a map of @link{ActiveConnection} maps, as would
+     * be returned by getActiveConnections(), grouped by the identifier of
+     * their corresponding data source. All given data sources are queried. If
+     * an error occurs while retrieving any ActiveConnection map, the promise
+     * will be rejected.
+     *
+     * @param {String[]} dataSources
+     *     The unique identifier of the data sources containing the active
+     *     connections to be retrieved. These identifiers correspond to
+     *     AuthenticationProviders within the Guacamole web application.
+     *
+     * @param {String[]} [permissionTypes]
+     *     The set of permissions to filter with. A user must have one or more
+     *     of these permissions for an active connection to appear in the
+     *     result.  If null, no filtering will be performed. Valid values are
+     *     listed within PermissionSet.ObjectType.
+     *
+     * @returns {Promise.<Object.<String, Object.<String, ActiveConnection>>>}
+     *     A promise which resolves with all active connections available to
+     *     the current user, as a map of ActiveConnection maps, as would be
+     *     returned by getActiveConnections(), grouped by the identifier of
+     *     their corresponding data source.
+     */
+    service.getAllActiveConnections = function getAllActiveConnections(dataSources, permissionTypes) {
+
+        var deferred = $q.defer();
+
+        var activeConnectionRequests = [];
+        var activeConnectionMaps = {};
+
+        // Retrieve all active connections from all data sources
+        angular.forEach(dataSources, function retrieveActiveConnections(dataSource) {
+            activeConnectionRequests.push(
+                service.getActiveConnections(dataSource, permissionTypes)
+                .success(function activeConnectionsRetrieved(activeConnections) {
+                    activeConnectionMaps[dataSource] = activeConnections;
+                })
+            );
+        });
+
+        // Resolve when all requests are completed
+        $q.all(activeConnectionRequests)
+        .then(
+
+            // All requests completed successfully
+            function allActiveConnectionsRetrieved() {
+                deferred.resolve(userArrays);
+            },
+
+            // At least one request failed
+            function activeConnectionRetrievalFailed(e) {
+                deferred.reject(e);
+            }
+
+        );
+
+        return deferred.promise;
+
+    };
+
+    /**
+     * Makes a request to the REST API to delete the active connections having
+     * the given identifiers, effectively disconnecting them, returning a
+     * promise that can be used for processing the results of the call.
+     *
+     * @param {String[]} identifiers
+     *     The identifiers of the active connections to delete.
+     *
+     * @returns {Promise}
+     *     A promise for the HTTP call which will succeed if and only if the
+     *     delete operation is successful.
+     */
+    service.deleteActiveConnections = function deleteActiveConnections(dataSource, identifiers) {
+
+        // Build HTTP parameters set
+        var httpParameters = {
+            token : authenticationService.getCurrentToken()
+        };
+
+        // Convert provided array of identifiers to a patch
+        var activeConnectionPatch = [];
+        identifiers.forEach(function addActiveConnectionPatch(identifier) {
+            activeConnectionPatch.push({
+                op   : 'remove',
+                path : '/' + identifier 
+            });
+        });
+
+        // Perform active connection deletion via PATCH
+        return $http({
+            method  : 'PATCH',
+            url     : 'api/data/' + encodeURIComponent(dataSource) + '/activeConnections',
+            params  : httpParameters,
+            data    : activeConnectionPatch
+        });
+        
+    };
+
+    return service;
+
+}]);
diff --git a/guacamole/src/main/webapp/app/rest/services/cacheService.js b/guacamole/src/main/webapp/app/rest/services/cacheService.js
new file mode 100644
index 0000000..ef523dc
--- /dev/null
+++ b/guacamole/src/main/webapp/app/rest/services/cacheService.js
@@ -0,0 +1,82 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Service which contains all REST API response caches.
+ */
+angular.module('rest').factory('cacheService', ['$injector',
+        function cacheService($injector) {
+
+    // Required services
+    var $cacheFactory = $injector.get('$cacheFactory');
+    var $rootScope    = $injector.get('$rootScope');
+
+    // Service containing all caches
+    var service = {};
+
+    /**
+     * Shared cache used by both connectionGroupService and
+     * connectionService.
+     *
+     * @type $cacheFactory.Cache
+     */
+    service.connections = $cacheFactory('API-CONNECTIONS');
+
+    /**
+     * Cache used by languageService.
+     *
+     * @type $cacheFactory.Cache
+     */
+    service.languages = $cacheFactory('API-LANGUAGES');
+
+    /**
+     * Cache used by schemaService.
+     *
+     * @type $cacheFactory.Cache
+     */
+    service.schema = $cacheFactory('API-SCHEMA');
+
+    /**
+     * Shared cache used by both userService and permissionService.
+     *
+     * @type $cacheFactory.Cache
+     */
+    service.users = $cacheFactory('API-USERS');
+
+    /**
+     * Clear all caches defined in this service.
+     */
+    service.clearCaches = function clearCaches() {
+        service.connections.removeAll();
+        service.languages.removeAll();
+        service.schema.removeAll();
+        service.users.removeAll();
+    };
+
+    // Clear caches on logout
+    $rootScope.$on('guacLogout', function handleLogout() {
+        service.clearCaches();
+    });
+
+    return service;
+
+}]);
diff --git a/guacamole/src/main/webapp/app/rest/services/connectionGroupService.js b/guacamole/src/main/webapp/app/rest/services/connectionGroupService.js
new file mode 100644
index 0000000..f157e75
--- /dev/null
+++ b/guacamole/src/main/webapp/app/rest/services/connectionGroupService.js
@@ -0,0 +1,203 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Service for operating on connection groups via the REST API.
+ */
+angular.module('rest').factory('connectionGroupService', ['$injector',
+        function connectionGroupService($injector) {
+
+    // Required services
+    var $http                 = $injector.get('$http');
+    var $q                    = $injector.get('$q');
+    var authenticationService = $injector.get('authenticationService');
+    var cacheService          = $injector.get('cacheService');
+    
+    // Required types
+    var ConnectionGroup = $injector.get('ConnectionGroup');
+
+    var service = {};
+    
+    /**
+     * Makes a request to the REST API to get an individual connection group
+     * and all descendants, returning a promise that provides the corresponding
+     * @link{ConnectionGroup} if successful. Descendant groups and connections
+     * will be stored as children of that connection group. If a permission
+     * type is specified, the result will be filtering by that permission.
+     * 
+     * @param {String} [connectionGroupID=ConnectionGroup.ROOT_IDENTIFIER]
+     *     The ID of the connection group to retrieve. If not provided, the
+     *     root connection group will be retrieved by default.
+     *     
+     * @param {String[]} [permissionTypes]
+     *     The set of permissions to filter with. A user must have one or more
+     *     of these permissions for a connection to appear in the result. 
+     *     If null, no filtering will be performed. Valid values are listed
+     *     within PermissionSet.ObjectType.
+     *
+     * @returns {Promise.ConnectionGroup}
+     *     A promise which will resolve with a @link{ConnectionGroup} upon
+     *     success.
+     */
+    service.getConnectionGroupTree = function getConnectionGroupTree(dataSource, connectionGroupID, permissionTypes) {
+        
+        // Use the root connection group ID if no ID is passed in
+        connectionGroupID = connectionGroupID || ConnectionGroup.ROOT_IDENTIFIER;
+
+        // Build HTTP parameters set
+        var httpParameters = {
+            token : authenticationService.getCurrentToken()
+        };
+
+        // Add permission filter if specified
+        if (permissionTypes)
+            httpParameters.permission = permissionTypes;
+
+        // Retrieve connection group 
+        return $http({
+            cache   : cacheService.connections,
+            method  : 'GET',
+            url     : 'api/data/' + encodeURIComponent(dataSource) + '/connectionGroups/' + encodeURIComponent(connectionGroupID) + '/tree',
+            params  : httpParameters
+        });
+       
+    };
+
+    /**
+     * Makes a request to the REST API to get an individual connection group,
+     * returning a promise that provides the corresponding
+     * @link{ConnectionGroup} if successful.
+     *
+     * @param {String} [connectionGroupID=ConnectionGroup.ROOT_IDENTIFIER]
+     *     The ID of the connection group to retrieve. If not provided, the
+     *     root connection group will be retrieved by default.
+     *     
+     * @returns {Promise.<ConnectionGroup>} A promise for the HTTP call.
+     *     A promise which will resolve with a @link{ConnectionGroup} upon
+     *     success.
+     */
+    service.getConnectionGroup = function getConnectionGroup(dataSource, connectionGroupID) {
+        
+        // Use the root connection group ID if no ID is passed in
+        connectionGroupID = connectionGroupID || ConnectionGroup.ROOT_IDENTIFIER;
+        
+        // Build HTTP parameters set
+        var httpParameters = {
+            token : authenticationService.getCurrentToken()
+        };
+
+        // Retrieve connection group
+        return $http({
+            cache   : cacheService.connections,
+            method  : 'GET',
+            url     : 'api/data/' + encodeURIComponent(dataSource) + '/connectionGroups/' + encodeURIComponent(connectionGroupID),
+            params  : httpParameters
+        });
+
+    };
+    
+    /**
+     * Makes a request to the REST API to save a connection group, returning a
+     * promise that can be used for processing the results of the call. If the
+     * connection group is new, and thus does not yet have an associated
+     * identifier, the identifier will be automatically set in the provided
+     * connection group upon success.
+     * 
+     * @param {ConnectionGroup} connectionGroup The connection group to update.
+     *                          
+     * @returns {Promise}
+     *     A promise for the HTTP call which will succeed if and only if the
+     *     save operation is successful.
+     */
+    service.saveConnectionGroup = function saveConnectionGroup(dataSource, connectionGroup) {
+
+        // Build HTTP parameters set
+        var httpParameters = {
+            token : authenticationService.getCurrentToken()
+        };
+
+        // If connection group is new, add it and set the identifier automatically
+        if (!connectionGroup.identifier) {
+            return $http({
+                method  : 'POST',
+                url     : 'api/data/' + encodeURIComponent(dataSource) + '/connectionGroups',
+                params  : httpParameters,
+                data    : connectionGroup
+            })
+
+            // Set the identifier on the new connection group and clear the cache
+            .success(function connectionGroupCreated(newConnectionGroup){
+                connectionGroup.identifier = newConnectionGroup.identifier;
+                cacheService.connections.removeAll();
+            });
+        }
+
+        // Otherwise, update the existing connection group
+        else {
+            return $http({
+                method  : 'PUT',
+                url     : 'api/data/' + encodeURIComponent(dataSource) + '/connectionGroups/' + encodeURIComponent(connectionGroup.identifier),
+                params  : httpParameters,
+                data    : connectionGroup
+            })
+
+            // Clear the cache
+            .success(function connectionGroupUpdated(){
+                cacheService.connections.removeAll();
+            });
+        }
+
+    };
+    
+    /**
+     * Makes a request to the REST API to delete a connection group, returning
+     * a promise that can be used for processing the results of the call.
+     * 
+     * @param {ConnectionGroup} connectionGroup The connection group to delete.
+     *                          
+     * @returns {Promise}
+     *     A promise for the HTTP call which will succeed if and only if the
+     *     delete operation is successful.
+     */
+    service.deleteConnectionGroup = function deleteConnectionGroup(dataSource, connectionGroup) {
+
+        // Build HTTP parameters set
+        var httpParameters = {
+            token : authenticationService.getCurrentToken()
+        };
+
+        // Delete connection group
+        return $http({
+            method  : 'DELETE',
+            url     : 'api/data/' + encodeURIComponent(dataSource) + '/connectionGroups/' + encodeURIComponent(connectionGroup.identifier),
+            params  : httpParameters
+        })
+
+        // Clear the cache
+        .success(function connectionGroupDeleted(){
+            cacheService.connections.removeAll();
+        });
+
+    };
+    
+    return service;
+}]);
diff --git a/guacamole/src/main/webapp/app/rest/services/connectionService.js b/guacamole/src/main/webapp/app/rest/services/connectionService.js
new file mode 100644
index 0000000..aa12639
--- /dev/null
+++ b/guacamole/src/main/webapp/app/rest/services/connectionService.js
@@ -0,0 +1,210 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Service for operating on connections via the REST API.
+ */
+angular.module('rest').factory('connectionService', ['$injector',
+        function connectionService($injector) {
+
+    // Required services
+    var $http                 = $injector.get('$http');
+    var authenticationService = $injector.get('authenticationService');
+    var cacheService          = $injector.get('cacheService');
+    
+    var service = {};
+    
+    /**
+     * Makes a request to the REST API to get a single connection, returning a
+     * promise that provides the corresponding @link{Connection} if successful.
+     * 
+     * @param {String} id The ID of the connection.
+     * 
+     * @returns {Promise.<Connection>}
+     *     A promise which will resolve with a @link{Connection} upon success.
+     * 
+     * @example
+     * 
+     * connectionService.getConnection('myConnection').success(function(connection) {
+     *     // Do something with the connection
+     * });
+     */
+    service.getConnection = function getConnection(dataSource, id) {
+
+        // Build HTTP parameters set
+        var httpParameters = {
+            token : authenticationService.getCurrentToken()
+        };
+
+        // Retrieve connection
+        return $http({
+            cache   : cacheService.connections,
+            method  : 'GET',
+            url     : 'api/data/' + encodeURIComponent(dataSource) + '/connections/' + encodeURIComponent(id),
+            params  : httpParameters
+        });
+
+    };
+
+    /**
+     * Makes a request to the REST API to get the usage history of a single
+     * connection, returning a promise that provides the corresponding
+     * array of @link{ConnectionHistoryEntry} objects if successful.
+     * 
+     * @param {String} id
+     *     The identifier of the connection.
+     * 
+     * @returns {Promise.<ConnectionHistoryEntry[]>}
+     *     A promise which will resolve with an array of
+     *     @link{ConnectionHistoryEntry} objects upon success.
+     */
+    service.getConnectionHistory = function getConnectionHistory(dataSource, id) {
+
+        // Build HTTP parameters set
+        var httpParameters = {
+            token : authenticationService.getCurrentToken()
+        };
+
+        // Retrieve connection history
+        return $http({
+            method  : 'GET',
+            url     : 'api/data/' + encodeURIComponent(dataSource) + '/connections/' + encodeURIComponent(id) + '/history',
+            params  : httpParameters
+        });
+ 
+    };
+
+    /**
+     * Makes a request to the REST API to get the parameters of a single
+     * connection, returning a promise that provides the corresponding
+     * map of parameter name/value pairs if successful.
+     * 
+     * @param {String} id
+     *     The identifier of the connection.
+     * 
+     * @returns {Promise.<Object.<String, String>>}
+     *     A promise which will resolve with an map of parameter name/value
+     *     pairs upon success.
+     */
+    service.getConnectionParameters = function getConnectionParameters(dataSource, id) {
+
+        // Build HTTP parameters set
+        var httpParameters = {
+            token : authenticationService.getCurrentToken()
+        };
+
+        // Retrieve connection parameters
+        return $http({
+            cache   : cacheService.connections,
+            method  : 'GET',
+            url     : 'api/data/' + encodeURIComponent(dataSource) + '/connections/' + encodeURIComponent(id) + '/parameters',
+            params  : httpParameters
+        });
+ 
+    };
+
+    /**
+     * Makes a request to the REST API to save a connection, returning a
+     * promise that can be used for processing the results of the call. If the
+     * connection is new, and thus does not yet have an associated identifier,
+     * the identifier will be automatically set in the provided connection
+     * upon success.
+     * 
+     * @param {Connection} connection The connection to update.
+     *                          
+     * @returns {Promise}
+     *     A promise for the HTTP call which will succeed if and only if the
+     *     save operation is successful.
+     */
+    service.saveConnection = function saveConnection(dataSource, connection) {
+        
+        // Build HTTP parameters set
+        var httpParameters = {
+            token : authenticationService.getCurrentToken()
+        };
+
+        // If connection is new, add it and set the identifier automatically
+        if (!connection.identifier) {
+            return $http({
+                method  : 'POST',
+                url     : 'api/data/' + encodeURIComponent(dataSource) + '/connections',
+                params  : httpParameters,
+                data    : connection
+            })
+
+            // Set the identifier on the new connection and clear the cache
+            .success(function connectionCreated(newConnection){
+                connection.identifier = newConnection.identifier;
+                cacheService.connections.removeAll();
+            });
+        }
+
+        // Otherwise, update the existing connection
+        else {
+            return $http({
+                method  : 'PUT',
+                url     : 'api/data/' + encodeURIComponent(dataSource) + '/connections/' + encodeURIComponent(connection.identifier),
+                params  : httpParameters,
+                data    : connection
+            })
+            
+            // Clear the cache
+            .success(function connectionUpdated(){
+                cacheService.connections.removeAll();
+            });
+        }
+
+    };
+    
+    /**
+     * Makes a request to the REST API to delete a connection,
+     * returning a promise that can be used for processing the results of the call.
+     * 
+     * @param {Connection} connection The connection to delete.
+     *                          
+     * @returns {Promise}
+     *     A promise for the HTTP call which will succeed if and only if the
+     *     delete operation is successful.
+     */
+    service.deleteConnection = function deleteConnection(dataSource, connection) {
+
+        // Build HTTP parameters set
+        var httpParameters = {
+            token : authenticationService.getCurrentToken()
+        };
+
+        // Delete connection
+        return $http({
+            method  : 'DELETE',
+            url     : 'api/data/' + encodeURIComponent(dataSource) + '/connections/' + encodeURIComponent(connection.identifier),
+            params  : httpParameters
+        })
+
+        // Clear the cache
+        .success(function connectionDeleted(){
+            cacheService.connections.removeAll();
+        });
+
+    };
+    
+    return service;
+}]);
diff --git a/guacamole/src/main/webapp/app/rest/services/dataSourceService.js b/guacamole/src/main/webapp/app/rest/services/dataSourceService.js
new file mode 100644
index 0000000..70e7ad4
--- /dev/null
+++ b/guacamole/src/main/webapp/app/rest/services/dataSourceService.js
@@ -0,0 +1,125 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Service which contains all REST API response caches.
+ */
+angular.module('rest').factory('dataSourceService', ['$injector',
+        function dataSourceService($injector) {
+
+    // Required services
+    var $q = $injector.get('$q');
+
+    // Service containing all caches
+    var service = {};
+
+    /**
+     * Invokes the given function once for each of the given data sources,
+     * passing that data source as the first argument to each invocation,
+     * followed by any additional arguments passed to apply(). The results of
+     * each invocation are aggregated into a map by data source identifier,
+     * and handled through a single promise which is resolved or rejected
+     * depending on the success/failure of each resulting REST call. Any error
+     * results in rejection of the entire apply() operation, except 404 ("NOT
+     * FOUND") errors, which are ignored.
+     *
+     * @param {Function} fn
+     *     The function to call for each of the given data sources. The data
+     *     source identifier will be given as the first argument, followed by
+     *     the rest of the arguments given to apply(), in order. The function
+     *     must return a Promise which is resolved or rejected depending on the
+     *     result of the REST call.
+     *
+     * @param {String[]} dataSources
+     *     The array or data source identifiers against which the given
+     *     function should be called.
+     *
+     * @param {...*} args
+     *     Any additional arguments to pass to the given function each time it
+     *     is called.
+     *
+     * @returns {Promise.<Object.<String, *>>}
+     *     A Promise which resolves with a map of data source identifier to
+     *     corresponding result. The result will be the exact object or value
+     *     provided as the resolution to the Promise returned by calls to the
+     *     given function.
+     */
+    service.apply = function apply(fn, dataSources) {
+
+        var deferred = $q.defer();
+
+        var requests = [];
+        var results = {};
+
+        // Build array of arguments to pass to the given function
+        var args = [];
+        for (var i = 2; i < arguments.length; i++)
+            args.push(arguments[i]);
+
+        // Retrieve the root group from all data sources
+        angular.forEach(dataSources, function invokeAgainstDataSource(dataSource) {
+
+            // Add promise to list of pending requests
+            var deferredRequest = $q.defer();
+            requests.push(deferredRequest.promise);
+
+            // Retrieve root group from data source
+            fn.apply(this, [dataSource].concat(args))
+
+            // Store result on success
+            .then(function immediateRequestSucceeded(response) {
+                results[dataSource] = response.data;
+                deferredRequest.resolve();
+            },
+
+            // Fail on any errors (except "NOT FOUND")
+            function immediateRequestFailed(response) {
+
+                // Ignore "NOT FOUND" errors
+                if (response.status === 404)
+                    deferredRequest.resolve();
+
+                // Explicitly abort for all other errors
+                else
+                    deferredRequest.reject(response);
+
+            });
+
+        });
+
+        // Resolve if all requests succeed
+        $q.all(requests).then(function requestsSucceeded() {
+            deferred.resolve(results);
+        },
+
+        // Reject if at least one request fails
+        function requestFailed(response) {
+            deferred.reject(response);
+        });
+
+        return deferred.promise;
+
+    };
+
+    return service;
+
+}]);
diff --git a/guacamole/src/main/webapp/app/rest/services/historyService.js b/guacamole/src/main/webapp/app/rest/services/historyService.js
new file mode 100644
index 0000000..a29521b
--- /dev/null
+++ b/guacamole/src/main/webapp/app/rest/services/historyService.js
@@ -0,0 +1,90 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Service for operating on history records via the REST API.
+ */
+angular.module('rest').factory('historyService', ['$injector',
+        function historyService($injector) {
+
+    // Required services
+    var $http                 = $injector.get('$http');
+    var authenticationService = $injector.get('authenticationService');
+
+    var service = {};
+
+    /**
+     * Makes a request to the REST API to get the usage history of all
+     * accessible connections, returning a promise that provides the
+     * corresponding array of @link{ConnectionHistoryEntry} objects if
+     * successful.
+     *
+     * @param {String} dataSource
+     *     The unique identifier of the data source containing the connection
+     *     history records to be retrieved. This identifier corresponds to an
+     *     AuthenticationProvider within the Guacamole web application.
+     *
+     * @param {String[]} [requiredContents]
+     *     The set of arbitrary strings to filter with. A ConnectionHistoryEntry
+     *     must contain each of these values within the associated username,
+     *     connection name, start date, or end date to appear in the result. If
+     *     null, no filtering will be performed.
+     *
+     * @param {String[]} [sortPredicates]
+     *     The set of predicates to sort against. The resulting array of
+     *     ConnectionHistoryEntry objects will be sorted according to the
+     *     properties and sort orders defined by each predicate. If null, the
+     *     order of the resulting entries is undefined. Valid values are listed
+     *     within ConnectionHistoryEntry.SortPredicate.
+     *
+     * @returns {Promise.<ConnectionHistoryEntry[]>}
+     *     A promise which will resolve with an array of
+     *     @link{ConnectionHistoryEntry} objects upon success.
+     */
+    service.getConnectionHistory = function getConnectionHistory(dataSource,
+        requiredContents, sortPredicates) {
+
+        // Build HTTP parameters set
+        var httpParameters = {
+            token : authenticationService.getCurrentToken()
+        };
+
+        // Filter according to contents if restrictions are specified
+        if (requiredContents)
+            httpParameters.contains = requiredContents;
+
+        // Sort according to provided predicates, if any
+        if (sortPredicates)
+            httpParameters.order = sortPredicates;
+
+        // Retrieve connection history
+        return $http({
+            method  : 'GET',
+            url     : 'api/data/' + encodeURIComponent(dataSource) + '/history/connections',
+            params  : httpParameters
+        });
+
+    };
+
+    return service;
+
+}]);
diff --git a/guacamole/src/main/webapp/app/rest/services/languageService.js b/guacamole/src/main/webapp/app/rest/services/languageService.js
new file mode 100644
index 0000000..407a6a9
--- /dev/null
+++ b/guacamole/src/main/webapp/app/rest/services/languageService.js
@@ -0,0 +1,64 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Service for operating on language metadata via the REST API.
+ */
+angular.module('rest').factory('languageService', ['$injector',
+        function languageService($injector) {
+
+    // Required services
+    var $http                 = $injector.get('$http');
+    var authenticationService = $injector.get('authenticationService');
+    var cacheService          = $injector.get('cacheService');
+
+    var service = {};
+    
+    /**
+     * Makes a request to the REST API to get the list of languages, returning
+     * a promise that provides a map of language names by language key if 
+     * successful.
+     *                          
+     * @returns {Promise.<Object.<String, String>>}
+     *     A promise which will resolve with a map of language names by
+     *     language key upon success.
+     */
+    service.getLanguages = function getLanguages() {
+
+        // Build HTTP parameters set
+        var httpParameters = {
+            token : authenticationService.getCurrentToken()
+        };
+
+        // Retrieve available languages
+        return $http({
+            cache   : cacheService.languages,
+            method  : 'GET',
+            url     : 'api/languages',
+            params  : httpParameters
+        });
+
+    };
+    
+    return service;
+
+}]);
diff --git a/guacamole/src/main/webapp/app/rest/services/permissionService.js b/guacamole/src/main/webapp/app/rest/services/permissionService.js
new file mode 100644
index 0000000..3cef2b9
--- /dev/null
+++ b/guacamole/src/main/webapp/app/rest/services/permissionService.js
@@ -0,0 +1,254 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Service for operating on user permissions via the REST API.
+ */
+angular.module('rest').factory('permissionService', ['$injector',
+        function permissionService($injector) {
+
+    // Required services
+    var $http                 = $injector.get('$http');
+    var $q                    = $injector.get('$q');
+    var authenticationService = $injector.get('authenticationService');
+    var cacheService          = $injector.get('cacheService');
+    
+    // Required types
+    var PermissionPatch = $injector.get('PermissionPatch');
+
+    var service = {};
+
+    /**
+     * Makes a request to the REST API to get the list of permissions for a
+     * given user, returning a promise that provides an array of
+     * @link{Permission} objects if successful.
+     * 
+     * @param {String} dataSource
+     *     The unique identifier of the data source containing the user whose
+     *     permissions should be retrieved. This identifier corresponds to an
+     *     AuthenticationProvider within the Guacamole web application.
+     *
+     * @param {String} userID
+     *     The ID of the user to retrieve the permissions for.
+     *                          
+     * @returns {Promise.<PermissionSet>}
+     *     A promise which will resolve with a @link{PermissionSet} upon
+     *     success.
+     */
+    service.getPermissions = function getPermissions(dataSource, userID) {
+
+        // Build HTTP parameters set
+        var httpParameters = {
+            token : authenticationService.getCurrentToken()
+        };
+
+        // Retrieve user permissions
+        return $http({
+            cache   : cacheService.users,
+            method  : 'GET',
+            url     : 'api/data/' + encodeURIComponent(dataSource) + '/users/' + encodeURIComponent(userID) + '/permissions',
+            params  : httpParameters
+        });
+
+    };
+
+    /**
+     * Makes a request to the REST API to add permissions for a given user,
+     * returning a promise that can be used for processing the results of the
+     * call.
+     * 
+     * @param {String} dataSource
+     *     The unique identifier of the data source containing the user whose
+     *     permissions should be modified. This identifier corresponds to an
+     *     AuthenticationProvider within the Guacamole web application.
+     *
+     * @param {String} userID
+     *     The ID of the user to modify the permissions of.
+     *                          
+     * @param {PermissionSet} permissions
+     *     The set of permissions to add.
+     *                          
+     * @returns {Promise}
+     *     A promise for the HTTP call which will succeed if and only if the
+     *     add operation is successful.
+     */
+    service.addPermissions = function addPermissions(dataSource, userID, permissions) {
+        return service.patchPermissions(dataSource, userID, permissions, null);
+    };
+    
+    /**
+     * Makes a request to the REST API to remove permissions for a given user,
+     * returning a promise that can be used for processing the results of the
+     * call.
+     * 
+     * @param {String} dataSource
+     *     The unique identifier of the data source containing the user whose
+     *     permissions should be modified. This identifier corresponds to an
+     *     AuthenticationProvider within the Guacamole web application.
+     *
+     * @param {String} userID
+     *     The ID of the user to modify the permissions of.
+     *                          
+     * @param {PermissionSet} permissions
+     *     The set of permissions to remove.
+     *                          
+     * @returns {Promise}
+     *     A promise for the HTTP call which will succeed if and only if the
+     *     remove operation is successful.
+     */
+    service.removePermissions = function removePermissions(dataSource, userID, permissions) {
+        return service.patchPermissions(dataSource, userID, null, permissions);
+    };
+
+    /**
+     * Adds patches for modifying the permissions associated with specific
+     * objects to the given array of patches.
+     *
+     * @param {PermissionPatch[]} patch
+     *     The array of patches to add new patches to.
+     *
+     * @param {String} operation
+     *     The operation to specify within each of the patches. Valid values
+     *     for this are defined within PermissionPatch.Operation.
+     *     
+     * @param {String} path
+     *     The path of the permissions being patched. The path is a JSON path
+     *     describing the position of the permissions within a PermissionSet.
+     *
+     * @param {Object.<String, String[]>} permissions
+     *     A map of object identifiers to arrays of permission type strings,
+     *     where each type string is a value from
+     *     PermissionSet.ObjectPermissionType.
+     */
+    var addObjectPatchOperations = function addObjectPatchOperations(patch, operation, path, permissions) {
+
+        // Add object permission operations to patch
+        for (var identifier in permissions) {
+            permissions[identifier].forEach(function addObjectPatch(type) {
+                patch.push({
+                    op    : operation,
+                    path  : path + "/" + identifier,
+                    value : type
+                });
+            });
+        }
+
+    };
+
+    /**
+     * Adds patches for modifying any permission that can be stored within a
+     * @link{PermissionSet}.
+     * 
+     * @param {PermissionPatch[]} patch
+     *     The array of patches to add new patches to.
+     *
+     * @param {String} operation
+     *     The operation to specify within each of the patches. Valid values
+     *     for this are defined within PermissionPatch.Operation.
+     *
+     * @param {PermissionSet} permissions
+     *     The set of permissions for which patches should be added.
+     */
+    var addPatchOperations = function addPatchOperations(patch, operation, permissions) {
+
+        // Add connection permission operations to patch
+        addObjectPatchOperations(patch, operation, "/connectionPermissions",
+            permissions.connectionPermissions);
+
+        // Add connection group permission operations to patch
+        addObjectPatchOperations(patch, operation, "/connectionGroupPermissions",
+            permissions.connectionGroupPermissions);
+
+        // Add active connection permission operations to patch
+        addObjectPatchOperations(patch, operation, "/activeConnectionPermissions",
+            permissions.activeConnectionPermissions);
+
+        // Add user permission operations to patch
+        addObjectPatchOperations(patch, operation, "/userPermissions",
+            permissions.userPermissions);
+
+        // Add system operations to patch
+        permissions.systemPermissions.forEach(function addSystemPatch(type) {
+            patch.push({
+                op    : operation,
+                path  : "/systemPermissions",
+                value : type
+            });
+        });
+
+    };
+            
+    /**
+     * Makes a request to the REST API to modify the permissions for a given
+     * user, returning a promise that can be used for processing the results of
+     * the call.
+     * 
+     * @param {String} dataSource
+     *     The unique identifier of the data source containing the user whose
+     *     permissions should be modified. This identifier corresponds to an
+     *     AuthenticationProvider within the Guacamole web application.
+     *
+     * @param {String} userID
+     *     The ID of the user to modify the permissions of.
+     *                          
+     * @param {PermissionSet} [permissionsToAdd]
+     *     The set of permissions to add, if any.
+     *
+     * @param {PermissionSet} [permissionsToRemove]
+     *     The set of permissions to remove, if any.
+     *                          
+     * @returns {Promise}
+     *     A promise for the HTTP call which will succeed if and only if the
+     *     patch operation is successful.
+     */
+    service.patchPermissions = function patchPermissions(dataSource, userID, permissionsToAdd, permissionsToRemove) {
+
+        var permissionPatch = [];
+        
+        // Build HTTP parameters set
+        var httpParameters = {
+            token : authenticationService.getCurrentToken()
+        };
+
+        // Add all the add operations to the patch
+        addPatchOperations(permissionPatch, PermissionPatch.Operation.ADD, permissionsToAdd);
+
+        // Add all the remove operations to the patch
+        addPatchOperations(permissionPatch, PermissionPatch.Operation.REMOVE, permissionsToRemove);
+
+        // Patch user permissions
+        return $http({
+            method  : 'PATCH', 
+            url     : 'api/data/' + encodeURIComponent(dataSource) + '/users/' + encodeURIComponent(userID) + '/permissions',
+            params  : httpParameters,
+            data    : permissionPatch
+        })
+        
+        // Clear the cache
+        .success(function permissionsPatched(){
+            cacheService.users.removeAll();
+        });
+    };
+    
+    return service;
+
+}]);
diff --git a/guacamole/src/main/webapp/app/rest/services/schemaService.js b/guacamole/src/main/webapp/app/rest/services/schemaService.js
new file mode 100644
index 0000000..2c3041f
--- /dev/null
+++ b/guacamole/src/main/webapp/app/rest/services/schemaService.js
@@ -0,0 +1,171 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Service for operating on metadata via the REST API.
+ */
+angular.module('rest').factory('schemaService', ['$injector',
+        function schemaService($injector) {
+
+    // Required services
+    var $http                 = $injector.get('$http');
+    var authenticationService = $injector.get('authenticationService');
+    var cacheService          = $injector.get('cacheService');
+
+    var service = {};
+
+    /**
+     * Makes a request to the REST API to get the list of available attributes
+     * for user objects, returning a promise that provides an array of
+     * @link{Form} objects if successful. Each element of the array describes
+     * a logical grouping of possible attributes.
+     *
+     * @param {String} dataSource
+     *     The unique identifier of the data source containing the users whose
+     *     available attributes are to be retrieved. This identifier
+     *     corresponds to an AuthenticationProvider within the Guacamole web
+     *     application.
+     *
+     * @returns {Promise.<Form[]>}
+     *     A promise which will resolve with an array of @link{Form}
+     *     objects, where each @link{Form} describes a logical grouping of
+     *     possible attributes.
+     */
+    service.getUserAttributes = function getUserAttributes(dataSource) {
+
+        // Build HTTP parameters set
+        var httpParameters = {
+            token : authenticationService.getCurrentToken()
+        };
+
+        // Retrieve available user attributes
+        return $http({
+            cache   : cacheService.schema,
+            method  : 'GET',
+            url     : 'api/schema/' + encodeURIComponent(dataSource) + '/users/attributes',
+            params  : httpParameters
+        });
+
+    };
+
+    /**
+     * Makes a request to the REST API to get the list of available attributes
+     * for connection objects, returning a promise that provides an array of
+     * @link{Form} objects if successful. Each element of the array describes
+     * a logical grouping of possible attributes.
+     *
+     * @param {String} dataSource
+     *     The unique identifier of the data source containing the connections
+     *     whose available attributes are to be retrieved. This identifier
+     *     corresponds to an AuthenticationProvider within the Guacamole web
+     *     application.
+     *
+     * @returns {Promise.<Form[]>}
+     *     A promise which will resolve with an array of @link{Form}
+     *     objects, where each @link{Form} describes a logical grouping of
+     *     possible attributes.
+     */
+    service.getConnectionAttributes = function getConnectionAttributes(dataSource) {
+
+        // Build HTTP parameters set
+        var httpParameters = {
+            token : authenticationService.getCurrentToken()
+        };
+
+        // Retrieve available connection attributes
+        return $http({
+            cache   : cacheService.schema,
+            method  : 'GET',
+            url     : 'api/schema/' + encodeURIComponent(dataSource) + '/connections/attributes',
+            params  : httpParameters
+        });
+
+    };
+
+    /**
+     * Makes a request to the REST API to get the list of available attributes
+     * for connection group objects, returning a promise that provides an array
+     * of @link{Form} objects if successful. Each element of the array
+     * a logical grouping of possible attributes.
+     *
+     * @param {String} dataSource
+     *     The unique identifier of the data source containing the connection
+     *     groups whose available attributes are to be retrieved. This
+     *     identifier corresponds to an AuthenticationProvider within the
+     *     Guacamole web application.
+     *
+     * @returns {Promise.<Form[]>}
+     *     A promise which will resolve with an array of @link{Form}
+     *     objects, where each @link{Form} describes a logical grouping of
+     *     possible attributes.
+     */
+    service.getConnectionGroupAttributes = function getConnectionGroupAttributes(dataSource) {
+
+        // Build HTTP parameters set
+        var httpParameters = {
+            token : authenticationService.getCurrentToken()
+        };
+
+        // Retrieve available connection group attributes
+        return $http({
+            cache   : cacheService.schema,
+            method  : 'GET',
+            url     : 'api/schema/' + encodeURIComponent(dataSource) + '/connectionGroups/attributes',
+            params  : httpParameters
+        });
+
+    };
+
+    /**
+     * Makes a request to the REST API to get the list of protocols, returning
+     * a promise that provides a map of @link{Protocol} objects by protocol
+     * name if successful.
+     *
+     * @param {String} dataSource
+     *     The unique identifier of the data source defining available
+     *     protocols. This identifier corresponds to an AuthenticationProvider
+     *     within the Guacamole web application.
+     *
+     * @returns {Promise.<Object.<String, Protocol>>}
+     *     A promise which will resolve with a map of @link{Protocol}
+     *     objects by protocol name upon success.
+     */
+    service.getProtocols = function getProtocols(dataSource) {
+
+        // Build HTTP parameters set
+        var httpParameters = {
+            token : authenticationService.getCurrentToken()
+        };
+
+        // Retrieve available protocols
+        return $http({
+            cache   : cacheService.schema,
+            method  : 'GET',
+            url     : 'api/schema/' + encodeURIComponent(dataSource) + '/protocols',
+            params  : httpParameters
+        });
+
+    };
+
+    return service;
+
+}]);
diff --git a/guacamole/src/main/webapp/app/rest/services/userService.js b/guacamole/src/main/webapp/app/rest/services/userService.js
new file mode 100644
index 0000000..e7b3a35
--- /dev/null
+++ b/guacamole/src/main/webapp/app/rest/services/userService.js
@@ -0,0 +1,278 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Service for operating on users via the REST API.
+ */
+angular.module('rest').factory('userService', ['$injector',
+        function userService($injector) {
+
+    // Required services
+    var $http                 = $injector.get('$http');
+    var $q                    = $injector.get('$q');
+    var authenticationService = $injector.get('authenticationService');
+    var cacheService          = $injector.get('cacheService');
+
+    // Get required types
+    var UserPasswordUpdate = $injector.get("UserPasswordUpdate");
+            
+    var service = {};
+    
+    /**
+     * Makes a request to the REST API to get the list of users,
+     * returning a promise that provides an array of @link{User} objects if
+     * successful.
+     * 
+     * @param {String} dataSource
+     *     The unique identifier of the data source containing the users to be
+     *     retrieved. This identifier corresponds to an AuthenticationProvider
+     *     within the Guacamole web application.
+     *
+     * @param {String[]} [permissionTypes]
+     *     The set of permissions to filter with. A user must have one or more
+     *     of these permissions for a user to appear in the result. 
+     *     If null, no filtering will be performed. Valid values are listed
+     *     within PermissionSet.ObjectType.
+     *                          
+     * @returns {Promise.<User[]>}
+     *     A promise which will resolve with an array of @link{User} objects
+     *     upon success.
+     */
+    service.getUsers = function getUsers(dataSource, permissionTypes) {
+
+        // Build HTTP parameters set
+        var httpParameters = {
+            token : authenticationService.getCurrentToken()
+        };
+
+        // Add permission filter if specified
+        if (permissionTypes)
+            httpParameters.permission = permissionTypes;
+
+        // Retrieve users
+        return $http({
+            cache   : cacheService.users,
+            method  : 'GET',
+            url     : 'api/data/' + encodeURIComponent(dataSource) + '/users',
+            params  : httpParameters
+        });
+
+    };
+
+    /**
+     * Makes a request to the REST API to get the user having the given
+     * username, returning a promise that provides the corresponding
+     * @link{User} if successful.
+     *
+     * @param {String} dataSource
+     *     The unique identifier of the data source containing the user to be
+     *     retrieved. This identifier corresponds to an AuthenticationProvider
+     *     within the Guacamole web application.
+     *
+     * @param {String} username
+     *     The username of the user to retrieve.
+     * 
+     * @returns {Promise.<User>}
+     *     A promise which will resolve with a @link{User} upon success.
+     */
+    service.getUser = function getUser(dataSource, username) {
+
+        // Build HTTP parameters set
+        var httpParameters = {
+            token : authenticationService.getCurrentToken()
+        };
+
+        // Retrieve user
+        return $http({
+            cache   : cacheService.users,
+            method  : 'GET',
+            url     : 'api/data/' + encodeURIComponent(dataSource) + '/users/' + encodeURIComponent(username),
+            params  : httpParameters
+        });
+
+    };
+    
+    /**
+     * Makes a request to the REST API to delete a user, returning a promise
+     * that can be used for processing the results of the call.
+     * 
+     * @param {String} dataSource
+     *     The unique identifier of the data source containing the user to be
+     *     deleted. This identifier corresponds to an AuthenticationProvider
+     *     within the Guacamole web application.
+     *
+     * @param {User} user
+     *     The user to delete.
+     *                          
+     * @returns {Promise}
+     *     A promise for the HTTP call which will succeed if and only if the
+     *     delete operation is successful.
+     */
+    service.deleteUser = function deleteUser(dataSource, user) {
+
+        // Build HTTP parameters set
+        var httpParameters = {
+            token : authenticationService.getCurrentToken()
+        };
+
+        // Delete user
+        return $http({
+            method  : 'DELETE',
+            url     : 'api/data/' + encodeURIComponent(dataSource) + '/users/' + encodeURIComponent(user.username),
+            params  : httpParameters
+        })
+
+        // Clear the cache
+        .success(function userDeleted(){
+            cacheService.users.removeAll();
+        });
+
+
+    };
+    
+    /**
+     * Makes a request to the REST API to create a user, returning a promise
+     * that can be used for processing the results of the call.
+     * 
+     * @param {String} dataSource
+     *     The unique identifier of the data source in which the user should be
+     *     created. This identifier corresponds to an AuthenticationProvider
+     *     within the Guacamole web application.
+     *
+     * @param {User} user
+     *     The user to create.
+     *                          
+     * @returns {Promise}
+     *     A promise for the HTTP call which will succeed if and only if the
+     *     create operation is successful.
+     */
+    service.createUser = function createUser(dataSource, user) {
+
+        // Build HTTP parameters set
+        var httpParameters = {
+            token : authenticationService.getCurrentToken()
+        };
+
+        // Create user
+        return $http({
+            method  : 'POST',
+            url     : 'api/data/' + encodeURIComponent(dataSource) + '/users',
+            params  : httpParameters,
+            data    : user
+        })
+
+        // Clear the cache
+        .success(function userCreated(){
+            cacheService.users.removeAll();
+        });
+
+    };
+    
+    /**
+     * Makes a request to the REST API to save a user, returning a promise that
+     * can be used for processing the results of the call.
+     * 
+     * @param {String} dataSource
+     *     The unique identifier of the data source containing the user to be
+     *     updated. This identifier corresponds to an AuthenticationProvider
+     *     within the Guacamole web application.
+     *
+     * @param {User} user
+     *     The user to update.
+     *                          
+     * @returns {Promise}
+     *     A promise for the HTTP call which will succeed if and only if the
+     *     save operation is successful.
+     */
+    service.saveUser = function saveUser(dataSource, user) {
+
+        // Build HTTP parameters set
+        var httpParameters = {
+            token : authenticationService.getCurrentToken()
+        };
+
+        // Update user
+        return $http({
+            method  : 'PUT',
+            url     : 'api/data/' + encodeURIComponent(dataSource) + '/users/' + encodeURIComponent(user.username),
+            params  : httpParameters,
+            data    : user
+        })
+
+        // Clear the cache
+        .success(function userUpdated(){
+            cacheService.users.removeAll();
+        });
+
+    };
+    
+    /**
+     * Makes a request to the REST API to update the password for a user, 
+     * returning a promise that can be used for processing the results of the call.
+     * 
+     * @param {String} dataSource
+     *     The unique identifier of the data source containing the user to be
+     *     updated. This identifier corresponds to an AuthenticationProvider
+     *     within the Guacamole web application.
+     *
+     * @param {String} username
+     *     The username of the user to update.
+     *     
+     * @param {String} oldPassword
+     *     The exiting password of the user to update.
+     *     
+     * @param {String} newPassword
+     *     The new password of the user to update.
+     *                          
+     * @returns {Promise}
+     *     A promise for the HTTP call which will succeed if and only if the
+     *     password update operation is successful.
+     */
+    service.updateUserPassword = function updateUserPassword(dataSource, username,
+            oldPassword, newPassword) {
+
+        // Build HTTP parameters set
+        var httpParameters = {
+            token : authenticationService.getCurrentToken()
+        };
+
+        // Update user password
+        return $http({
+            method  : 'PUT',
+            url     : 'api/data/' + encodeURIComponent(dataSource) + '/users/' + encodeURIComponent(username) + '/password',
+            params  : httpParameters,
+            data    : new UserPasswordUpdate({
+                oldPassword : oldPassword,
+                newPassword : newPassword
+            })
+        })
+
+        // Clear the cache
+        .success(function passwordChanged(){
+            cacheService.users.removeAll();
+        });
+
+    };
+    
+    return service;
+
+}]);
diff --git a/guacamole/src/main/webapp/app/rest/types/ActiveConnection.js b/guacamole/src/main/webapp/app/rest/types/ActiveConnection.js
new file mode 100644
index 0000000..d44b27d
--- /dev/null
+++ b/guacamole/src/main/webapp/app/rest/types/ActiveConnection.js
@@ -0,0 +1,86 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Service which defines the ActiveConnection class.
+ */
+angular.module('rest').factory('ActiveConnection', [function defineActiveConnection() {
+            
+    /**
+     * The object returned by REST API calls when representing the data
+     * associated with an active connection. Each active connection is
+     * effectively a pairing of a connection and the user currently using it,
+     * along with other information.
+     * 
+     * @constructor
+     * @param {ActiveConnection|Object} [template={}]
+     *     The object whose properties should be copied within the new
+     *     ActiveConnection.
+     */
+    var ActiveConnection = function ActiveConnection(template) {
+
+        // Use empty object by default
+        template = template || {};
+
+        /**
+         * The identifier which uniquely identifies this specific active
+         * connection.
+         * 
+         * @type String
+         */
+        this.identifier = template.identifier;
+
+        /**
+         * The identifier of the connection associated with this active
+         * connection.
+         *
+         * @type String
+         */
+        this.connectionIdentifier = template.connectionIdentifier;
+
+        /**
+         * The time that the connection began, in seconds since
+         * 1970-01-01 00:00:00 UTC, if known.
+         *
+         * @type Number 
+         */
+        this.startDate = template.startDate;
+
+        /**
+         * The remote host that initiated the connection, if known.
+         *
+         * @type String
+         */
+        this.remoteHost = template.remoteHost;
+
+        /**
+         * The username of the user associated with the connection, if known.
+         * 
+         * @type String
+         */
+        this.username = template.username;
+
+    };
+
+    return ActiveConnection;
+
+}]);
\ No newline at end of file
diff --git a/guacamole/src/main/webapp/app/rest/types/Connection.js b/guacamole/src/main/webapp/app/rest/types/Connection.js
new file mode 100644
index 0000000..488cfdc
--- /dev/null
+++ b/guacamole/src/main/webapp/app/rest/types/Connection.js
@@ -0,0 +1,105 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Service which defines the Connection class.
+ */
+angular.module('rest').factory('Connection', [function defineConnection() {
+            
+    /**
+     * The object returned by REST API calls when representing the data
+     * associated with a connection.
+     * 
+     * @constructor
+     * @param {Connection|Object} [template={}]
+     *     The object whose properties should be copied within the new
+     *     Connection.
+     */
+    var Connection = function Connection(template) {
+
+        // Use empty object by default
+        template = template || {};
+
+        /**
+         * The unique identifier associated with this connection.
+         *
+         * @type String
+         */
+        this.identifier = template.identifier;
+
+        /**
+         * The unique identifier of the connection group that contains this
+         * connection.
+         * 
+         * @type String
+         */
+        this.parentIdentifier = template.parentIdentifier;
+
+        /**
+         * The human-readable name of this connection, which is not necessarily
+         * unique.
+         * 
+         * @type String
+         */
+        this.name = template.name;
+
+        /**
+         * The name of the protocol associated with this connection, such as
+         * "vnc" or "rdp".
+         *
+         * @type String
+         */
+        this.protocol = template.protocol;
+
+        /**
+         * Connection configuration parameters, as dictated by the protocol in
+         * use, arranged as name/value pairs. This information may not be
+         * available until directly queried. If this information is
+         * unavailable, this property will be null or undefined.
+         *
+         * @type Object.<String, String>
+         */
+        this.parameters = template.parameters;
+
+        /**
+         * Arbitrary name/value pairs which further describe this connection.
+         * The semantics and validity of these attributes are dictated by the
+         * extension which defines them.
+         *
+         * @type Object.<String, String>
+         */
+        this.attributes = {};
+
+        /**
+         * The count of currently active connections using this connection.
+         * This field will be returned from the REST API during a get
+         * operation, but manually setting this field will have no effect.
+         * 
+         * @type Number
+         */
+        this.activeConnections = template.activeConnections;
+
+    };
+
+    return Connection;
+
+}]);
\ No newline at end of file
diff --git a/guacamole/src/main/webapp/app/rest/types/ConnectionGroup.js b/guacamole/src/main/webapp/app/rest/types/ConnectionGroup.js
new file mode 100644
index 0000000..7c3dc2b
--- /dev/null
+++ b/guacamole/src/main/webapp/app/rest/types/ConnectionGroup.js
@@ -0,0 +1,145 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Service which defines the ConnectionGroup class.
+ */
+angular.module('rest').factory('ConnectionGroup', [function defineConnectionGroup() {
+            
+    /**
+     * The object returned by REST API calls when representing the data
+     * associated with a connection group.
+     * 
+     * @constructor
+     * @param {ConnectionGroup|Object} [template={}]
+     *     The object whose properties should be copied within the new
+     *     ConnectionGroup.
+     */
+    var ConnectionGroup = function ConnectionGroup(template) {
+
+        // Use empty object by default
+        template = template || {};
+
+        /**
+         * The unique identifier associated with this connection group.
+         *
+         * @type String
+         */
+        this.identifier = template.identifier;
+
+        /**
+         * The unique identifier of the connection group that contains this
+         * connection group.
+         * 
+         * @type String
+         * @default ConnectionGroup.ROOT_IDENTIFIER
+         */
+        this.parentIdentifier = template.parentIdentifier || ConnectionGroup.ROOT_IDENTIFIER;
+
+        /**
+         * The human-readable name of this connection group, which is not
+         * necessarily unique.
+         * 
+         * @type String
+         */
+        this.name = template.name;
+
+        /**
+         * The type of this connection group, which may be either
+         * ConnectionGroup.Type.ORGANIZATIONAL or
+         * ConnectionGroup.Type.BALANCING.
+         * 
+         * @type String
+         * @default ConnectionGroup.Type.ORGANIZATIONAL
+         */
+        this.type = template.type || ConnectionGroup.Type.ORGANIZATIONAL;
+
+        /**
+         * An array of all child connections, if known. This property may be
+         * null or undefined if children have not been queried, and thus the
+         * child connections are unknown.
+         *
+         * @type Connection[]
+         */
+        this.childConnections = template.childConnections;
+
+        /**
+         * An array of all child connection groups, if known. This property may
+         * be null or undefined if children have not been queried, and thus the
+         * child connection groups are unknown.
+         *
+         * @type ConnectionGroup[]
+         */
+        this.childConnectionGroups = template.childConnectionGroups;
+
+        /**
+         * Arbitrary name/value pairs which further describe this connection
+         * group. The semantics and validity of these attributes are dictated
+         * by the extension which defines them.
+         *
+         * @type Object.<String, String>
+         */
+        this.attributes = {};
+
+        /**
+         * The count of currently active connections using this connection
+         * group. This field will be returned from the REST API during a get
+         * operation, but manually setting this field will have no effect.
+         * 
+         * @type Number
+         */
+        this.activeConnections = template.activeConnections;
+
+    };
+
+    /**
+     * The reserved identifier which always represents the root connection
+     * group.
+     * 
+     * @type String
+     */
+    ConnectionGroup.ROOT_IDENTIFIER = "ROOT";
+
+    /**
+     * All valid connection group types.
+     */
+    ConnectionGroup.Type = {
+
+        /**
+         * The type string associated with balancing connection groups.
+         *
+         * @type String
+         */
+        BALANCING : "BALANCING",
+
+        /**
+         * The type string associated with organizational connection groups.
+         *
+         * @type String
+         */
+        ORGANIZATIONAL : "ORGANIZATIONAL"
+
+    };
+
+    return ConnectionGroup;
+
+}]);
\ No newline at end of file
diff --git a/guacamole/src/main/webapp/app/rest/types/ConnectionHistoryEntry.js b/guacamole/src/main/webapp/app/rest/types/ConnectionHistoryEntry.js
new file mode 100644
index 0000000..355f808
--- /dev/null
+++ b/guacamole/src/main/webapp/app/rest/types/ConnectionHistoryEntry.js
@@ -0,0 +1,192 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Service which defines the ConnectionHistoryEntry class.
+ */
+angular.module('rest').factory('ConnectionHistoryEntry', [function defineConnectionHistoryEntry() {
+            
+    /**
+     * The object returned by REST API calls when representing the data
+     * associated with an entry in a connection's usage history. Each history
+     * entry represents the time at which a particular started using a
+     * connection and, if applicable, the time that usage stopped.
+     * 
+     * @constructor
+     * @param {ConnectionHistoryEntry|Object} [template={}]
+     *     The object whose properties should be copied within the new
+     *     ConnectionHistoryEntry.
+     */
+    var ConnectionHistoryEntry = function ConnectionHistoryEntry(template) {
+
+        // Use empty object by default
+        template = template || {};
+
+        /**
+         * The identifier of the connection associated with this history entry.
+         *
+         * @type String
+         */
+        this.connectionIdentifier = template.connectionIdentifier;
+
+        /**
+         * The name of the connection associated with this history entry.
+         *
+         * @type String
+         */
+        this.connectionName = template.connectionName;
+
+        /**
+         * The time that usage began, in seconds since 1970-01-01 00:00:00 UTC.
+         *
+         * @type Number 
+         */
+        this.startDate = template.startDate;
+
+        /**
+         * The time that usage ended, in seconds since 1970-01-01 00:00:00 UTC.
+         * The absence of an endDate does NOT necessarily indicate that the
+         * connection is still in use, particularly if the server was shutdown
+         * or restarted before the history entry could be updated. To determine
+         * whether a connection is still active, check the active property of
+         * this history entry.
+         * 
+         * @type Number 
+         */
+        this.endDate = template.endDate;
+
+        /**
+         * The remote host that initiated this connection, if known.
+         *
+         * @type String
+         */
+        this.remoteHost = template.remoteHost;
+
+        /**
+         * The username of the user associated with this particular usage of
+         * the connection.
+         * 
+         * @type String
+         */
+        this.username = template.username;
+
+        /**
+         * Whether this usage of the connection is still active. Note that this
+         * is the only accurate way to check for connection activity; the
+         * absence of endDate does not necessarily imply the connection is
+         * active, as the history entry may simply be incomplete.
+         * 
+         * @type Boolean
+         */
+        this.active = template.active;
+
+    };
+
+    /**
+     * All possible predicates for sorting ConnectionHistoryEntry objects using
+     * the REST API. By default, each predicate indicates ascending order. To
+     * indicate descending order, add "-" to the beginning of the predicate.
+     *
+     * @type Object.<String, String>
+     */
+    ConnectionHistoryEntry.SortPredicate = {
+
+        /**
+         * The date and time that the connection associated with the history
+         * entry began (connected).
+         */
+        START_DATE : 'startDate'
+
+    };
+
+    /**
+     * Value/unit pair representing the length of time that a connection was
+     * used.
+     * 
+     * @constructor
+     * @param {Number} milliseconds
+     *     The number of milliseconds that the associated connection was used.
+     */
+    ConnectionHistoryEntry.Duration = function Duration(milliseconds) {
+
+        /**
+         * The provided duration in seconds.
+         *
+         * @type Number
+         */
+        var seconds = milliseconds / 1000;
+
+        /**
+         * Rounds the given value to the nearest tenth.
+         *
+         * @param {Number} value The value to round.
+         * @returns {Number} The given value, rounded to the nearest tenth.
+         */
+        var round = function round(value) {
+            return Math.round(value * 10) / 10;
+        };
+
+        // Days
+        if (seconds >= 86400) {
+            this.value = round(seconds / 86400);
+            this.unit  = 'day';
+        }
+
+        // Hours
+        else if (seconds >= 3600) {
+            this.value = round(seconds / 3600);
+            this.unit  = 'hour';
+        }
+
+        // Minutes
+        else if (seconds >= 60) {
+            this.value = round(seconds / 60);
+            this.unit  = 'minute';
+        }
+        
+        // Seconds
+        else {
+
+            /**
+             * The number of seconds (or minutes, or hours, etc.) that the
+             * connection was used. The units associated with this value are
+             * represented by the unit property.
+             *
+             * @type Number
+             */
+            this.value = round(seconds);
+
+            /**
+             * The units associated with the value of this duration. Valid
+             * units are 'second', 'minute', 'hour', and 'day'.
+             *
+             * @type String
+             */
+            this.unit = 'second';
+
+        }
+
+    };
+
+    return ConnectionHistoryEntry;
+
+}]);
\ No newline at end of file
diff --git a/guacamole/src/main/webapp/app/rest/types/Error.js b/guacamole/src/main/webapp/app/rest/types/Error.js
new file mode 100644
index 0000000..fe2a599
--- /dev/null
+++ b/guacamole/src/main/webapp/app/rest/types/Error.js
@@ -0,0 +1,122 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Service which defines the Error class.
+ */
+angular.module('rest').factory('Error', [function defineError() {
+
+    /**
+     * The object returned by REST API calls when an error occurs.
+     *
+     * @constructor
+     * @param {Error|Object} [template={}]
+     *     The object whose properties should be copied within the new
+     *     Error.
+     */
+    var Error = function Error(template) {
+
+        // Use empty object by default
+        template = template || {};
+
+        /**
+         * A human-readable message describing the error that occurred.
+         *
+         * @type String
+         */
+        this.message = template.message;
+
+        /**
+         * The type string defining which values this parameter may contain,
+         * as well as what properties are applicable. Valid types are listed
+         * within Error.Type.
+         *
+         * @type String
+         * @default Error.Type.INTERNAL_ERROR
+         */
+        this.type = template.type || Error.Type.INTERNAL_ERROR;
+
+        /**
+         * Any parameters which were expected in the original request, or are
+         * now expected as a result of the original request, if any. If no
+         * such information is available, this will be null.
+         *
+         * @type Field[]
+         */
+        this.expected = template.expected;
+
+    };
+
+    /**
+     * All valid field types.
+     */
+    Error.Type = {
+
+        /**
+         * The requested operation could not be performed because the request
+         * itself was malformed.
+         *
+         * @type String
+         */
+        BAD_REQUEST : 'BAD_REQUEST',
+
+        /**
+         * The credentials provided were invalid.
+         *
+         * @type String
+         */
+        INVALID_CREDENTIALS : 'INVALID_CREDENTIALS',
+
+        /**
+         * The credentials provided were not necessarily invalid, but were not
+         * sufficient to determine validity.
+         *
+         * @type String
+         */
+        INSUFFICIENT_CREDENTIALS : 'INSUFFICIENT_CREDENTIALS',
+
+        /**
+         * An internal server error has occurred.
+         *
+         * @type String
+         */
+        INTERNAL_ERROR : 'INTERNAL_ERROR',
+
+        /**
+         * An object related to the request does not exist.
+         *
+         * @type String
+         */
+        NOT_FOUND : 'NOT_FOUND',
+
+        /**
+         * Permission was denied to perform the requested operation.
+         *
+         * @type String
+         */
+        PERMISSION_DENIED : 'PERMISSION_DENIED'
+
+    };
+
+    return Error;
+
+}]);
\ No newline at end of file
diff --git a/guacamole/src/main/webapp/app/rest/types/Field.js b/guacamole/src/main/webapp/app/rest/types/Field.js
new file mode 100644
index 0000000..3474ef4
--- /dev/null
+++ b/guacamole/src/main/webapp/app/rest/types/Field.js
@@ -0,0 +1,164 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Service which defines the Field class.
+ */
+angular.module('rest').factory('Field', [function defineField() {
+            
+    /**
+     * The object returned by REST API calls when representing the data
+     * associated with a field or configuration parameter.
+     * 
+     * @constructor
+     * @param {Field|Object} [template={}]
+     *     The object whose properties should be copied within the new
+     *     Field.
+     */
+    var Field = function Field(template) {
+
+        // Use empty object by default
+        template = template || {};
+
+        /**
+         * The name which uniquely identifies this parameter.
+         *
+         * @type String
+         */
+        this.name = template.name;
+
+        /**
+         * The type string defining which values this parameter may contain,
+         * as well as what properties are applicable. Valid types are listed
+         * within Field.Type.
+         *
+         * @type String
+         * @default Field.Type.TEXT
+         */
+        this.type = template.type || Field.Type.TEXT;
+
+        /**
+         * All possible legal values for this parameter.
+         *
+         * @type String[]
+         */
+        this.options = template.options;
+
+    };
+
+    /**
+     * All valid field types.
+     */
+    Field.Type = {
+
+        /**
+         * The type string associated with parameters that may contain a single
+         * line of arbitrary text.
+         *
+         * @type String
+         */
+        TEXT : "TEXT",
+
+        /**
+         * The type string associated with parameters that may contain an
+         * arbitrary string, where that string represents the username of the
+         * user authenticating with the remote desktop service.
+         * 
+         * @type String
+         */
+        USERNAME : "USERNAME",
+
+        /**
+         * The type string associated with parameters that may contain an
+         * arbitrary string, where that string represents the password of the
+         * user authenticating with the remote desktop service.
+         * 
+         * @type String
+         */
+        PASSWORD : "PASSWORD",
+
+        /**
+         * The type string associated with parameters that may contain only
+         * numeric values.
+         * 
+         * @type String
+         */
+        NUMERIC : "NUMERIC",
+
+        /**
+         * The type string associated with parameters that may contain only a
+         * single possible value, where that value enables the parameter's
+         * effect. It is assumed that each BOOLEAN field will provide exactly
+         * one possible value (option), which will be the value if that field
+         * is true.
+         * 
+         * @type String
+         */
+        BOOLEAN : "BOOLEAN",
+
+        /**
+         * The type string associated with parameters that may contain a
+         * strictly-defined set of possible values.
+         * 
+         * @type String
+         */
+        ENUM : "ENUM",
+
+        /**
+         * The type string associated with parameters that may contain any
+         * number of lines of arbitrary text.
+         *
+         * @type String
+         */
+        MULTILINE : "MULTILINE",
+
+        /**
+         * The type string associated with parameters that may contain timezone
+         * IDs. Valid timezone IDs are dictated by Java:
+         * http://docs.oracle.com/javase/7/docs/api/java/util/TimeZone.html#getAvailableIDs%28%29
+         *
+         * @type String
+         */
+        TIMEZONE : "TIMEZONE",
+
+        /**
+         * The type string associated with parameters that may contain dates.
+         * The format of the date is standardized as YYYY-MM-DD, zero-padded.
+         *
+         * @type String
+         */
+        DATE : "DATE",
+
+        /**
+         * The type string associated with parameters that may contain times.
+         * The format of the time is stnadardized as HH:MM:DD, zero-padded,
+         * 24-hour.
+         *
+         * @type String
+         */
+        TIME : "TIME"
+
+    };
+
+    return Field;
+
+}]);
\ No newline at end of file
diff --git a/guacamole/src/main/webapp/app/rest/types/Form.js b/guacamole/src/main/webapp/app/rest/types/Form.js
new file mode 100644
index 0000000..d736044
--- /dev/null
+++ b/guacamole/src/main/webapp/app/rest/types/Form.js
@@ -0,0 +1,61 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Service which defines the Form class.
+ */
+angular.module('rest').factory('Form', [function defineForm() {
+
+    /**
+     * The object returned by REST API calls when representing the data
+     * associated with a form or set of configuration parameters.
+     *
+     * @constructor
+     * @param {Form|Object} [template={}]
+     *     The object whose properties should be copied within the new
+     *     Form.
+     */
+    var Form = function Form(template) {
+
+        // Use empty object by default
+        template = template || {};
+
+        /**
+         * The name which uniquely identifies this form, or null if this form
+         * has no name.
+         *
+         * @type String
+         */
+        this.name = template.name;
+
+        /**
+         * All fields contained within this form.
+         *
+         * @type Field[]
+         */
+        this.fields = template.fields || [];
+
+    };
+
+    return Form;
+
+}]);
diff --git a/guacamole/src/main/webapp/app/rest/types/PermissionFlagSet.js b/guacamole/src/main/webapp/app/rest/types/PermissionFlagSet.js
new file mode 100644
index 0000000..5985948
--- /dev/null
+++ b/guacamole/src/main/webapp/app/rest/types/PermissionFlagSet.js
@@ -0,0 +1,206 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * A service for defining the PermissionFlagSet class.
+ */
+angular.module('rest').factory('PermissionFlagSet', ['PermissionSet',
+    function definePermissionFlagSet(PermissionSet) {
+
+    /**
+     * Alternative view of a @link{PermissionSet} which allows manipulation of
+     * each permission through the setting (or retrieval) of boolean property
+     * values.
+     * 
+     * @constructor
+     * @param {PermissionFlagSet|Object} template 
+     *     The object whose properties should be copied within the new
+     *     PermissionFlagSet.
+     */
+    var PermissionFlagSet = function PermissionFlagSet(template) {
+
+        // Use empty object by default
+        template = template || {};
+
+        /**
+         * The granted state of each system permission, as a map of system
+         * permission type string to boolean value. A particular permission is
+         * granted if its corresponding boolean value is set to true. Valid
+         * permission type strings are defined within
+         * PermissionSet.SystemPermissionType. Permissions which are not
+         * granted may be set to false, but this is not required.
+         * 
+         * @type Object.<String, Boolean>
+         */
+        this.systemPermissions = template.systemPermissions || {};
+
+        /**
+         * The granted state of each permission for each connection, as a map
+         * of object permission type string to permission map. The permission
+         * map is, in turn, a map of connection identifier to boolean value. A
+         * particular permission is granted if its corresponding boolean value
+         * is set to true. Valid permission type strings are defined within
+         * PermissionSet.ObjectPermissionType. Permissions which are not
+         * granted may be set to false, but this is not required.
+         * 
+         * @type Object.<String, Object.<String, Boolean>>
+         */
+        this.connectionPermissions = template.connectionPermissions || {
+            'READ'       : {},
+            'UPDATE'     : {},
+            'DELETE'     : {},
+            'ADMINISTER' : {}
+        };
+
+        /**
+         * The granted state of each permission for each connection group, as a
+         * map of object permission type string to permission map. The
+         * permission map is, in turn, a map of connection group identifier to
+         * boolean value. A particular permission is granted if its
+         * corresponding boolean value is set to true. Valid permission type
+         * strings are defined within PermissionSet.ObjectPermissionType.
+         * Permissions which are not granted may be set to false, but this is
+         * not required.
+         * 
+         * @type Object.<String, Object.<String, Boolean>>
+         */
+        this.connectionGroupPermissions = template.connectionGroupPermissions || {
+            'READ'       : {},
+            'UPDATE'     : {},
+            'DELETE'     : {},
+            'ADMINISTER' : {}
+        };
+
+        /**
+         * The granted state of each permission for each active connection, as
+         * a map of object permission type string to permission map. The
+         * permission map is, in turn, a map of active connection identifier to
+         * boolean value. A particular permission is granted if its
+         * corresponding boolean value is set to true. Valid permission type
+         * strings are defined within PermissionSet.ObjectPermissionType.
+         * Permissions which are not granted may be set to false, but this is
+         * not required.
+         * 
+         * @type Object.<String, Object.<String, Boolean>>
+         */
+        this.activeConnectionPermissions = template.activeConnectionPermissions || {
+            'READ'       : {},
+            'UPDATE'     : {},
+            'DELETE'     : {},
+            'ADMINISTER' : {}
+        };
+
+        /**
+         * The granted state of each permission for each user, as a map of
+         * object permission type string to permission map. The permission map
+         * is, in turn, a map of username to boolean value. A particular
+         * permission is granted if its corresponding boolean value is set to
+         * true. Valid permission type strings are defined within
+         * PermissionSet.ObjectPermissionType. Permissions which are not
+         * granted may be set to false, but this is not required.
+         * 
+         * @type Object.<String, Object.<String, Boolean>>
+         */
+        this.userPermissions = template.userPermissions || {
+            'READ'       : {},
+            'UPDATE'     : {},
+            'DELETE'     : {},
+            'ADMINISTER' : {}
+        };
+
+    };
+
+    /**
+     * Iterates through all permissions in the given permission map, setting
+     * the corresponding permission flags in the given permission flag map.
+     *
+     * @param {Object.<String, String[]>} permMap
+     *     Map of object identifiers to the set of granted permissions. Each
+     *     permission is represented by a string listed within
+     *     PermissionSet.ObjectPermissionType.
+     *
+     * @param {Object.<String, Object.<String, Boolean>>} flagMap
+     *     Map of permission type strings to identifier/flag pairs representing
+     *     whether the permission of that type is granted for the object having
+     *     having the associated identifier.
+     */
+    var addObjectPermissions = function addObjectPermissions(permMap, flagMap) {
+
+        // For each defined identifier in the permission map
+        for (var identifier in permMap) {
+
+            // Pull the permission array and loop through each permission
+            var permissions = permMap[identifier];
+            permissions.forEach(function addObjectPermission(type) {
+
+                // Get identifier/flag mapping, creating first if necessary
+                var objectFlags = flagMap[type] = flagMap[type] || {};
+
+                // Set flag for current permission
+                objectFlags[identifier] = true;
+
+            });
+
+        }
+
+    };
+
+    /**
+     * Creates a new PermissionFlagSet, populating it with all the permissions
+     * indicated as granted within the given PermissionSet.
+     *
+     * @param {PermissionSet} permissionSet
+     *     The PermissionSet containing the permissions to be copied into a new
+     *     PermissionFlagSet.
+     *
+     * @returns {PermissionFlagSet}
+     *     A new PermissionFlagSet containing flags representing all granted
+     *     permissions from the given PermissionSet.
+     */
+    PermissionFlagSet.fromPermissionSet = function fromPermissionSet(permissionSet) {
+
+        var permissionFlagSet = new PermissionFlagSet();
+
+        // Add all granted system permissions
+        permissionSet.systemPermissions.forEach(function addSystemPermission(type) {
+            permissionFlagSet.systemPermissions[type] = true;
+        });
+
+        // Add all granted connection permissions
+        addObjectPermissions(permissionSet.connectionPermissions, permissionFlagSet.connectionPermissions);
+
+        // Add all granted connection group permissions
+        addObjectPermissions(permissionSet.connectionGroupPermissions, permissionFlagSet.connectionGroupPermissions);
+
+        // Add all granted active connection permissions
+        addObjectPermissions(permissionSet.activeConnectionPermissions, permissionFlagSet.activeConnectionPermissions);
+
+        // Add all granted user permissions
+        addObjectPermissions(permissionSet.userPermissions, permissionFlagSet.userPermissions);
+
+        return permissionFlagSet;
+
+    };
+
+    return PermissionFlagSet;
+
+}]);
\ No newline at end of file
diff --git a/guacamole/src/main/webapp/app/rest/types/PermissionPatch.js b/guacamole/src/main/webapp/app/rest/types/PermissionPatch.js
new file mode 100644
index 0000000..9f3aa88
--- /dev/null
+++ b/guacamole/src/main/webapp/app/rest/types/PermissionPatch.js
@@ -0,0 +1,94 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Service which defines the PermissionPatch class.
+ */
+angular.module('rest').factory('PermissionPatch', [function definePermissionPatch() {
+            
+    /**
+     * The object returned by REST API calls when representing changes to the
+     * permissions granted to a specific user.
+     * 
+     * @constructor
+     * @param {PermissionPatch|Object} [template={}]
+     *     The object whose properties should be copied within the new
+     *     PermissionPatch.
+     */
+    var PermissionPatch = function PermissionPatch(template) {
+
+        // Use empty object by default
+        template = template || {};
+
+        /**
+         * The operation to apply to the permissions indicated by the path.
+         * Valid operation values are defined within PermissionPatch.Operation.
+         *
+         * @type String
+         */
+        this.op = template.op;
+
+        /**
+         * The path of the permissions to modify. Depending on the type of the
+         * permission, this will be either "/connectionPermissions/ID",
+         * "/connectionGroupPermissions/ID", "/userPermissions/ID", or
+         * "/systemPermissions", where "ID" is the identifier of the object
+         * to which the permissions apply, if any.
+         *
+         * @type String
+         */
+        this.path = template.path;
+
+        /**
+         * The permissions being added or removed. If the permission applies to
+         * an object, such as a connection or connection group, this will be a
+         * value from PermissionSet.ObjectPermissionType. If the permission
+         * applies to the system as a whole (the path is "/systemPermissions"),
+         * this will be a value from PermissionSet.SystemPermissionType.
+         *
+         * @type String
+         */
+        this.value = template.value;
+
+    };
+
+    /**
+     * All valid patch operations for permissions. Currently, only add and
+     * remove are supported.
+     */
+    PermissionPatch.Operation = {
+
+        /**
+         * Adds (grants) the specified permission.
+         */
+        ADD : "add",
+
+        /**
+         * Removes (revokes) the specified permission.
+         */
+        REMOVE : "remove"
+
+    };
+
+    return PermissionPatch;
+
+}]);
\ No newline at end of file
diff --git a/guacamole/src/main/webapp/app/rest/types/PermissionSet.js b/guacamole/src/main/webapp/app/rest/types/PermissionSet.js
new file mode 100644
index 0000000..c7e9237
--- /dev/null
+++ b/guacamole/src/main/webapp/app/rest/types/PermissionSet.js
@@ -0,0 +1,655 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Service which defines the PermissionSet class.
+ */
+angular.module('rest').factory('PermissionSet', [function definePermissionSet() {
+            
+    /**
+     * The object returned by REST API calls when representing the permissions
+     * granted to a specific user.
+     * 
+     * @constructor
+     * @param {PermissionSet|Object} [template={}]
+     *     The object whose properties should be copied within the new
+     *     PermissionSet.
+     */
+    var PermissionSet = function PermissionSet(template) {
+
+        // Use empty object by default
+        template = template || {};
+
+        /**
+         * Map of connection identifiers to the corresponding array of granted
+         * permissions. Each permission is represented by a string listed
+         * within PermissionSet.ObjectPermissionType.
+         *
+         * @type Object.<String, String[]>
+         */
+        this.connectionPermissions = template.connectionPermissions || {};
+
+        /**
+         * Map of connection group identifiers to the corresponding array of
+         * granted permissions. Each permission is represented by a string
+         * listed within PermissionSet.ObjectPermissionType.
+         *
+         * @type Object.<String, String[]>
+         */
+        this.connectionGroupPermissions = template.connectionGroupPermissions || {};
+        
+        /**
+         * Map of active connection identifiers to the corresponding array of
+         * granted permissions. Each permission is represented by a string
+         * listed within PermissionSet.ObjectPermissionType.
+         *
+         * @type Object.<String, String[]>
+         */
+        this.activeConnectionPermissions = template.activeConnectionPermissions || {};
+        
+        /**
+         * Map of user identifiers to the corresponding array of granted
+         * permissions. Each permission is represented by a string listed
+         * within PermissionSet.ObjectPermissionType.
+         *
+         * @type Object.<String, String[]>
+         */
+        this.userPermissions = template.userPermissions || {};
+
+        /**
+         * Array of granted system permissions. Each permission is represented
+         * by a string listed within PermissionSet.SystemPermissionType.
+         *
+         * @type String[]
+         */
+        this.systemPermissions = template.systemPermissions || [];
+
+    };
+
+    /**
+     * Valid object permission type strings.
+     */
+    PermissionSet.ObjectPermissionType = {
+
+        /**
+         * Permission to read from the specified object.
+         */
+        READ : "READ",
+
+        /**
+         * Permission to update the specified object.
+         */
+        UPDATE : "UPDATE",
+
+        /**
+         * Permission to delete the specified object.
+         */
+        DELETE : "DELETE",
+
+        /**
+         * Permission to administer the specified object
+         */
+        ADMINISTER : "ADMINISTER"
+
+    };
+
+    /**
+     * Valid system permission type strings.
+     */
+    PermissionSet.SystemPermissionType = {
+
+        /**
+         * Permission to administer the entire system.
+         */
+        ADMINISTER : "ADMINISTER",
+
+        /**
+         * Permission to create new users.
+         */
+        CREATE_USER : "CREATE_USER",
+
+        /**
+         * Permission to create new connections.
+         */
+        CREATE_CONNECTION : "CREATE_CONNECTION",
+
+        /**
+         * Permission to create new connection groups.
+         */
+        CREATE_CONNECTION_GROUP : "CREATE_CONNECTION_GROUP"
+
+    };
+
+    /**
+     * Returns whether the given permission is granted for at least one
+     * arbitrary object, regardless of ID.
+     *
+     * @param {Object.<String, String[]>} permMap
+     *     The permission map to check, where each entry maps an object
+     *     identifer to the array of granted permissions.
+     *
+     * @param {String} type
+     *     The permission to search for, as defined by
+     *     PermissionSet.ObjectPermissionType.
+     *     
+     * @returns {Boolean}
+     *     true if the permission is present (granted), false otherwise.
+     */
+    var containsPermission = function containsPermission(permMap, type) {
+
+        // Search all identifiers for given permission
+        for (var identifier in permMap) {
+
+            // If permission is granted, then no further searching is necessary
+            if (permMap[identifier].indexOf(type) !== -1)
+                return true;
+
+        }
+
+        // No such permission exists
+        return false;
+
+    };
+
+    /**
+     * Returns whether the given permission is granted for the arbitrary
+     * object having the given ID. If no ID is given, this function determines
+     * whether the permission is granted at all for any such arbitrary object.
+     *
+     * @param {Object.<String, String[]>} permMap
+     *     The permission map to check, where each entry maps an object
+     *     identifer to the array of granted permissions.
+     *
+     * @param {String} type
+     *     The permission to search for, as defined by
+     *     PermissionSet.ObjectPermissionType.
+     *     
+     * @param {String} [identifier]
+     *     The identifier of the object to which the permission applies.
+     *
+     * @returns {Boolean}
+     *     true if the permission is present (granted), false otherwise.
+     */
+    var hasPermission = function hasPermission(permMap, type, identifier) {
+
+        // No permission if no permission map at all
+        if (!permMap)
+            return false;
+
+        // If no identifier given, search ignoring the identifier
+        if (!identifier)
+            return containsPermission(permMap, type);
+
+        // If identifier not present at all, there are no such permissions
+        if (!(identifier in permMap))
+            return false;
+
+        return permMap[identifier].indexOf(type) !== -1;
+
+    };
+
+    /**
+     * Returns whether the given permission is granted for the connection
+     * having the given ID.
+     *
+     * @param {PermissionSet|Object} permSet
+     *     The permission set to check.
+     *
+     * @param {String} type
+     *     The permission to search for, as defined by
+     *     PermissionSet.ObjectPermissionType.
+     *     
+     * @param {String} identifier
+     *     The identifier of the connection to which the permission applies.
+     *
+     * @returns {Boolean}
+     *     true if the permission is present (granted), false otherwise.
+     */
+    PermissionSet.hasConnectionPermission = function hasConnectionPermission(permSet, type, identifier) {
+        return hasPermission(permSet.connectionPermissions, type, identifier);
+    };
+
+    /**
+     * Returns whether the given permission is granted for the connection group
+     * having the given ID.
+     *
+     * @param {PermissionSet|Object} permSet
+     *     The permission set to check.
+     *
+     * @param {String} type
+     *     The permission to search for, as defined by
+     *     PermissionSet.ObjectPermissionType.
+     *     
+     * @param {String} identifier
+     *     The identifier of the connection group to which the permission
+     *     applies.
+     *
+     * @returns {Boolean}
+     *     true if the permission is present (granted), false otherwise.
+     */
+    PermissionSet.hasConnectionGroupPermission = function hasConnectionGroupPermission(permSet, type, identifier) {
+        return hasPermission(permSet.connectionGroupPermissions, type, identifier);
+    };
+
+    /**
+     * Returns whether the given permission is granted for the active
+     * connection having the given ID.
+     *
+     * @param {PermissionSet|Object} permSet
+     *     The permission set to check.
+     *
+     * @param {String} type
+     *     The permission to search for, as defined by
+     *     PermissionSet.ObjectPermissionType.
+     *     
+     * @param {String} identifier
+     *     The identifier of the active connection to which the permission
+     *     applies.
+     *
+     * @returns {Boolean}
+     *     true if the permission is present (granted), false otherwise.
+     */
+    PermissionSet.hasActiveConnectionPermission = function hasActiveConnectionPermission(permSet, type, identifier) {
+        return hasPermission(permSet.activeConnectionPermissions, type, identifier);
+    };
+
+    /**
+     * Returns whether the given permission is granted for the user having the 
+     * given ID.
+     *
+     * @param {PermissionSet|Object} permSet
+     *     The permission set to check.
+     *
+     * @param {String} type
+     *     The permission to search for, as defined by
+     *     PermissionSet.ObjectPermissionType.
+     *     
+     * @param {String} identifier
+     *     The identifier of the user to which the permission applies.
+     *
+     * @returns {Boolean}
+     *     true if the permission is present (granted), false otherwise.
+     */
+    PermissionSet.hasUserPermission = function hasUserPermission(permSet, type, identifier) {
+        return hasPermission(permSet.userPermissions, type, identifier);
+    };
+
+    /**
+     * Returns whether the given permission is granted at the system level.
+     *
+     * @param {PermissionSet|Object} permSet
+     *     The permission set to check.
+     *
+     * @param {String} type
+     *     The permission to search for, as defined by
+     *     PermissionSet.SystemPermissionType.
+     *
+     * @returns {Boolean}
+     *     true if the permission is present (granted), false otherwise.
+     */
+    PermissionSet.hasSystemPermission = function hasSystemPermission(permSet, type) {
+        if (!permSet.systemPermissions) return false;
+        return permSet.systemPermissions.indexOf(type) !== -1;
+    };
+
+    /**
+     * Adds the given system permission to the given permission set, if not
+     * already present. If the permission is already present, this function has
+     * no effect.
+     *
+     * @param {PermissionSet} permSet
+     *     The permission set to modify.
+     *
+     * @param {String} type
+     *     The permission to add, as defined by
+     *     PermissionSet.SystemPermissionType.
+     *
+     * @returns {Boolean}
+     *     true if the permission was added, false if the permission was
+     *     already present in the given permission set.
+     */
+    PermissionSet.addSystemPermission = function addSystemPermission(permSet, type) {
+
+        permSet.systemPermissions = permSet.systemPermissions || [];
+
+        // Add permission, if it doesn't already exist
+        if (permSet.systemPermissions.indexOf(type) === -1) {
+            permSet.systemPermissions.push(type);
+            return true;
+        }
+
+        // Permission already present
+        return false;
+
+    };
+
+    /**
+     * Removes the given system permission from the given permission set, if
+     * present. If the permission is not present, this function has no effect.
+     *
+     * @param {PermissionSet} permSet
+     *     The permission set to modify.
+     *
+     * @param {String} type
+     *     The permission to remove, as defined by
+     *     PermissionSet.SystemPermissionType.
+     *
+     * @returns {Boolean}
+     *     true if the permission was removed, false if the permission was not
+     *     present in the given permission set.
+     */
+    PermissionSet.removeSystemPermission = function removeSystemPermission(permSet, type) {
+
+        permSet.systemPermissions = permSet.systemPermissions || [];
+
+        // Remove permission, if it exists
+        var permLocation = permSet.systemPermissions.indexOf(type);
+        if (permLocation !== -1) {
+            permSet.systemPermissions.splice(permLocation, 1);
+            return true;
+        }
+
+        // Permission not present
+        return false;
+
+    };
+
+    /**
+     * Adds the given permission applying to the arbitrary object with the 
+     * given ID to the given permission set, if not already present. If the
+     * permission is already present, this function has no effect.
+     *
+     * @param {Object.<String, String[]>} permMap
+     *     The permission map to modify, where each entry maps an object
+     *     identifer to the array of granted permissions.
+     *
+     * @param {String} type
+     *     The permission to add, as defined by
+     *     PermissionSet.ObjectPermissionType.
+     *
+     * @param {String} identifier
+     *     The identifier of the arbitrary object to which the permission
+     *     applies.
+     *
+     * @returns {Boolean}
+     *     true if the permission was added, false if the permission was
+     *     already present in the given permission set.
+     */
+    var addObjectPermission = function addObjectPermission(permMap, type, identifier) {
+
+        // Pull array of permissions, creating it if necessary
+        var permArray = permMap[identifier] = permMap[identifier] || [];
+
+        // Add permission, if it doesn't already exist
+        if (permArray.indexOf(type) === -1) {
+            permArray.push(type);
+            return true;
+        }
+
+        // Permission already present
+        return false;
+
+    };
+
+    /**
+     * Removes the given permission applying to the arbitrary object with the 
+     * given ID from the given permission set, if present. If the permission is
+     * not present, this function has no effect.
+     *
+     * @param {Object.<String, String[]>} permMap
+     *     The permission map to modify, where each entry maps an object
+     *     identifer to the array of granted permissions.
+     *
+     * @param {String} type
+     *     The permission to remove, as defined by
+     *     PermissionSet.ObjectPermissionType.
+     *
+     * @param {String} identifier
+     *     The identifier of the arbitrary object to which the permission
+     *     applies.
+     *
+     * @returns {Boolean}
+     *     true if the permission was removed, false if the permission was not
+     *     present in the given permission set.
+     */
+    var removeObjectPermission = function removeObjectPermission(permMap, type, identifier) {
+
+        // Pull array of permissions
+        var permArray = permMap[identifier];
+
+        // If no permissions present at all, nothing to remove
+        if (!(identifier in permMap))
+            return false;
+
+        // Remove permission, if it exists
+        var permLocation = permArray.indexOf(type);
+        if (permLocation !== -1) {
+            permArray.splice(permLocation, 1);
+            return true;
+        }
+
+        // Permission not present
+        return false;
+
+    };
+
+    /**
+     * Adds the given connection permission applying to the connection with
+     * the given ID to the given permission set, if not already present. If the
+     * permission is already present, this function has no effect.
+     *
+     * @param {PermissionSet} permSet
+     *     The permission set to modify.
+     *
+     * @param {String} type
+     *     The permission to add, as defined by
+     *     PermissionSet.ObjectPermissionType.
+     *
+     * @param {String} identifier
+     *     The identifier of the connection to which the permission applies.
+     *
+     * @returns {Boolean}
+     *     true if the permission was added, false if the permission was
+     *     already present in the given permission set.
+     */
+    PermissionSet.addConnectionPermission = function addConnectionPermission(permSet, type, identifier) {
+        permSet.connectionPermissions = permSet.connectionPermissions || {};
+        return addObjectPermission(permSet.connectionPermissions, type, identifier);
+    };
+
+    /**
+     * Removes the given connection permission applying to the connection with
+     * the given ID from the given permission set, if present. If the
+     * permission is not present, this function has no effect.
+     *
+     * @param {PermissionSet} permSet
+     *     The permission set to modify.
+     *
+     * @param {String} type
+     *     The permission to remove, as defined by
+     *     PermissionSet.ObjectPermissionType.
+     *
+     * @param {String} identifier
+     *     The identifier of the connection to which the permission applies.
+     *
+     * @returns {Boolean}
+     *     true if the permission was removed, false if the permission was not
+     *     present in the given permission set.
+     */
+    PermissionSet.removeConnectionPermission = function removeConnectionPermission(permSet, type, identifier) {
+        permSet.connectionPermissions = permSet.connectionPermissions || {};
+        return removeObjectPermission(permSet.connectionPermissions, type, identifier);
+    };
+
+    /**
+     * Adds the given connection group permission applying to the connection
+     * group with the given ID to the given permission set, if not already
+     * present. If the permission is already present, this function has no
+     * effect.
+     *
+     * @param {PermissionSet} permSet
+     *     The permission set to modify.
+     *
+     * @param {String} type
+     *     The permission to add, as defined by
+     *     PermissionSet.ObjectPermissionType.
+     *
+     * @param {String} identifier
+     *     The identifier of the connection group to which the permission
+     *     applies.
+     *
+     * @returns {Boolean}
+     *     true if the permission was added, false if the permission was
+     *     already present in the given permission set.
+     */
+    PermissionSet.addConnectionGroupPermission = function addConnectionGroupPermission(permSet, type, identifier) {
+        permSet.connectionGroupPermissions = permSet.connectionGroupPermissions || {};
+        return addObjectPermission(permSet.connectionGroupPermissions, type, identifier);
+    };
+
+    /**
+     * Removes the given connection group permission applying to the connection
+     * group with the given ID from the given permission set, if present. If
+     * the permission is not present, this function has no effect.
+     *
+     * @param {PermissionSet} permSet
+     *     The permission set to modify.
+     *
+     * @param {String} type
+     *     The permission to remove, as defined by
+     *     PermissionSet.ObjectPermissionType.
+     *
+     * @param {String} identifier
+     *     The identifier of the connection group to which the permission
+     *     applies.
+     *
+     * @returns {Boolean}
+     *     true if the permission was removed, false if the permission was not
+     *     present in the given permission set.
+     */
+    PermissionSet.removeConnectionGroupPermission = function removeConnectionGroupPermission(permSet, type, identifier) {
+        permSet.connectionGroupPermissions = permSet.connectionGroupPermissions || {};
+        return removeObjectPermission(permSet.connectionGroupPermissions, type, identifier);
+    };
+
+    /**
+     * Adds the given active connection permission applying to the connection
+     * group with the given ID to the given permission set, if not already
+     * present. If the permission is already present, this function has no
+     * effect.
+     *
+     * @param {PermissionSet} permSet
+     *     The permission set to modify.
+     *
+     * @param {String} type
+     *     The permission to add, as defined by
+     *     PermissionSet.ObjectPermissionType.
+     *
+     * @param {String} identifier
+     *     The identifier of the active connection to which the permission
+     *     applies.
+     *
+     * @returns {Boolean}
+     *     true if the permission was added, false if the permission was
+     *     already present in the given permission set.
+     */
+    PermissionSet.addActiveConnectionPermission = function addActiveConnectionPermission(permSet, type, identifier) {
+        permSet.activeConnectionPermissions = permSet.activeConnectionPermissions || {};
+        return addObjectPermission(permSet.activeConnectionPermissions, type, identifier);
+    };
+
+    /**
+     * Removes the given active connection permission applying to the
+     * connection group with the given ID from the given permission set, if
+     * present. If the permission is not present, this function has no effect.
+     *
+     * @param {PermissionSet} permSet
+     *     The permission set to modify.
+     *
+     * @param {String} type
+     *     The permission to remove, as defined by
+     *     PermissionSet.ObjectPermissionType.
+     *
+     * @param {String} identifier
+     *     The identifier of the active connection to which the permission
+     *     applies.
+     *
+     * @returns {Boolean}
+     *     true if the permission was removed, false if the permission was not
+     *     present in the given permission set.
+     */
+    PermissionSet.removeActiveConnectionPermission = function removeActiveConnectionPermission(permSet, type, identifier) {
+        permSet.activeConnectionPermissions = permSet.activeConnectionPermissions || {};
+        return removeObjectPermission(permSet.activeConnectionPermissions, type, identifier);
+    };
+
+    /**
+     * Adds the given user permission applying to the user with the given ID to
+     * the given permission set, if not already present. If the permission is
+     * already present, this function has no effect.
+     *
+     * @param {PermissionSet} permSet
+     *     The permission set to modify.
+     *
+     * @param {String} type
+     *     The permission to add, as defined by
+     *     PermissionSet.ObjectPermissionType.
+     *
+     * @param {String} identifier
+     *     The identifier of the user to which the permission applies.
+     *
+     * @returns {Boolean}
+     *     true if the permission was added, false if the permission was
+     *     already present in the given permission set.
+     */
+    PermissionSet.addUserPermission = function addUserPermission(permSet, type, identifier) {
+        permSet.userPermissions = permSet.userPermissions || {};
+        return addObjectPermission(permSet.userPermissions, type, identifier);
+    };
+
+    /**
+     * Removes the given user permission applying to the user with the given ID
+     * from the given permission set, if present. If the permission is not
+     * present, this function has no effect.
+     *
+     * @param {PermissionSet} permSet
+     *     The permission set to modify.
+     *
+     * @param {String} type
+     *     The permission to remove, as defined by
+     *     PermissionSet.ObjectPermissionType.
+     *
+     * @param {String} identifier
+     *     The identifier of the user to whom the permission applies.
+     *
+     * @returns {Boolean}
+     *     true if the permission was removed, false if the permission was not
+     *     present in the given permission set.
+     */
+    PermissionSet.removeUserPermission = function removeUserPermission(permSet, type, identifier) {
+        permSet.userPermissions = permSet.userPermissions || {};
+        return removeObjectPermission(permSet.userPermissions, type, identifier);
+    };
+
+    return PermissionSet;
+
+}]);
\ No newline at end of file
diff --git a/guacamole/src/main/webapp/app/rest/types/Protocol.js b/guacamole/src/main/webapp/app/rest/types/Protocol.js
new file mode 100644
index 0000000..fff1049
--- /dev/null
+++ b/guacamole/src/main/webapp/app/rest/types/Protocol.js
@@ -0,0 +1,62 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Service which defines the Protocol class.
+ */
+angular.module('rest').factory('Protocol', [function defineProtocol() {
+            
+    /**
+     * The object returned by REST API calls when representing the data
+     * associated with a supported remote desktop protocol.
+     * 
+     * @constructor
+     * @param {Protocol|Object} [template={}]
+     *     The object whose properties should be copied within the new
+     *     Protocol.
+     */
+    var Protocol = function Protocol(template) {
+
+        // Use empty object by default
+        template = template || {};
+
+        /**
+         * The name which uniquely identifies this protocol.
+         *
+         * @type String
+         */
+        this.name = template.name;
+
+        /**
+         * An array of forms containing all known parameters for this protocol,
+         * their types, and other information.
+         *
+         * @type Form[]
+         * @default []
+         */
+        this.forms = template.forms || [];
+
+    };
+
+    return Protocol;
+
+}]);
\ No newline at end of file
diff --git a/guacamole/src/main/webapp/app/rest/types/User.js b/guacamole/src/main/webapp/app/rest/types/User.js
new file mode 100644
index 0000000..8c18d01
--- /dev/null
+++ b/guacamole/src/main/webapp/app/rest/types/User.js
@@ -0,0 +1,72 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Service which defines the User class.
+ */
+angular.module('rest').factory('User', [function defineUser() {
+            
+    /**
+     * The object returned by REST API calls when representing the data
+     * associated with a user.
+     * 
+     * @constructor
+     * @param {User|Object} [template={}]
+     *     The object whose properties should be copied within the new
+     *     User.
+     */
+    var User = function User(template) {
+
+        // Use empty object by default
+        template = template || {};
+
+        /**
+         * The name which uniquely identifies this user.
+         *
+         * @type String
+         */
+        this.username = template.username;
+
+        /**
+         * This user's password. Note that the REST API may not populate this
+         * property for the sake of security. In most cases, it's not even
+         * possible for the authentication layer to retrieve the user's true
+         * password.
+         * 
+         * @type String
+         */
+        this.password = template.password;
+
+        /**
+         * Arbitrary name/value pairs which further describe this user. The
+         * semantics and validity of these attributes are dictated by the
+         * extension which defines them.
+         *
+         * @type Object.<String, String>
+         */
+        this.attributes = {};
+
+    };
+
+    return User;
+
+}]);
\ No newline at end of file
diff --git a/guacamole/src/main/webapp/app/rest/types/UserPasswordUpdate.js b/guacamole/src/main/webapp/app/rest/types/UserPasswordUpdate.js
new file mode 100644
index 0000000..14c7e2c
--- /dev/null
+++ b/guacamole/src/main/webapp/app/rest/types/UserPasswordUpdate.js
@@ -0,0 +1,61 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Service which defines the UserPasswordUpdate class.
+ */
+angular.module('rest').factory('UserPasswordUpdate', [function defineUserPasswordUpdate() {
+            
+    /**
+     * The object sent to the REST API when representing the data
+     * associated with a user password update.
+     * 
+     * @constructor
+     * @param {UserPasswordUpdate|Object} [template={}]
+     *     The object whose properties should be copied within the new
+     *     UserPasswordUpdate.
+     */
+    var UserPasswordUpdate = function UserPasswordUpdate(template) {
+
+        // Use empty object by default
+        template = template || {};
+
+        /**
+         * This user's current password. Required for authenticating the user
+         * as part of to the password update operation.
+         * 
+         * @type String
+         */
+        this.oldPassword = template.oldPassword;
+
+        /**
+         * The new password to set for the user.
+         * 
+         * @type String
+         */
+        this.newPassword = template.newPassword;
+
+    };
+
+    return UserPasswordUpdate;
+
+}]);
\ No newline at end of file
diff --git a/guacamole/src/main/webapp/app/settings/controllers/settingsController.js b/guacamole/src/main/webapp/app/settings/controllers/settingsController.js
new file mode 100644
index 0000000..ce1a26a
--- /dev/null
+++ b/guacamole/src/main/webapp/app/settings/controllers/settingsController.js
@@ -0,0 +1,55 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * The controller for the general settings page.
+ */
+angular.module('manage').controller('settingsController', ['$scope', '$injector', 
+        function settingsController($scope, $injector) {
+
+    // Required services
+    var $routeParams    = $injector.get('$routeParams');
+    var userPageService = $injector.get('userPageService');
+
+    /**
+     * The array of settings pages available to the current user, or null if
+     * not yet known.
+     *
+     * @type Page[]
+     */
+    $scope.settingsPages = null;
+
+    /**
+     * The currently-selected settings tab. This may be 'users', 'connections',
+     * or 'sessions'.
+     *
+     * @type String
+     */
+    $scope.activeTab = $routeParams.tab;
+
+    // Retrieve settings pages
+    userPageService.getSettingsPages()
+    .then(function settingsPagesRetrieved(pages) {
+        $scope.settingsPages = pages;
+    });
+
+}]);
diff --git a/guacamole/src/main/webapp/app/settings/directives/guacSettingsConnectionHistory.js b/guacamole/src/main/webapp/app/settings/directives/guacSettingsConnectionHistory.js
new file mode 100644
index 0000000..0e1bd9d
--- /dev/null
+++ b/guacamole/src/main/webapp/app/settings/directives/guacSettingsConnectionHistory.js
@@ -0,0 +1,190 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * A directive for viewing connection history records.
+ */
+angular.module('settings').directive('guacSettingsConnectionHistory', [function guacSettingsConnectionHistory() {
+        
+    return {
+        // Element only
+        restrict: 'E',
+        replace: true,
+
+        scope: {
+        },
+
+        templateUrl: 'app/settings/templates/settingsConnectionHistory.html',
+        controller: ['$scope', '$injector', function settingsConnectionHistoryController($scope, $injector) {
+                
+            // Get required types
+            var ConnectionHistoryEntryWrapper = $injector.get('ConnectionHistoryEntryWrapper');
+            var FilterToken                   = $injector.get('FilterToken');
+            var SortOrder                     = $injector.get('SortOrder');
+
+            // Get required services
+            var $routeParams   = $injector.get('$routeParams');
+            var $translate     = $injector.get('$translate');
+            var historyService = $injector.get('historyService');
+
+            /**
+             * The identifier of the currently-selected data source.
+             *
+             * @type String
+             */
+            $scope.dataSource = $routeParams.dataSource;
+
+            /**
+             * All wrapped matching connection history entries, or null if these
+             * entries have not yet been retrieved.
+             *
+             * @type ConnectionHistoryEntryWrapper[]
+             */
+            $scope.historyEntryWrappers = null;
+
+            /**
+             * The search terms to use when filtering the history records.
+             *
+             * @type String
+             */
+            $scope.searchString = '';
+
+            /**
+             * The date format for use for start/end dates.
+             *
+             * @type String
+             */
+            $scope.dateFormat = null;
+
+            /**
+             * SortOrder instance which stores the sort order of the history
+             * records.
+             *
+             * @type SortOrder
+             */
+            $scope.order = new SortOrder([
+                '-startDate',
+                '-duration',
+                'username',
+                'connectionName'
+            ]);
+
+            // Get session date format
+            $translate('SETTINGS_CONNECTION_HISTORY.FORMAT_DATE')
+            .then(function dateFormatReceived(retrievedDateFormat) {
+
+                // Store received date format
+                $scope.dateFormat = retrievedDateFormat;
+
+            });
+            
+            /**
+             * Returns true if the connection history records have been loaded,
+             * indicating that information needed to render the page is fully 
+             * loaded.
+             * 
+             * @returns {Boolean} 
+             *     true if the history records have been loaded, false
+             *     otherwise.
+             * 
+             */
+            $scope.isLoaded = function isLoaded() {
+                return $scope.historyEntryWrappers !== null
+                    && $scope.dateFormat           !== null;
+            };
+
+            /**
+             * Returns whether the search has completed but contains no history
+             * records. This function will return false if there are history
+             * records in the results OR if the search has not yet completed.
+             *
+             * @returns {Boolean}
+             *     true if the search results have been loaded but no history
+             *     records are present, false otherwise.
+             */
+            $scope.isHistoryEmpty = function isHistoryEmpty() {
+                return $scope.isLoaded() && $scope.historyEntryWrappers.length === 0;
+            };
+
+            /**
+             * Query the API for the connection record history, filtered by 
+             * searchString, and ordered by order.
+             */
+            $scope.search = function search() {
+
+                // Clear current results
+                $scope.historyEntryWrappers = null;
+
+                // Tokenize search string
+                var tokens = FilterToken.tokenize($scope.searchString);
+
+                // Transform tokens into list of required string contents
+                var requiredContents = [];
+                angular.forEach(tokens, function addRequiredContents(token) {
+
+                    // Transform depending on token type
+                    switch (token.type) {
+
+                        // For string literals, use parsed token value
+                        case 'LITERAL':
+                            requiredContents.push(token.value);
+
+                        // Ignore whitespace
+                        case 'WHITESPACE':
+                            break;
+
+                        // For all other token types, use the relevant portion
+                        // of the original search string
+                        default:
+                            requiredContents.push(token.consumed);
+
+                    }
+
+                });
+
+                // Fetch history records
+                historyService.getConnectionHistory(
+                    $scope.dataSource,
+                    requiredContents,
+                    $scope.order.predicate.filter(function isSupportedPredicate(predicate) {
+                        return predicate === 'startDate' || predicate === '-startDate';
+                    })
+                )
+                .success(function historyRetrieved(historyEntries) {
+
+                    // Wrap all history entries for sake of display
+                    $scope.historyEntryWrappers = [];
+                    angular.forEach(historyEntries, function wrapHistoryEntry(historyEntry) {
+                       $scope.historyEntryWrappers.push(new ConnectionHistoryEntryWrapper(historyEntry)); 
+                    });
+
+                });
+
+            };
+            
+            // Initialize search results
+            $scope.search();
+            
+        }]
+    };
+    
+}]);
diff --git a/guacamole/src/main/webapp/app/settings/directives/guacSettingsConnections.js b/guacamole/src/main/webapp/app/settings/directives/guacSettingsConnections.js
new file mode 100644
index 0000000..20b5e7d
--- /dev/null
+++ b/guacamole/src/main/webapp/app/settings/directives/guacSettingsConnections.js
@@ -0,0 +1,241 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * A directive for managing all connections and connection groups in the system.
+ */
+angular.module('settings').directive('guacSettingsConnections', [function guacSettingsConnections() {
+    
+    return {
+        // Element only
+        restrict: 'E',
+        replace: true,
+
+        scope: {
+        },
+
+        templateUrl: 'app/settings/templates/settingsConnections.html',
+        controller: ['$scope', '$injector', function settingsConnectionsController($scope, $injector) {
+
+            // Required types
+            var ConnectionGroup = $injector.get('ConnectionGroup');
+            var PermissionSet   = $injector.get('PermissionSet');
+
+            // Required services
+            var $location              = $injector.get('$location');
+            var $routeParams           = $injector.get('$routeParams');
+            var authenticationService  = $injector.get('authenticationService');
+            var connectionGroupService = $injector.get('connectionGroupService');
+            var dataSourceService      = $injector.get('dataSourceService');
+            var guacNotification       = $injector.get('guacNotification');
+            var permissionService      = $injector.get('permissionService');
+
+            /**
+             * The identifier of the current user.
+             *
+             * @type String
+             */
+            var currentUsername = authenticationService.getCurrentUsername();
+
+            /**
+             * An action to be provided along with the object sent to
+             * showStatus which closes the currently-shown status dialog.
+             */
+            var ACKNOWLEDGE_ACTION = {
+                name        : "SETTINGS_CONNECTIONS.ACTION_ACKNOWLEDGE",
+                // Handle action
+                callback    : function acknowledgeCallback() {
+                    guacNotification.showStatus(false);
+                }
+            };
+
+            /**
+             * The identifier of the currently-selected data source.
+             *
+             * @type String
+             */
+            $scope.dataSource = $routeParams.dataSource;
+
+            /**
+             * The root connection group of the connection group hierarchy.
+             *
+             * @type Object.<String, ConnectionGroup>
+             */
+            $scope.rootGroups = null;
+
+            /**
+             * All permissions associated with the current user, or null if the
+             * user's permissions have not yet been loaded.
+             *
+             * @type PermissionSet
+             */
+            $scope.permissions = null;
+
+            /**
+             * Array of all connection properties that are filterable.
+             *
+             * @type String[]
+             */
+            $scope.filteredConnectionProperties = [
+                'name',
+                'protocol'
+            ];
+
+            /**
+             * Array of all connection group properties that are filterable.
+             *
+             * @type String[]
+             */
+            $scope.filteredConnectionGroupProperties = [
+                'name'
+            ];
+
+            /**
+             * Returns whether critical data has completed being loaded.
+             *
+             * @returns {Boolean}
+             *     true if enough data has been loaded for the user interface
+             *     to be useful, false otherwise.
+             */
+            $scope.isLoaded = function isLoaded() {
+
+                return $scope.rootGroup   !== null
+                    && $scope.permissions !== null;
+
+            };
+
+            /**
+             * Returns whether the current user can create new connections
+             * within the current data source.
+             *
+             * @return {Boolean}
+             *     true if the current user can create new connections within
+             *     the current data source, false otherwise.
+             */
+            $scope.canCreateConnections = function canCreateConnections() {
+
+                // Abort if permissions have not yet loaded
+                if (!$scope.permissions)
+                    return false;
+
+                // Can create connections if adminstrator or have explicit permission
+                if (PermissionSet.hasSystemPermission($scope.permissions, PermissionSet.SystemPermissionType.ADMINISTER)
+                 || PermissionSet.hasSystemPermission($scope.permissions, PermissionSet.SystemPermissionType.CREATE_CONNECTION))
+                     return true;
+
+                // No data sources allow connection creation
+                return false;
+
+            };
+
+            /**
+             * Returns whether the current user can create new connection
+             * groups within the current data source.
+             *
+             * @return {Boolean}
+             *     true if the current user can create new connection groups
+             *     within the current data source, false otherwise.
+             */
+            $scope.canCreateConnectionGroups = function canCreateConnectionGroups() {
+
+                // Abort if permissions have not yet loaded
+                if (!$scope.permissions)
+                    return false;
+
+                // Can create connections groups if adminstrator or have explicit permission
+                if (PermissionSet.hasSystemPermission($scope.permissions, PermissionSet.SystemPermissionType.ADMINISTER)
+                 || PermissionSet.hasSystemPermission($scope.permissions, PermissionSet.SystemPermissionType.CREATE_CONNECTION_GROUP))
+                     return true;
+
+                // No data sources allow connection group creation
+                return false;
+
+            };
+
+            /**
+             * Returns whether the current user can create new connections or
+             * connection groups or make changes to existing connections or
+             * connection groups within the current data source. The
+             * connection management interface as a whole is useless if this
+             * function returns false.
+             *
+             * @return {Boolean}
+             *     true if the current user can create new connections/groups
+             *     or make changes to existing connections/groups within the
+             *     current data source, false otherwise.
+             */
+            $scope.canManageConnections = function canManageConnections() {
+
+                // Abort if permissions have not yet loaded
+                if (!$scope.permissions)
+                    return false;
+
+                // Creating connections/groups counts as management
+                if ($scope.canCreateConnections() || $scope.canCreateConnectionGroups())
+                    return true;
+
+                // Can manage connections if granted explicit update or delete
+                if (PermissionSet.hasConnectionPermission($scope.permissions, PermissionSet.ObjectPermissionType.UPDATE)
+                 || PermissionSet.hasConnectionPermission($scope.permissions, PermissionSet.ObjectPermissionType.DELETE))
+                    return true;
+
+                // Can manage connections groups if granted explicit update or delete
+                if (PermissionSet.hasConnectionGroupPermission($scope.permissions, PermissionSet.ObjectPermissionType.UPDATE)
+                 || PermissionSet.hasConnectionGroupPermission($scope.permissions, PermissionSet.ObjectPermissionType.DELETE))
+                    return true;
+
+                // No data sources allow management of connections or groups
+                return false;
+
+            };
+
+            // Retrieve current permissions
+            permissionService.getPermissions($scope.dataSource, currentUsername)
+            .success(function permissionsRetrieved(permissions) {
+
+                // Store retrieved permissions
+                $scope.permissions = permissions;
+
+                // Ignore permission to update root group
+                PermissionSet.removeConnectionGroupPermission($scope.permissions, PermissionSet.ObjectPermissionType.UPDATE, ConnectionGroup.ROOT_IDENTIFIER);
+
+                // Return to home if there's nothing to do here
+                if (!$scope.canManageConnections())
+                    $location.path('/');
+
+            });
+            
+            // Retrieve all connections for which we have UPDATE or DELETE permission
+            dataSourceService.apply(
+                connectionGroupService.getConnectionGroupTree,
+                [$scope.dataSource],
+                ConnectionGroup.ROOT_IDENTIFIER,
+                [PermissionSet.ObjectPermissionType.UPDATE, PermissionSet.ObjectPermissionType.DELETE]
+            )
+            .then(function connectionGroupsReceived(rootGroups) {
+                $scope.rootGroups = rootGroups;
+            });
+            
+        }]
+    };
+    
+}]);
diff --git a/guacamole/src/main/webapp/app/settings/directives/guacSettingsPreferences.js b/guacamole/src/main/webapp/app/settings/directives/guacSettingsPreferences.js
new file mode 100644
index 0000000..f13c04b
--- /dev/null
+++ b/guacamole/src/main/webapp/app/settings/directives/guacSettingsPreferences.js
@@ -0,0 +1,211 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * A directive for managing preferences local to the current user.
+ */
+angular.module('settings').directive('guacSettingsPreferences', [function guacSettingsPreferences() {
+    
+    return {
+        // Element only
+        restrict: 'E',
+        replace: true,
+
+        scope: {},
+
+        templateUrl: 'app/settings/templates/settingsPreferences.html',
+        controller: ['$scope', '$injector', function settingsPreferencesController($scope, $injector) {
+
+            // Get required types
+            var PermissionSet = $injector.get('PermissionSet');
+
+            // Required services
+            var $translate            = $injector.get('$translate');
+            var authenticationService = $injector.get('authenticationService');
+            var guacNotification      = $injector.get('guacNotification');
+            var languageService       = $injector.get('languageService');
+            var permissionService     = $injector.get('permissionService');
+            var preferenceService     = $injector.get('preferenceService');
+            var userService           = $injector.get('userService');
+
+            /**
+             * An action to be provided along with the object sent to
+             * showStatus which closes the currently-shown status dialog.
+             */
+            var ACKNOWLEDGE_ACTION = {
+                name        : 'SETTINGS_PREFERENCES.ACTION_ACKNOWLEDGE',
+                // Handle action
+                callback    : function acknowledgeCallback() {
+                    guacNotification.showStatus(false);
+                }
+            };
+
+            /**
+             * The username of the current user.
+             *
+             * @type String
+             */
+            var username = authenticationService.getCurrentUsername();
+
+            /**
+             * The identifier of the data source which authenticated the
+             * current user.
+             *
+             * @type String
+             */
+            var dataSource = authenticationService.getDataSource();
+
+            /**
+             * All currently-set preferences, or their defaults if not yet set.
+             *
+             * @type Object.<String, Object>
+             */
+            $scope.preferences = preferenceService.preferences;
+            
+            /**
+             * A map of all available language keys to their human-readable
+             * names.
+             * 
+             * @type Object.<String, String>
+             */
+            $scope.languages = null;
+            
+            /**
+             * Switches the active display langugae to the chosen language.
+             */
+            $scope.changeLanguage = function changeLanguage() {
+                $translate.use($scope.preferences.language);
+            };
+
+            /**
+             * The new password for the user.
+             *
+             * @type String
+             */
+            $scope.newPassword = null;
+
+            /**
+             * The password match for the user. The update password action will
+             * fail if $scope.newPassword !== $scope.passwordMatch.
+             *
+             * @type String
+             */
+            $scope.newPasswordMatch = null;
+
+            /**
+             * Whether the current user can change their own password, or null
+             * if this is not yet known.
+             *
+             * @type Boolean
+             */
+            $scope.canChangePassword = null;
+
+            /**
+             * Update the current user's password to the password currently set within
+             * the password change dialog.
+             */
+            $scope.updatePassword = function updatePassword() {
+
+                // Verify passwords match
+                if ($scope.newPasswordMatch !== $scope.newPassword) {
+                    guacNotification.showStatus({
+                        className  : 'error',
+                        title      : 'SETTINGS_PREFERENCES.DIALOG_HEADER_ERROR',
+                        text       : 'SETTINGS_PREFERENCES.ERROR_PASSWORD_MISMATCH',
+                        actions    : [ ACKNOWLEDGE_ACTION ]
+                    });
+                    return;
+                }
+                
+                // Verify that the new password is not blank
+                if (!$scope.newPassword) {
+                    guacNotification.showStatus({
+                        className  : 'error',
+                        title      : 'SETTINGS_PREFERENCES.DIALOG_HEADER_ERROR',
+                        text       : 'SETTINGS_PREFERENCES.ERROR_PASSWORD_BLANK',
+                        actions    : [ ACKNOWLEDGE_ACTION ]
+                    });
+                    return;
+                }
+                
+                // Save the user with the new password
+                userService.updateUserPassword(dataSource, username, $scope.oldPassword, $scope.newPassword)
+                .success(function passwordUpdated() {
+                
+                    // Clear the password fields
+                    $scope.oldPassword      = null;
+                    $scope.newPassword      = null;
+                    $scope.newPasswordMatch = null;
+
+                    // Indicate that the password has been changed
+                    guacNotification.showStatus({
+                        text    : 'SETTINGS_PREFERENCES.INFO_PASSWORD_CHANGED',
+                        actions : [ ACKNOWLEDGE_ACTION ]
+                    });
+                })
+                
+                // Notify of any errors
+                .error(function passwordUpdateFailed(error) {
+                    guacNotification.showStatus({
+                        className  : 'error',
+                        title      : 'SETTINGS_PREFERENCES.DIALOG_HEADER_ERROR',
+                        'text'       : error.message,
+                        actions    : [ ACKNOWLEDGE_ACTION ]
+                    });
+                });
+                
+            };
+
+            // Retrieve defined languages
+            languageService.getLanguages()
+            .success(function languagesRetrieved(languages) {
+                $scope.languages = languages;
+            });
+
+            // Retrieve current permissions
+            permissionService.getPermissions(dataSource, username)
+            .success(function permissionsRetrieved(permissions) {
+
+                // Add action for changing password if permission is granted
+                $scope.canChangePassword = PermissionSet.hasUserPermission(permissions,
+                        PermissionSet.ObjectPermissionType.UPDATE, username);
+                        
+            });
+
+            /**
+             * Returns whether critical data has completed being loaded.
+             *
+             * @returns {Boolean}
+             *     true if enough data has been loaded for the user interface to be
+             *     useful, false otherwise.
+             */
+            $scope.isLoaded = function isLoaded() {
+
+                return $scope.canChangePassword !== null
+                    && $scope.languages         !== null;
+
+            };
+
+        }]
+    };
+    
+}]);
diff --git a/guacamole/src/main/webapp/app/settings/directives/guacSettingsSessions.js b/guacamole/src/main/webapp/app/settings/directives/guacSettingsSessions.js
new file mode 100644
index 0000000..f40bfa1
--- /dev/null
+++ b/guacamole/src/main/webapp/app/settings/directives/guacSettingsSessions.js
@@ -0,0 +1,405 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * A directive for managing all active Guacamole sessions.
+ */
+angular.module('settings').directive('guacSettingsSessions', [function guacSettingsSessions() {
+    
+    return {
+        // Element only
+        restrict: 'E',
+        replace: true,
+
+        scope: {
+        },
+
+        templateUrl: 'app/settings/templates/settingsSessions.html',
+        controller: ['$scope', '$injector', function settingsSessionsController($scope, $injector) {
+
+            // Required types
+            var ActiveConnectionWrapper = $injector.get('ActiveConnectionWrapper');
+            var ConnectionGroup         = $injector.get('ConnectionGroup');
+            var SortOrder               = $injector.get('SortOrder');
+
+            // Required services
+            var $filter                 = $injector.get('$filter');
+            var $translate              = $injector.get('$translate');
+            var $q                      = $injector.get('$q');
+            var activeConnectionService = $injector.get('activeConnectionService');
+            var authenticationService   = $injector.get('authenticationService');
+            var connectionGroupService  = $injector.get('connectionGroupService');
+            var dataSourceService       = $injector.get('dataSourceService');
+            var guacNotification        = $injector.get('guacNotification');
+
+            /**
+             * The identifiers of all data sources accessible by the current
+             * user.
+             *
+             * @type String[]
+             */
+            var dataSources = authenticationService.getAvailableDataSources();
+
+            /**
+             * The ActiveConnectionWrappers of all active sessions accessible
+             * by the current user, or null if the active sessions have not yet
+             * been loaded.
+             *
+             * @type ActiveConnectionWrapper[]
+             */
+            $scope.wrappers = null;
+
+            /**
+             * SortOrder instance which maintains the sort order of the visible
+             * connection wrappers.
+             *
+             * @type SortOrder
+             */
+            $scope.wrapperOrder = new SortOrder([
+                'activeConnection.username',
+                'startDate',
+                'activeConnection.remoteHost',
+                'name'
+            ]);
+
+            /**
+             * Array of all wrapper properties that are filterable.
+             *
+             * @type String[]
+             */
+            $scope.filteredWrapperProperties = [
+                'activeConnection.username',
+                'startDate',
+                'activeConnection.remoteHost',
+                'name'
+            ];
+
+            /**
+             * All active connections, if known, grouped by corresponding data
+             * source identifier, or null if active connections have not yet
+             * been loaded.
+             *
+             * @type Object.<String, Object.<String, ActiveConnection>>
+             */
+            var allActiveConnections = null;
+
+            /**
+             * Map of all visible connections by data source identifier and
+             * object identifier, or null if visible connections have not yet
+             * been loaded.
+             *
+             * @type Object.<String, Object.<String, Connection>>
+             */
+            var allConnections = null;
+
+            /**
+             * The date format for use for session-related dates.
+             *
+             * @type String
+             */
+            var sessionDateFormat = null;
+
+            /**
+             * Map of all currently-selected active connection wrappers by
+             * data source and identifier.
+             * 
+             * @type Object.<String, Object.<String, ActiveConnectionWrapper>>
+             */
+            var allSelectedWrappers = {};
+
+            /**
+             * Adds the given connection to the internal set of visible
+             * connections.
+             *
+             * @param {String} dataSource
+             *     The identifier of the data source associated with the given
+             *     connection.
+             *
+             * @param {Connection} connection
+             *     The connection to add to the internal set of visible
+             *     connections.
+             */
+            var addConnection = function addConnection(dataSource, connection) {
+
+                // Add given connection to set of visible connections
+                allConnections[dataSource][connection.identifier] = connection;
+
+            };
+
+            /**
+             * Adds all descendant connections of the given connection group to
+             * the internal set of connections.
+             * 
+             * @param {String} dataSource
+             *     The identifier of the data source associated with the given
+             *     connection group.
+             *
+             * @param {ConnectionGroup} connectionGroup
+             *     The connection group whose descendant connections should be
+             *     added to the internal set of connections.
+             */
+            var addDescendantConnections = function addDescendantConnections(dataSource, connectionGroup) {
+
+                // Add all child connections
+                angular.forEach(connectionGroup.childConnections, function addConnectionForDataSource(connection) {
+                    addConnection(dataSource, connection);
+                });
+
+                // Add all child connection groups
+                angular.forEach(connectionGroup.childConnectionGroups, function addConnectionGroupForDataSource(connectionGroup) {
+                    addDescendantConnections(dataSource, connectionGroup);
+                });
+
+            };
+
+            /**
+             * Wraps all loaded active connections, storing the resulting array
+             * within the scope. If required data has not yet finished loading,
+             * this function has no effect.
+             */
+            var wrapAllActiveConnections = function wrapAllActiveConnections() {
+
+                // Abort if not all required data is available
+                if (!allActiveConnections || !allConnections || !sessionDateFormat)
+                    return;
+
+                // Wrap all active connections for sake of display
+                $scope.wrappers = [];
+                angular.forEach(allActiveConnections, function wrapActiveConnections(activeConnections, dataSource) {
+                    angular.forEach(activeConnections, function wrapActiveConnection(activeConnection, identifier) {
+
+                        // Retrieve corresponding connection
+                        var connection = allConnections[dataSource][activeConnection.connectionIdentifier];
+
+                        // Add wrapper
+                        $scope.wrappers.push(new ActiveConnectionWrapper({
+                            dataSource       : dataSource,
+                            name             : connection.name,
+                            startDate        : $filter('date')(activeConnection.startDate, sessionDateFormat),
+                            activeConnection : activeConnection
+                        }));
+
+                    });
+                });
+
+            };
+
+            // Retrieve all connections 
+            dataSourceService.apply(
+                connectionGroupService.getConnectionGroupTree,
+                dataSources,
+                ConnectionGroup.ROOT_IDENTIFIER
+            )
+            .then(function connectionGroupsReceived(rootGroups) {
+
+                allConnections = {};
+
+                // Load connections from each received root group
+                angular.forEach(rootGroups, function connectionGroupReceived(rootGroup, dataSource) {
+                    allConnections[dataSource] = {};
+                    addDescendantConnections(dataSource, rootGroup);
+                });
+
+                // Attempt to produce wrapped list of active connections
+                wrapAllActiveConnections();
+
+            });
+            
+            // Query active sessions
+            dataSourceService.apply(
+                activeConnectionService.getActiveConnections,
+                dataSources
+            )
+            .then(function sessionsRetrieved(retrievedActiveConnections) {
+
+                // Store received map of active connections
+                allActiveConnections = retrievedActiveConnections;
+
+                // Attempt to produce wrapped list of active connections
+                wrapAllActiveConnections();
+
+            });
+
+            // Get session date format
+            $translate('SETTINGS_SESSIONS.FORMAT_STARTDATE').then(function sessionDateFormatReceived(retrievedSessionDateFormat) {
+
+                // Store received date format
+                sessionDateFormat = retrievedSessionDateFormat;
+
+                // Attempt to produce wrapped list of active connections
+                wrapAllActiveConnections();
+
+            });
+
+            /**
+             * Returns whether critical data has completed being loaded.
+             *
+             * @returns {Boolean}
+             *     true if enough data has been loaded for the user interface
+             *     to be useful, false otherwise.
+             */
+            $scope.isLoaded = function isLoaded() {
+                return $scope.wrappers !== null;
+            };
+
+            /**
+             * An action to be provided along with the object sent to
+             * showStatus which closes the currently-shown status dialog.
+             */
+            var ACKNOWLEDGE_ACTION = {
+                name        : "SETTINGS_SESSIONS.ACTION_ACKNOWLEDGE",
+                // Handle action
+                callback    : function acknowledgeCallback() {
+                    guacNotification.showStatus(false);
+                }
+            };
+
+            /**
+             * An action to be provided along with the object sent to
+             * showStatus which closes the currently-shown status dialog.
+             */
+            var CANCEL_ACTION = {
+                name        : "SETTINGS_SESSIONS.ACTION_CANCEL",
+                // Handle action
+                callback    : function cancelCallback() {
+                    guacNotification.showStatus(false);
+                }
+            };
+            
+            /**
+             * An action to be provided along with the object sent to
+             * showStatus which immediately deletes the currently selected
+             * sessions.
+             */
+            var DELETE_ACTION = {
+                name        : "SETTINGS_SESSIONS.ACTION_DELETE",
+                className   : "danger",
+                // Handle action
+                callback    : function deleteCallback() {
+                    deleteAllSessionsImmediately();
+                    guacNotification.showStatus(false);
+                }
+            };
+            
+            /**
+             * Immediately deletes the selected sessions, without prompting the
+             * user for confirmation.
+             */
+            var deleteAllSessionsImmediately = function deleteAllSessionsImmediately() {
+
+                var deletionRequests = [];
+
+                // Perform deletion for each relevant data source
+                angular.forEach(allSelectedWrappers, function deleteSessionsImmediately(selectedWrappers, dataSource) {
+
+                    // Delete sessions, if any are selected
+                    var identifiers = Object.keys(selectedWrappers);
+                    if (identifiers.length)
+                        deletionRequests.push(activeConnectionService.deleteActiveConnections(dataSource, identifiers));
+
+                });
+
+                // Update interface
+                $q.all(deletionRequests)
+                .then(function activeConnectionsDeleted() {
+
+                    // Remove deleted connections from wrapper array
+                    $scope.wrappers = $scope.wrappers.filter(function activeConnectionStillExists(wrapper) {
+                        return !(wrapper.activeConnection.identifier in (allSelectedWrappers[wrapper.dataSource] || {}));
+                    });
+
+                    // Clear selection
+                    allSelectedWrappers = {};
+
+                },
+
+                // Notify of any errors
+                function activeConnectionDeletionFailed(error) {
+                    guacNotification.showStatus({
+                        'className'  : 'error',
+                        'title'      : 'SETTINGS_SESSIONS.DIALOG_HEADER_ERROR',
+                        'text'       : error.message,
+                        'actions'    : [ ACKNOWLEDGE_ACTION ]
+                    });
+                });
+
+            }; 
+            
+            /**
+             * Delete all selected sessions, prompting the user first to
+             * confirm that deletion is desired.
+             */
+            $scope.deleteSessions = function deleteSessions() {
+                // Confirm deletion request
+                guacNotification.showStatus({
+                    'title'      : 'SETTINGS_SESSIONS.DIALOG_HEADER_CONFIRM_DELETE',
+                    'text'       : 'SETTINGS_SESSIONS.TEXT_CONFIRM_DELETE',
+                    'actions'    : [ DELETE_ACTION, CANCEL_ACTION]
+                });
+            };
+            
+            /**
+             * Returns whether the selected sessions can be deleted.
+             * 
+             * @returns {Boolean}
+             *     true if selected sessions can be deleted, false otherwise.
+             */
+            $scope.canDeleteSessions = function canDeleteSessions() {
+
+                // We can delete sessions if at least one is selected
+                for (var dataSource in allSelectedWrappers) {
+                    for (var identifier in allSelectedWrappers[dataSource])
+                        return true;
+                }
+
+                return false;
+
+            };
+            
+            /**
+             * Called whenever an active connection wrapper changes selected
+             * status.
+             * 
+             * @param {ActiveConnectionWrapper} wrapper
+             *     The wrapper whose selected status has changed.
+             */
+            $scope.wrapperSelectionChange = function wrapperSelectionChange(wrapper) {
+
+                // Get selection map for associated data source, creating if necessary
+                var selectedWrappers = allSelectedWrappers[wrapper.dataSource];
+                if (!selectedWrappers)
+                    selectedWrappers = allSelectedWrappers[wrapper.dataSource] = {};
+
+                // Add wrapper to map if selected
+                if (wrapper.checked)
+                    selectedWrappers[wrapper.activeConnection.identifier] = wrapper;
+
+                // Otherwise, remove wrapper from map
+                else
+                    delete selectedWrappers[wrapper.activeConnection.identifier];
+
+            };
+            
+        }]
+    };
+    
+}]);
diff --git a/guacamole/src/main/webapp/app/settings/directives/guacSettingsUsers.js b/guacamole/src/main/webapp/app/settings/directives/guacSettingsUsers.js
new file mode 100644
index 0000000..13a3064
--- /dev/null
+++ b/guacamole/src/main/webapp/app/settings/directives/guacSettingsUsers.js
@@ -0,0 +1,269 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * A directive for managing all users in the system.
+ */
+angular.module('settings').directive('guacSettingsUsers', [function guacSettingsUsers() {
+    
+    return {
+        // Element only
+        restrict: 'E',
+        replace: true,
+
+        scope: {
+        },
+
+        templateUrl: 'app/settings/templates/settingsUsers.html',
+        controller: ['$scope', '$injector', function settingsUsersController($scope, $injector) {
+
+            // Required types
+            var ManageableUser  = $injector.get('ManageableUser');
+            var PermissionSet   = $injector.get('PermissionSet');
+
+            // Required services
+            var $location              = $injector.get('$location');
+            var authenticationService  = $injector.get('authenticationService');
+            var dataSourceService      = $injector.get('dataSourceService');
+            var guacNotification       = $injector.get('guacNotification');
+            var permissionService      = $injector.get('permissionService');
+            var userService            = $injector.get('userService');
+
+            // Identifier of the current user
+            var currentUsername = authenticationService.getCurrentUsername();
+
+            /**
+             * An action to be provided along with the object sent to
+             * showStatus which closes the currently-shown status dialog.
+             */
+            var ACKNOWLEDGE_ACTION = {
+                name        : "SETTINGS_USERS.ACTION_ACKNOWLEDGE",
+                // Handle action
+                callback    : function acknowledgeCallback() {
+                    guacNotification.showStatus(false);
+                }
+            };
+
+            /**
+             * The identifiers of all data sources accessible by the current
+             * user.
+             *
+             * @type String[]
+             */
+            var dataSources = authenticationService.getAvailableDataSources();
+
+            /**
+             * All visible users, along with their corresponding data sources.
+             *
+             * @type ManageableUser[]
+             */
+            $scope.manageableUsers = null;
+
+            /**
+             * The name of the new user to create, if any, when user creation
+             * is requested via newUser().
+             *
+             * @type String
+             */
+            $scope.newUsername = "";
+
+            /**
+             * Map of data source identifiers to all permissions associated
+             * with the current user within that data source, or null if the
+             * user's permissions have not yet been loaded.
+             *
+             * @type Object.<String, PermissionSet>
+             */
+            $scope.permissions = null;
+
+            /**
+             * Array of all user properties that are filterable.
+             *
+             * @type String[]
+             */
+            $scope.filteredUserProperties = [
+                'user.username'
+            ];
+
+            /**
+             * Returns whether critical data has completed being loaded.
+             *
+             * @returns {Boolean}
+             *     true if enough data has been loaded for the user interface
+             *     to be useful, false otherwise.
+             */
+            $scope.isLoaded = function isLoaded() {
+
+                return $scope.manageableUsers !== null
+                    && $scope.permissions     !== null;
+
+            };
+
+            /**
+             * Returns the identifier of the data source that should be used by
+             * default when creating a new user.
+             *
+             * @return {String}
+             *     The identifier of the data source that should be used by
+             *     default when creating a new user, or null if user creation
+             *     is not allowed.
+             */
+            $scope.getDefaultDataSource = function getDefaultDataSource() {
+
+                // Abort if permissions have not yet loaded
+                if (!$scope.permissions)
+                    return null;
+
+                // For each data source
+                for (var dataSource in $scope.permissions) {
+
+                    // Retrieve corresponding permission set
+                    var permissionSet = $scope.permissions[dataSource];
+
+                    // Can create users if adminstrator or have explicit permission
+                    if (PermissionSet.hasSystemPermission(permissionSet, PermissionSet.SystemPermissionType.ADMINISTER)
+                     || PermissionSet.hasSystemPermission(permissionSet, PermissionSet.SystemPermissionType.CREATE_USER))
+                        return dataSource;
+
+                }
+
+                // No data sources allow user creation
+                return null;
+
+            };
+
+            /**
+             * Returns whether the current user can create new users within at
+             * least one data source.
+             *
+             * @return {Boolean}
+             *     true if the current user can create new users within at
+             *     least one data source, false otherwise.
+             */
+            $scope.canCreateUsers = function canCreateUsers() {
+                return $scope.getDefaultDataSource() !== null;
+            };
+
+            /**
+             * Returns whether the current user can create new users or make
+             * changes to existing users within at least one data source. The
+             * user management interface as a whole is useless if this function
+             * returns false.
+             *
+             * @return {Boolean}
+             *     true if the current user can create new users or make
+             *     changes to existing users within at least one data source,
+             *     false otherwise.
+             */
+            var canManageUsers = function canManageUsers() {
+
+                // Abort if permissions have not yet loaded
+                if (!$scope.permissions)
+                    return false;
+
+                // Creating users counts as management
+                if ($scope.canCreateUsers())
+                    return true;
+
+                // For each data source
+                for (var dataSource in $scope.permissions) {
+
+                    // Retrieve corresponding permission set
+                    var permissionSet = $scope.permissions[dataSource];
+
+                    // Can manage users if granted explicit update or delete
+                    if (PermissionSet.hasUserPermission(permissionSet, PermissionSet.ObjectPermissionType.UPDATE)
+                     || PermissionSet.hasUserPermission(permissionSet, PermissionSet.ObjectPermissionType.DELETE))
+                        return true;
+
+                }
+
+                // No data sources allow management of users
+                return false;
+
+            };
+
+            // Retrieve current permissions
+            dataSourceService.apply(
+                permissionService.getPermissions,
+                dataSources,
+                currentUsername
+            )
+            .then(function permissionsRetrieved(permissions) {
+
+                // Store retrieved permissions
+                $scope.permissions = permissions;
+
+                // Return to home if there's nothing to do here
+                if (!canManageUsers())
+                    $location.path('/');
+
+                var userPromise;
+
+                // If users can be created, list all readable users
+                if ($scope.canCreateUsers())
+                    userPromise = dataSourceService.apply(userService.getUsers, dataSources);
+
+                // Otherwise, list only updateable/deletable users
+                else
+                    userPromise = dataSourceService.apply(userService.getUsers, dataSources, [
+                        PermissionSet.ObjectPermissionType.UPDATE,
+                        PermissionSet.ObjectPermissionType.DELETE
+                    ]);
+
+                userPromise.then(function usersReceived(userArrays) {
+
+                    var addedUsers = {};
+                    $scope.manageableUsers = [];
+
+                    // For each user in each data source
+                    angular.forEach(dataSources, function addUserList(dataSource) {
+                        angular.forEach(userArrays[dataSource], function addUser(user) {
+
+                            // Do not add the same user twice
+                            if (addedUsers[user.username])
+                                return;
+
+                            // Link to default creation data source if we cannot manage this user
+                            if (!PermissionSet.hasSystemPermission(permissions[dataSource], PermissionSet.ObjectPermissionType.ADMINISTER)
+                             && !PermissionSet.hasUserPermission(permissions[dataSource], PermissionSet.ObjectPermissionType.UPDATE, user.username)
+                             && !PermissionSet.hasUserPermission(permissions[dataSource], PermissionSet.ObjectPermissionType.DELETE, user.username))
+                                dataSource = $scope.getDefaultDataSource();
+
+                            // Add user to overall list
+                            addedUsers[user.username] = user;
+                            $scope.manageableUsers.push(new ManageableUser ({
+                                'dataSource' : dataSource,
+                                'user'       : user
+                            }));
+
+                        });
+                    });
+
+                });
+
+            });
+            
+        }]
+    };
+    
+}]);
diff --git a/guacamole/src/main/webapp/app/settings/services/preferenceService.js b/guacamole/src/main/webapp/app/settings/services/preferenceService.js
new file mode 100644
index 0000000..f02270d
--- /dev/null
+++ b/guacamole/src/main/webapp/app/settings/services/preferenceService.js
@@ -0,0 +1,201 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * A service for setting and retrieving browser-local preferences. Preferences
+ * may be any JSON-serializable type.
+ */
+angular.module('settings').provider('preferenceService', function preferenceServiceProvider() {
+
+    /**
+     * Reference to the provider itself.
+     *
+     * @type preferenceServiceProvider
+     */
+    var provider = this;
+
+    /**
+     * The storage key of Guacamole preferences within local storage.
+     *
+     * @type String
+     */
+    var GUAC_PREFERENCES_STORAGE_KEY = "GUAC_PREFERENCES";
+
+    /**
+     * All valid input method type names.
+     *
+     * @type Object.<String, String>
+     */
+    var inputMethods = {
+
+        /**
+         * No input method is used. Keyboard events are generated from a
+         * physical keyboard.
+         *
+         * @constant
+         * @type String
+         */
+        NONE : 'none',
+
+        /**
+         * Keyboard events will be generated from the Guacamole on-screen
+         * keyboard.
+         *
+         * @constant
+         * @type String
+         */
+        OSK : 'osk',
+
+        /**
+         * Keyboard events will be generated by inferring the keys necessary to
+         * produce typed text from an IME (Input Method Editor) such as the
+         * native on-screen keyboard of a mobile device.
+         *
+         * @constant
+         * @type String
+         */
+        TEXT : 'text'
+
+    };
+
+    /**
+     * Returns the key of the language currently in use within the browser.
+     * This is not necessarily the user's desired language, but is rather the
+     * language user by the browser's interface.
+     *
+     * @returns {String}
+     *     The key of the language currently in use within the browser.
+     */
+    var getDefaultLanguageKey = function getDefaultLanguageKey() {
+
+        // Pull browser language, falling back to US English
+        var language = (navigator.languages && navigator.languages[0])
+                     || navigator.language
+                     || navigator.browserLanguage
+                     || 'en';
+
+        // Convert to format used internally
+        return language.replace(/-/g, '_');
+
+    };
+
+    /**
+     * All currently-set preferences, as name/value pairs. Each property name
+     * corresponds to the name of a preference.
+     *
+     * @type Object.<String, Object>
+     */
+    this.preferences = {
+
+        /**
+         * Whether translation of touch to mouse events should emulate an
+         * absolute pointer device, or a relative pointer device.
+         * 
+         * @type Boolean
+         */
+        emulateAbsoluteMouse : true,
+
+        /**
+         * The default input method. This may be any of the values defined
+         * within preferenceService.inputMethods.
+         *
+         * @type String
+         */
+        inputMethod : inputMethods.NONE,
+        
+        /**
+         * The key of the desired display language.
+         * 
+         * @type String
+         */
+        language : getDefaultLanguageKey()
+
+    };
+
+    // Get stored preferences, ignore inability to use localStorage
+    try {
+
+        if (localStorage) {
+            var preferencesJSON = localStorage.getItem(GUAC_PREFERENCES_STORAGE_KEY);
+            if (preferencesJSON)
+                angular.extend(provider.preferences, JSON.parse(preferencesJSON));
+        }
+
+    }
+    catch (ignore) {}
+
+    // Factory method required by provider
+    this.$get = ['$injector', function preferenceServiceFactory($injector) {
+
+        // Required services
+        var $rootScope = $injector.get('$rootScope');
+        var $window    = $injector.get('$window');
+
+        var service = {};
+
+        /**
+         * All valid input method type names.
+         *
+         * @type Object.<String, String>
+         */
+        service.inputMethods = inputMethods;
+
+        /**
+         * All currently-set preferences, as name/value pairs. Each property name
+         * corresponds to the name of a preference.
+         *
+         * @type Object.<String, Object>
+         */
+        service.preferences = provider.preferences;
+
+        /**
+         * Persists the current values of all preferences, if possible.
+         */
+        service.save = function save() {
+
+            // Save updated preferences, ignore inability to use localStorage
+            try {
+                if (localStorage)
+                    localStorage.setItem(GUAC_PREFERENCES_STORAGE_KEY, JSON.stringify(service.preferences));
+            }
+            catch (ignore) {}
+
+        };
+
+        // Persist settings when window is unloaded
+        $window.addEventListener('unload', service.save);
+
+        // Persist settings upon navigation 
+        $rootScope.$on('$routeChangeSuccess', function handleNavigate() {
+            service.save();
+        });
+
+        // Persist settings upon logout
+        $rootScope.$on('guacLogout', function handleLogout() {
+            service.save();
+        });
+
+        return service;
+
+    }];
+
+});
diff --git a/guacamole/src/main/webapp/app/settings/settingsModule.js b/guacamole/src/main/webapp/app/settings/settingsModule.js
new file mode 100644
index 0000000..b3d8c06
--- /dev/null
+++ b/guacamole/src/main/webapp/app/settings/settingsModule.js
@@ -0,0 +1,33 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * The module for manipulation of general settings. This is distinct from the
+ * "manage" module, which deals only with administrator-level system management.
+ */
+angular.module('settings', [
+    'groupList',
+    'list',
+    'navigation',
+    'notification',
+    'rest'
+]);
diff --git a/guacamole/src/main/webapp/app/settings/styles/buttons.css b/guacamole/src/main/webapp/app/settings/styles/buttons.css
new file mode 100644
index 0000000..fe96446
--- /dev/null
+++ b/guacamole/src/main/webapp/app/settings/styles/buttons.css
@@ -0,0 +1,58 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+a.button.add-user,
+a.button.add-connection,
+a.button.add-connection-group {
+    font-size: 0.8em;
+    padding-left: 1.8em;
+    position: relative;
+}
+
+a.button.add-user::before,
+a.button.add-connection::before,
+a.button.add-connection-group::before {
+
+    content: ' ';
+    position: absolute;
+    width: 1.8em;
+    top: 0;
+    bottom: 0;
+    left: 0;
+
+    background-repeat: no-repeat;
+    background-size: 1em;
+    background-position: 0.5em 0.45em;
+
+}
+
+a.button.add-user::before {
+    background-image: url('images/action-icons/guac-user-add.png');
+}
+
+a.button.add-connection::before {
+    background-image: url('images/action-icons/guac-monitor-add.png');
+}
+
+a.button.add-connection-group::before {
+    background-image: url('images/action-icons/guac-group-add.png');
+}
diff --git a/guacamole/src/main/webapp/app/settings/styles/history.css b/guacamole/src/main/webapp/app/settings/styles/history.css
new file mode 100644
index 0000000..4daa5c6
--- /dev/null
+++ b/guacamole/src/main/webapp/app/settings/styles/history.css
@@ -0,0 +1,59 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+.settings.connectionHistory .filter {
+
+    /* IE10 */
+    display: -ms-flexbox;
+    -ms-flex-align: stretch;
+    -ms-flex-direction: row;
+
+    /* Ancient Mozilla */
+    display: -moz-box;
+    -moz-box-align: stretch;
+    -moz-box-orient: horizontal;
+
+    /* Ancient WebKit */
+    display: -webkit-box;
+    -webkit-box-align: stretch;
+    -webkit-box-orient: horizontal;
+
+    /* Old WebKit */
+    display: -webkit-flex;
+    -webkit-align-items: stretch;
+    -webkit-flex-direction: row;
+
+    /* W3C */
+    display: flex;
+    align-items: stretch;
+    flex-direction: row;
+
+}
+
+.settings.connectionHistory .filter .search-button {
+    margin-top: 0;
+    margin-bottom: 0;
+}
+
+.settings.connectionHistory .history-list {
+    width: 100%;
+}
\ No newline at end of file
diff --git a/guacamole/src/main/webapp/app/settings/styles/input-method.css b/guacamole/src/main/webapp/app/settings/styles/input-method.css
new file mode 100644
index 0000000..39e12e3
--- /dev/null
+++ b/guacamole/src/main/webapp/app/settings/styles/input-method.css
@@ -0,0 +1,26 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+.preferences .input-method .caption {
+    margin-left: 2em;
+    margin-right: 2em;
+}
diff --git a/guacamole/src/main/webapp/app/settings/styles/mouse-mode.css b/guacamole/src/main/webapp/app/settings/styles/mouse-mode.css
new file mode 100644
index 0000000..ff37b1d
--- /dev/null
+++ b/guacamole/src/main/webapp/app/settings/styles/mouse-mode.css
@@ -0,0 +1,47 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+.preferences .mouse-mode .choices {
+    text-align: center;
+}
+
+.preferences .mouse-mode .choice {
+    display: inline-block;
+}
+
+.preferences .mouse-mode .choice .figure {
+    display: inline-block;
+    vertical-align: middle;
+    width: 75%;
+    max-width: 320px;
+}
+
+.preferences .mouse-mode .figure img {
+    display: block;
+    width: 100%;
+    max-width: 320px;
+    margin: 1em auto;
+}
+
+.preferences .mouse-mode .caption {
+    text-align: left;
+}
diff --git a/guacamole/src/main/webapp/app/settings/styles/preferences.css b/guacamole/src/main/webapp/app/settings/styles/preferences.css
new file mode 100644
index 0000000..eadf490
--- /dev/null
+++ b/guacamole/src/main/webapp/app/settings/styles/preferences.css
@@ -0,0 +1,27 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+.preferences .update-password .form, 
+.preferences .language .form {
+    padding-left: 0.5em;
+    border-left: 3px solid rgba(0, 0, 0, 0.125);
+}
\ No newline at end of file
diff --git a/guacamole/src/main/webapp/app/settings/styles/sessions.css b/guacamole/src/main/webapp/app/settings/styles/sessions.css
new file mode 100644
index 0000000..e13a382
--- /dev/null
+++ b/guacamole/src/main/webapp/app/settings/styles/sessions.css
@@ -0,0 +1,34 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+.settings table.session-list {
+    width: 100%;
+}
+
+.settings table.session-list tr.session:hover {
+    background: #CDA;
+}
+
+.settings table.session-list .select-session {
+    min-width: 2em;
+    text-align: center;
+}
diff --git a/guacamole/src/main/webapp/app/settings/styles/settings.css b/guacamole/src/main/webapp/app/settings/styles/settings.css
new file mode 100644
index 0000000..f2495c3
--- /dev/null
+++ b/guacamole/src/main/webapp/app/settings/styles/settings.css
@@ -0,0 +1,78 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+.settings .header {
+    margin-bottom: 0;
+}
+
+.settings table.properties th {
+    text-align: left;
+    font-weight: normal;
+    padding-right: 1em;
+}
+
+.settings .action-buttons {
+    text-align: center;
+    margin: 1em 0;
+}
+
+.settings .toolbar {
+
+    /* IE10 */
+    display: -ms-flexbox;
+    -ms-flex-align: center;
+    -ms-flex-direction: row;
+
+    /* Ancient Mozilla */
+    display: -moz-box;
+    -moz-box-align: center;
+    -moz-box-orient: horizontal;
+
+    /* Ancient WebKit */
+    display: -webkit-box;
+    -webkit-box-align: center;
+    -webkit-box-orient: horizontal;
+
+    /* Old WebKit */
+    display: -webkit-flex;
+    -webkit-align-items: center;
+    -webkit-flex-direction: row;
+
+    /* W3C */
+    display: flex;
+    align-items: center;
+    flex-direction: row;
+
+}
+
+.settings .toolbar .action-buttons {
+    margin-right: 0.25em;
+}
+
+.settings .toolbar .filter {
+    -ms-flex: 1 1 auto;
+    -moz-box-flex: 1;
+    -webkit-box-flex: 1;
+    -webkit-flex: 1 1 auto;
+    flex: 1 1 auto;
+}
+
diff --git a/guacamole/src/main/webapp/app/settings/templates/connection.html b/guacamole/src/main/webapp/app/settings/templates/connection.html
new file mode 100644
index 0000000..7514ade
--- /dev/null
+++ b/guacamole/src/main/webapp/app/settings/templates/connection.html
@@ -0,0 +1,40 @@
+<a ng-href="#/manage/{{item.dataSource}}/connections/{{item.identifier}}">
+    <!--
+       Copyright (C) 2014 Glyptodon LLC
+
+       Permission is hereby granted, free of charge, to any person obtaining a copy
+       of this software and associated documentation files (the "Software"), to deal
+       in the Software without restriction, including without limitation the rights
+       to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+       copies of the Software, and to permit persons to whom the Software is
+       furnished to do so, subject to the following conditions:
+
+       The above copyright notice and this permission notice shall be included in
+       all copies or substantial portions of the Software.
+
+       THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+       IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+       FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+       AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+       LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+       OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+       THE SOFTWARE.
+    -->
+
+    <div class="caption" ng-class="{active: item.getActiveConnections()}">
+
+        <!-- Connection icon -->
+        <div class="protocol">
+            <div class="icon type" ng-class="item.protocol"></div>
+        </div>
+
+        <!-- Connection name -->
+        <span class="name">{{item.name}}</span>
+
+        <!-- Active user count -->
+        <span class="activeUserCount" ng-show="item.getActiveConnections()"
+            translate="SETTINGS_CONNECTIONS.INFO_ACTIVE_USER_COUNT"
+            translate-values="{USERS: item.getActiveConnections()}"></span>
+        
+    </div>
+</a>
diff --git a/guacamole/src/main/webapp/app/settings/templates/connectionGroup.html b/guacamole/src/main/webapp/app/settings/templates/connectionGroup.html
new file mode 100644
index 0000000..ebfb180
--- /dev/null
+++ b/guacamole/src/main/webapp/app/settings/templates/connectionGroup.html
@@ -0,0 +1,25 @@
+<a ng-href="#/manage/{{item.dataSource}}/connectionGroups/{{item.identifier}}">
+    <!--
+       Copyright (C) 2014 Glyptodon LLC
+
+       Permission is hereby granted, free of charge, to any person obtaining a copy
+       of this software and associated documentation files (the "Software"), to deal
+       in the Software without restriction, including without limitation the rights
+       to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+       copies of the Software, and to permit persons to whom the Software is
+       furnished to do so, subject to the following conditions:
+
+       The above copyright notice and this permission notice shall be included in
+       all copies or substantial portions of the Software.
+
+       THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+       IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+       FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+       AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+       LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+       OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+       THE SOFTWARE.
+    -->
+
+    <span class="name">{{item.name}}</span>
+</a>
diff --git a/guacamole/src/main/webapp/app/settings/templates/settings.html b/guacamole/src/main/webapp/app/settings/templates/settings.html
new file mode 100644
index 0000000..9880ee2
--- /dev/null
+++ b/guacamole/src/main/webapp/app/settings/templates/settings.html
@@ -0,0 +1,42 @@
+<!--
+Copyright 2015 Glyptodon LLC.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+-->
+
+<div class="view">
+
+    <div class="header">
+        <h2>{{'SETTINGS.SECTION_HEADER_SETTINGS' | translate}}</h2>
+        <guac-user-menu></guac-user-menu>
+    </div>
+
+    <!-- Available tabs -->
+    <div class="page-tabs">
+        <guac-page-list pages="settingsPages"></guac-page-list>
+    </div>
+
+    <!-- Selected tab -->
+    <guac-settings-users                ng-if="activeTab === 'users'"></guac-settings-users>
+    <guac-settings-connections          ng-if="activeTab === 'connections'"></guac-settings-connections>
+    <guac-settings-connection-history   ng-if="activeTab === 'history'"></guac-settings-connection-history>
+    <guac-settings-sessions             ng-if="activeTab === 'sessions'"></guac-settings-sessions>
+    <guac-settings-preferences          ng-if="activeTab === 'preferences'"></guac-settings-preferences>
+
+</div>
diff --git a/guacamole/src/main/webapp/app/settings/templates/settingsConnectionHistory.html b/guacamole/src/main/webapp/app/settings/templates/settingsConnectionHistory.html
new file mode 100644
index 0000000..c22560e
--- /dev/null
+++ b/guacamole/src/main/webapp/app/settings/templates/settingsConnectionHistory.html
@@ -0,0 +1,75 @@
+<div class="settings section connectionHistory">
+    <!--
+    Copyright 2015 Glyptodon LLC.
+
+    Permission is hereby granted, free of charge, to any person obtaining a copy
+    of this software and associated documentation files (the "Software"), to deal
+    in the Software without restriction, including without limitation the rights
+    to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+    copies of the Software, and to permit persons to whom the Software is
+    furnished to do so, subject to the following conditions:
+
+    The above copyright notice and this permission notice shall be included in
+    all copies or substantial portions of the Software.
+
+    THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+    IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+    FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+    AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+    LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+    OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+    THE SOFTWARE.
+    -->
+
+    <!-- Connection history -->
+    <p>{{'SETTINGS_CONNECTION_HISTORY.HELP_CONNECTION_HISTORY' | translate}}</p>
+
+    <!-- Search controls -->
+    <form class="filter" ng-submit="search()">
+        <input class="search-string" type="text" placeholder="{{'SETTINGS_CONNECTION_HISTORY.FIELD_PLACEHOLDER_FILTER' | translate}}" ng-model="searchString" />
+        <input class="search-button" type="submit" value="{{'SETTINGS_CONNECTION_HISTORY.ACTION_SEARCH' | translate}}" />
+    </form>
+
+    <!-- Search results -->
+    <div class="results">
+
+        <!-- List of matching history records -->
+        <table class="sorted history-list">
+            <thead>
+                <tr>
+                    <th guac-sort-order="order" guac-sort-property="'username'">
+                        {{'SETTINGS_CONNECTION_HISTORY.TABLE_HEADER_SESSION_USERNAME' | translate}}
+                    </th>
+                    <th guac-sort-order="order" guac-sort-property="'startDate'">
+                        {{'SETTINGS_CONNECTION_HISTORY.TABLE_HEADER_SESSION_STARTDATE' | translate}}
+                    </th>
+                    <th guac-sort-order="order" guac-sort-property="'duration'">
+                        {{'SETTINGS_CONNECTION_HISTORY.TABLE_HEADER_SESSION_DURATION' | translate}}
+                    </th>
+                    <th guac-sort-order="order" guac-sort-property="'connectionName'">
+                        {{'SETTINGS_CONNECTION_HISTORY.TABLE_HEADER_SESSION_CONNECTION_NAME' | translate}}
+                    </th>
+                </tr>
+            </thead>
+            <tbody ng-class="{loading: !isLoaded()}">
+                <tr ng-repeat="historyEntryWrapper in historyEntryWrapperPage" class="history">
+                    <td>{{historyEntryWrapper.username}}</td>
+                    <td>{{historyEntryWrapper.startDate | date : dateFormat}}</td>
+                    <td translate="{{historyEntryWrapper.readableDurationText}}"
+                        translate-values="{VALUE: historyEntryWrapper.readableDuration.value, UNIT: historyEntryWrapper.readableDuration.unit}"></td>
+                    <td>{{historyEntryWrapper.connectionName}}</td>
+                </tr>
+            </tbody>
+        </table>
+
+        <!-- Text displayed if no history exists -->
+        <p class="placeholder" ng-show="isHistoryEmpty()">
+            {{'SETTINGS_CONNECTION_HISTORY.INFO_NO_HISTORY' | translate}}
+        </p>
+
+        <!-- Pager for history list -->
+        <guac-pager page="historyEntryWrapperPage" page-size="25"
+                    items="historyEntryWrappers | orderBy : order.predicate"></guac-pager>
+    </div>
+
+</div>
diff --git a/guacamole/src/main/webapp/app/settings/templates/settingsConnections.html b/guacamole/src/main/webapp/app/settings/templates/settingsConnections.html
new file mode 100644
index 0000000..ca61736
--- /dev/null
+++ b/guacamole/src/main/webapp/app/settings/templates/settingsConnections.html
@@ -0,0 +1,60 @@
+<div class="settings section connections" ng-class="{loading: !isLoaded()}">
+    <!--
+    Copyright 2015 Glyptodon LLC.
+
+    Permission is hereby granted, free of charge, to any person obtaining a copy
+    of this software and associated documentation files (the "Software"), to deal
+    in the Software without restriction, including without limitation the rights
+    to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+    copies of the Software, and to permit persons to whom the Software is
+    furnished to do so, subject to the following conditions:
+
+    The above copyright notice and this permission notice shall be included in
+    all copies or substantial portions of the Software.
+
+    THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+    IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+    FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+    AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+    LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+    OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+    THE SOFTWARE.
+    -->
+
+    <!-- Connection management -->
+    <p>{{'SETTINGS_CONNECTIONS.HELP_CONNECTIONS' | translate}}</p>
+
+    <!-- Connection management toolbar -->
+    <div class="toolbar">
+
+        <!-- Form action buttons -->
+        <div class="action-buttons">
+
+            <a class="add-connection button"
+               ng-show="canCreateConnections()"
+               href="#/manage/{{dataSource}}/connections/">{{'SETTINGS_CONNECTIONS.ACTION_NEW_CONNECTION' | translate}}</a>
+
+            <a class="add-connection-group button"
+               ng-show="canCreateConnectionGroups()"
+               href="#/manage/{{dataSource}}/connectionGroups/">{{'SETTINGS_CONNECTIONS.ACTION_NEW_CONNECTION_GROUP' | translate}}</a>
+
+        </div>
+
+        <!-- Connection filter -->
+        <guac-group-list-filter connection-groups="rootGroups"
+            filtered-connection-groups="filteredRootGroups"
+            placeholder="'SETTINGS_CONNECTIONS.FIELD_PLACEHOLDER_FILTER' | translate"
+            connection-properties="filteredConnectionProperties"
+            connection-group-properties="filteredConnectionGroupProperties"></guac-group-list-filter>
+
+    </div>
+
+    <!-- List of accessible connections and groups -->
+    <div class="connection-list">
+        <guac-group-list
+            page-size="25"
+            connection-groups="filteredRootGroups"
+            connection-template="'app/settings/templates/connection.html'"
+            connection-group-template="'app/settings/templates/connectionGroup.html'"/>
+    </div>
+</div>
diff --git a/guacamole/src/main/webapp/app/settings/templates/settingsPreferences.html b/guacamole/src/main/webapp/app/settings/templates/settingsPreferences.html
new file mode 100644
index 0000000..e202c88
--- /dev/null
+++ b/guacamole/src/main/webapp/app/settings/templates/settingsPreferences.html
@@ -0,0 +1,122 @@
+<div class="preferences" ng-class="{loading: !isLoaded()}">
+    <!--
+    Copyright 2015 Glyptodon LLC.
+
+    Permission is hereby granted, free of charge, to any person obtaining a copy
+    of this software and associated documentation files (the "Software"), to deal
+    in the Software without restriction, including without limitation the rights
+    to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+    copies of the Software, and to permit persons to whom the Software is
+    furnished to do so, subject to the following conditions:
+
+    The above copyright notice and this permission notice shall be included in
+    all copies or substantial portions of the Software.
+
+    THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+    IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+    FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+    AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+    LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+    OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+    THE SOFTWARE.
+    -->
+
+    <!-- Language settings -->
+    <div class="settings section language">
+        <p>{{'SETTINGS_PREFERENCES.HELP_LANGUAGE' | translate}}</p>
+
+        <!-- Language selection -->
+        <div class="form">
+            <table class="fields">
+                <tr>
+                    <th>{{'SETTINGS_PREFERENCES.FIELD_HEADER_LANGUAGE' | translate}}</th>
+                    <td><select ng-model="preferences.language" ng-change="changeLanguage()" ng-options="key as name for (key, name) in languages | orderBy: name"></select></td>
+                </tr>
+            </table>
+        </div>
+    </div>
+    
+    <!-- Password update -->
+    <h2 class="header" ng-show="canChangePassword">{{'SETTINGS_PREFERENCES.SECTION_HEADER_UPDATE_PASSWORD' | translate}}</h2>
+    <div class="settings section update-password" ng-show="canChangePassword">
+        <p>{{'SETTINGS_PREFERENCES.HELP_UPDATE_PASSWORD' | translate}}</p>
+
+        <!-- Password editor -->
+        <div class="form">
+            <table class="fields">
+                <tr>
+                    <th>{{'SETTINGS_PREFERENCES.FIELD_HEADER_PASSWORD_OLD' | translate}}</th>
+                    <td><input ng-model="oldPassword" type="password" /></td>
+                </tr>
+                <tr>
+                    <th>{{'SETTINGS_PREFERENCES.FIELD_HEADER_PASSWORD_NEW' | translate}}</th>
+                    <td><input ng-model="newPassword" type="password" /></td>
+                </tr>
+                <tr>
+                    <th>{{'SETTINGS_PREFERENCES.FIELD_HEADER_PASSWORD_NEW_AGAIN' | translate}}</th>
+                    <td><input ng-model="newPasswordMatch" type="password" /></td>
+                </tr>
+            </table>
+        </div>
+
+        <!-- Form action buttons -->
+        <div class="action-buttons">
+            <button class="change-password" ng-click="updatePassword()">{{'SETTINGS_PREFERENCES.ACTION_UPDATE_PASSWORD' | translate}}</button>
+        </div>
+    </div>
+
+    <!-- Input method -->
+    <h2 class="header">{{'SETTINGS_PREFERENCES.SECTION_HEADER_DEFAULT_INPUT_METHOD' | translate}}</h2>
+    <div class="settings section input-method">
+        <p>{{'SETTINGS_PREFERENCES.HELP_DEFAULT_INPUT_METHOD' | translate}}</p>
+        <div class="choices">
+
+            <!-- No IME -->
+            <div class="choice">
+                <label><input id="ime-none" name="input-method" ng-model="preferences.inputMethod" type="radio" value="none"/> {{'SETTINGS_PREFERENCES.NAME_INPUT_METHOD_NONE' | translate}}</label>
+                <p class="caption"><label for="ime-none">{{'SETTINGS_PREFERENCES.HELP_INPUT_METHOD_NONE' | translate}}</label></p>
+            </div>
+
+            <!-- Text input -->
+            <div class="choice">
+                <label><input id="ime-text" name="input-method" ng-model="preferences.inputMethod" type="radio" value="text"/> {{'SETTINGS_PREFERENCES.NAME_INPUT_METHOD_TEXT' | translate}}</label>
+                <p class="caption"><label for="ime-text">{{'SETTINGS_PREFERENCES.HELP_INPUT_METHOD_TEXT' | translate}} </label></p>
+            </div>
+
+            <!-- Guac OSK -->
+            <div class="choice">
+                <label><input id="ime-osk" name="input-method" ng-model="preferences.inputMethod" type="radio" value="osk"/> {{'SETTINGS_PREFERENCES.NAME_INPUT_METHOD_OSK' | translate}}</label>
+                <p class="caption"><label for="ime-osk">{{'SETTINGS_PREFERENCES.HELP_INPUT_METHOD_OSK' | translate}}</label></p>
+            </div>
+
+        </div>
+    </div>
+
+    <!-- Mouse mode -->
+    <h2 class="header">{{'SETTINGS_PREFERENCES.SECTION_HEADER_DEFAULT_MOUSE_MODE' | translate}}</h2>
+    <div class="settings section mouse-mode">
+        <p>{{'SETTINGS_PREFERENCES.HELP_DEFAULT_MOUSE_MODE' | translate}}</p>
+        <div class="choices">
+
+            <!-- Touchscreen -->
+            <div class="choice">
+                <input name="mouse-mode" ng-model="preferences.emulateAbsoluteMouse" type="radio" ng-value="true" checked="checked" id="absolute"/>
+                <div class="figure">
+                    <label for="absolute"><img src="images/settings/touchscreen.png" alt="{{'SETTINGS_PREFERENCES.NAME_MOUSE_MODE_ABSOLUTE' | translate}}"/></label>
+                    <p class="caption"><label for="absolute">{{'SETTINGS_PREFERENCES.HELP_MOUSE_MODE_ABSOLUTE' | translate}}</label></p>
+                </div>
+            </div>
+
+            <!-- Touchpad -->
+            <div class="choice">
+                <input name="mouse-mode" ng-model="preferences.emulateAbsoluteMouse" type="radio" ng-value="false" id="relative"/>
+                <div class="figure">
+                    <label for="relative"><img src="images/settings/touchpad.png" alt="{{'SETTINGS_PREFERENCES.NAME_MOUSE_MODE_RELATIVE' | translate}}"/></label>
+                    <p class="caption"><label for="relative">{{'SETTINGS_PREFERENCES.HELP_MOUSE_MODE_RELATIVE' | translate}}</label></p>
+                </div>
+            </div>
+
+        </div>
+    </div>
+
+</div>
diff --git a/guacamole/src/main/webapp/app/settings/templates/settingsSessions.html b/guacamole/src/main/webapp/app/settings/templates/settingsSessions.html
new file mode 100644
index 0000000..405c9d3
--- /dev/null
+++ b/guacamole/src/main/webapp/app/settings/templates/settingsSessions.html
@@ -0,0 +1,77 @@
+<div class="settings section sessions" ng-class="{loading: !isLoaded()}">
+    <!--
+    Copyright 2015 Glyptodon LLC.
+
+    Permission is hereby granted, free of charge, to any person obtaining a copy
+    of this software and associated documentation files (the "Software"), to deal
+    in the Software without restriction, including without limitation the rights
+    to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+    copies of the Software, and to permit persons to whom the Software is
+    furnished to do so, subject to the following conditions:
+
+    The above copyright notice and this permission notice shall be included in
+    all copies or substantial portions of the Software.
+
+    THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+    IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+    FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+    AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+    LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+    OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+    THE SOFTWARE.
+    -->
+
+    <!-- User Session management -->
+    <p>{{'SETTINGS_SESSIONS.HELP_SESSIONS' | translate}}</p>
+
+    <!-- Form action buttons -->
+    <div class="action-buttons">
+        <button class="delete-sessions danger" ng-disabled="!canDeleteSessions()" ng-click="deleteSessions()">{{'SETTINGS_SESSIONS.ACTION_DELETE' | translate}}</button>
+    </div>
+
+    <!-- Session filter -->
+    <guac-filter filtered-items="filteredWrappers" items="wrappers"
+                 placeholder="'SETTINGS_SESSIONS.FIELD_PLACEHOLDER_FILTER' | translate"
+                 properties="filteredWrapperProperties"></guac-filter>
+
+    <!-- List of current user sessions -->
+    <table class="sorted session-list">
+        <thead>
+            <tr>
+                <th class="select-session"></th>
+                <th guac-sort-order="wrapperOrder" guac-sort-property="'activeConnection.username'">
+                    {{'SETTINGS_SESSIONS.TABLE_HEADER_SESSION_USERNAME' | translate}}
+                </th>
+                <th guac-sort-order="wrapperOrder" guac-sort-property="'startDate'">
+                    {{'SETTINGS_SESSIONS.TABLE_HEADER_SESSION_STARTDATE' | translate}}
+                </th>
+                <th guac-sort-order="wrapperOrder" guac-sort-property="'activeConnection.remoteHost'">
+                    {{'SETTINGS_SESSIONS.TABLE_HEADER_SESSION_REMOTEHOST' | translate}}
+                </th>
+                <th guac-sort-order="wrapperOrder" guac-sort-property="'name'">
+                    {{'SETTINGS_SESSIONS.TABLE_HEADER_SESSION_CONNECTION_NAME' | translate}}
+                </th>
+            </tr>
+        </thead>
+        <tbody>
+            <tr ng-repeat="wrapper in wrapperPage" class="session">
+                <td class="select-session">
+                    <input ng-change="wrapperSelectionChange(wrapper)" type="checkbox" ng-model="wrapper.checked" />
+                </td>
+                <td>{{wrapper.activeConnection.username}}</td>
+                <td>{{wrapper.startDate}}</td>
+                <td>{{wrapper.activeConnection.remoteHost}}</td>
+                <td>{{wrapper.name}}</td>
+            </tr>
+        </tbody>
+    </table>
+
+    <!-- Text displayed if no sessions exist -->
+    <p class="placeholder" ng-hide="wrapperPage.length">
+        {{'SETTINGS_SESSIONS.INFO_NO_SESSIONS' | translate}}
+    </p>
+
+    <!-- Pager for session list -->
+    <guac-pager page="wrapperPage" page-size="25"
+                items="filteredWrappers | orderBy : wrapperOrder.predicate"></guac-pager>
+</div>
diff --git a/guacamole/src/main/webapp/app/settings/templates/settingsUsers.html b/guacamole/src/main/webapp/app/settings/templates/settingsUsers.html
new file mode 100644
index 0000000..e6bd5c9
--- /dev/null
+++ b/guacamole/src/main/webapp/app/settings/templates/settingsUsers.html
@@ -0,0 +1,60 @@
+<div class="settings section users" ng-class="{loading: !isLoaded()}">
+    <!--
+    Copyright 2015 Glyptodon LLC.
+
+    Permission is hereby granted, free of charge, to any person obtaining a copy
+    of this software and associated documentation files (the "Software"), to deal
+    in the Software without restriction, including without limitation the rights
+    to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+    copies of the Software, and to permit persons to whom the Software is
+    furnished to do so, subject to the following conditions:
+
+    The above copyright notice and this permission notice shall be included in
+    all copies or substantial portions of the Software.
+
+    THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+    IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+    FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+    AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+    LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+    OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+    THE SOFTWARE.
+    -->
+
+    <!-- User management -->
+    <p>{{'SETTINGS_USERS.HELP_USERS' | translate}}</p>
+
+
+    <!-- User management toolbar -->
+    <div class="toolbar">
+
+        <!-- Form action buttons -->
+        <div class="action-buttons">
+            <a class="add-user button" ng-show="canCreateUsers()"
+               href="#/manage/{{getDefaultDataSource()}}/users/">{{'SETTINGS_USERS.ACTION_NEW_USER' | translate}}</a>
+        </div>
+
+        <!-- User filter -->
+        <guac-filter filtered-items="filteredManageableUsers" items="manageableUsers"
+                     placeholder="'SETTINGS_USERS.FIELD_PLACEHOLDER_FILTER' | translate"
+                     properties="filteredUserProperties"></guac-filter>
+
+    </div>
+
+    <!-- List of users this user has access to -->
+    <div class="user-list">
+        <div ng-repeat="manageableUser in manageableUserPage" class="user list-item">
+            <a ng-href="#/manage/{{manageableUser.dataSource}}/users/{{manageableUser.user.username}}">
+                <div class="caption">
+                    <div class="icon user"></div>
+                    <span class="name">{{manageableUser.user.username}}</span>
+                </div>
+            </a>
+        </div>
+    </div>
+
+    <!-- Pager controls for user list -->
+    <guac-pager page="manageableUserPage" page-size="25"
+                items="filteredManageableUsers | orderBy : 'user.username'"></guac-pager>
+
+</div>
\ No newline at end of file
diff --git a/guacamole/src/main/webapp/app/settings/types/ActiveConnectionWrapper.js b/guacamole/src/main/webapp/app/settings/types/ActiveConnectionWrapper.js
new file mode 100644
index 0000000..abc1af2
--- /dev/null
+++ b/guacamole/src/main/webapp/app/settings/types/ActiveConnectionWrapper.js
@@ -0,0 +1,80 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * A service for defining the ActiveConnectionWrapper class.
+ */
+angular.module('settings').factory('ActiveConnectionWrapper', [
+    function defineActiveConnectionWrapper() {
+
+    /**
+     * Wrapper for ActiveConnection which adds display-specific
+     * properties, such as a checked option.
+     * 
+     * @constructor
+     * @param {ActiveConnectionWrapper|Object} template
+     *     The object whose properties should be copied within the new
+     *     ActiveConnectionWrapper.
+     */
+    var ActiveConnectionWrapper = function ActiveConnectionWrapper(template) {
+
+        /**
+         * The identifier of the data source associated with the
+         * ActiveConnection wrapped by this ActiveConnectionWrapper.
+         *
+         * @type String
+         */
+        this.dataSource = template.dataSource;
+
+        /**
+         * The display name of this connection.
+         *
+         * @type String
+         */
+        this.name = template.name;
+
+        /**
+         * The date and time this session began, pre-formatted for display.
+         *
+         * @type String
+         */
+        this.startDate = template.startDate;
+
+        /**
+         * The wrapped ActiveConnection.
+         *
+         * @type ActiveConnection
+         */
+        this.activeConnection = template.activeConnection;
+
+        /**
+         * A flag indicating that the active connection has been selected.
+         *
+         * @type Boolean
+         */
+        this.checked = template.checked || false;
+
+    };
+
+    return ActiveConnectionWrapper;
+
+}]);
\ No newline at end of file
diff --git a/guacamole/src/main/webapp/app/settings/types/ConnectionHistoryEntryWrapper.js b/guacamole/src/main/webapp/app/settings/types/ConnectionHistoryEntryWrapper.js
new file mode 100644
index 0000000..e2e3726
--- /dev/null
+++ b/guacamole/src/main/webapp/app/settings/types/ConnectionHistoryEntryWrapper.js
@@ -0,0 +1,121 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * A service for defining the ConnectionHistoryEntryWrapper class.
+ */
+angular.module('settings').factory('ConnectionHistoryEntryWrapper', ['$injector',
+    function defineConnectionHistoryEntryWrapper($injector) {
+
+    // Required types
+    var ConnectionHistoryEntry = $injector.get('ConnectionHistoryEntry');
+
+    /**
+     * Wrapper for ConnectionHistoryEntry which adds display-specific
+     * properties, such as a duration.
+     *
+     * @constructor
+     * @param {ConnectionHistoryEntry} historyEntry
+     *     The ConnectionHistoryEntry that should be wrapped.
+     */
+    var ConnectionHistoryEntryWrapper = function ConnectionHistoryEntryWrapper(historyEntry) {
+
+        /**
+         * The identifier of the connection associated with this history entry.
+         *
+         * @type String
+         */
+        this.connectionIdentifier = historyEntry.connectionIdentifier;
+
+        /**
+         * The name of the connection associated with this history entry.
+         *
+         * @type String
+         */
+        this.connectionName = historyEntry.connectionName;
+
+        /**
+         * The username of the user associated with this particular usage of
+         * the connection.
+         *
+         * @type String
+         */
+        this.username = historyEntry.username;
+
+        /**
+         * The time that usage began, in seconds since 1970-01-01 00:00:00 UTC.
+         *
+         * @type Number
+         */
+        this.startDate = historyEntry.startDate;
+
+        /**
+         * The time that usage ended, in seconds since 1970-01-01 00:00:00 UTC.
+         * The absence of an endDate does NOT necessarily indicate that the
+         * connection is still in use, particularly if the server was shutdown
+         * or restarted before the history entry could be updated. To determine
+         * whether a connection is still active, check the active property of
+         * this history entry.
+         *
+         * @type Number
+         */
+        this.endDate = historyEntry.endDate;
+
+        /**
+         * The total amount of time the connection associated with the wrapped
+         * history record was open, in seconds.
+         *
+         * @type Number
+         */
+        this.duration = this.endDate - this.startDate;
+
+        /**
+         * An object providing value and unit properties, denoting the duration
+         * and its corresponding units.
+         *
+         * @type ConnectionHistoryEntry.Duration
+         */
+        this.readableDuration = null;
+
+        // Set the duration if the necessary information is present
+        if (this.endDate && this.startDate)
+            this.readableDuration = new ConnectionHistoryEntry.Duration(this.duration);
+
+        /**
+         * The string to display as the duration of this history entry. If a
+         * duration is available, its value and unit will be exposed to any
+         * given translation string as the VALUE and UNIT substitution
+         * variables respectively.
+         *
+         * @type String
+         */
+        this.readableDurationText = 'SETTINGS_CONNECTION_HISTORY.TEXT_HISTORY_DURATION';
+
+        // Inform user if end date is not known
+        if (!this.endDate)
+            this.readableDurationText = 'SETTINGS_CONNECTION_HISTORY.INFO_CONNECTION_DURATION_UNKNOWN';
+
+    };
+
+    return ConnectionHistoryEntryWrapper;
+
+}]);
diff --git a/guacamole/src/main/webapp/app/storage/services/sessionStorageFactory.js b/guacamole/src/main/webapp/app/storage/services/sessionStorageFactory.js
new file mode 100644
index 0000000..018ff9e
--- /dev/null
+++ b/guacamole/src/main/webapp/app/storage/services/sessionStorageFactory.js
@@ -0,0 +1,134 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Factory for session-local storage. Creating session-local storage creates a
+ * getter/setter with semantics tied to the user's session. If a user is logged
+ * in, the storage is consistent. If the user logs out, the storage will not
+ * persist new values, and attempts to retrieve the existing value will result
+ * only in the default value.
+ */
+angular.module('storage').factory('sessionStorageFactory', ['$injector', function sessionStorageFactory($injector) {
+
+    // Required services
+    var $rootScope            = $injector.get('$rootScope');
+    var authenticationService = $injector.get('authenticationService');
+
+    var service = {};
+
+    /**
+     * Creates session-local storage that uses the provided default value or
+     * getter to obtain new values as necessary. Beware that if the default is
+     * an object, the resulting getter provide deep copies for new values.
+     *
+     * @param {Function|*} [template]
+     *     The default value for new users, or a getter which returns a newly-
+     *     created default value.
+     *
+     * @param {Function} [destructor]
+     *     Function which will be called just before the stored value is
+     *     destroyed on logout, if a value is stored.
+     *
+     * @returns {Function}
+     *     A getter/setter which returns or sets the current value of the new
+     *     session-local storage. Newly-set values will only persist of the
+     *     user is actually logged in.
+     */
+    service.create = function create(template, destructor) {
+
+        /**
+         * Whether new values may be stored and retrieved.
+         *
+         * @type Boolean
+         */
+        var enabled = !!authenticationService.getCurrentToken();
+
+        /**
+         * Getter which returns the default value for this storage.
+         *
+         * @type Function
+         */
+        var getter;
+
+        // If getter provided, use that
+        if (typeof template === 'function')
+            getter = template;
+
+        // Otherwise, always create a deep copy
+        else
+            getter = function getCopy() {
+                return angular.copy(template);
+            };
+
+        /**
+         * The current value of this storage, or undefined if not yet set.
+         */
+        var value = undefined;
+
+        // Reset value and allow storage when the user is logged in
+        $rootScope.$on('guacLogin', function userLoggedIn() {
+            enabled = true;
+            value = undefined;
+        });
+
+        // Reset value and disallow storage when the user is logged out
+        $rootScope.$on('guacLogout', function userLoggedOut() {
+
+            // Call destructor before storage is teared down
+            if (angular.isDefined(value) && destructor)
+                destructor(value);
+
+            // Destroy storage
+            enabled = false;
+            value = undefined;
+
+        });
+
+        // Return getter/setter for value
+        return function sessionLocalGetterSetter(newValue) {
+
+            // Only actually store/retrieve values if enabled
+            if (enabled) {
+
+                // Set value if provided
+                if (angular.isDefined(newValue))
+                    value = newValue;
+
+                // Obtain new value if unset
+                if (!angular.isDefined(value))
+                    value = getter();
+
+                // Return current value
+                return value;
+
+            }
+
+            // Otherwise, just pretend to store/retrieve
+            return angular.isDefined(newValue) ? newValue : getter();
+
+        };
+
+    };
+
+    return service;
+
+}]);
diff --git a/guacamole/src/main/webapp/app/storage/storageModule.js b/guacamole/src/main/webapp/app/storage/storageModule.js
new file mode 100644
index 0000000..f41860e
--- /dev/null
+++ b/guacamole/src/main/webapp/app/storage/storageModule.js
@@ -0,0 +1,28 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Module which provides generic storage services.
+ */
+angular.module('storage', [
+    'auth'
+]);
diff --git a/guacamole/src/main/webapp/app/textInput/directives/guacKey.js b/guacamole/src/main/webapp/app/textInput/directives/guacKey.js
new file mode 100644
index 0000000..f6dd6f4
--- /dev/null
+++ b/guacamole/src/main/webapp/app/textInput/directives/guacKey.js
@@ -0,0 +1,115 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * A directive which displays a button that controls the pressed state of a
+ * single keyboard key.
+ */
+angular.module('textInput').directive('guacKey', [function guacKey() {
+
+    return {
+        restrict: 'E',
+        replace: true,
+        scope: {
+
+            /**
+             * The text to display within the key. This will be run through the
+             * translation filter prior to display.
+             * 
+             * @type String
+             */
+            text    : '=',
+
+            /**
+             * The keysym to send within keyup and keydown events when this key
+             * is pressed or released.
+             * 
+             * @type Number
+             */
+            keysym  : '=',
+
+            /**
+             * Whether this key is sticky. Sticky keys toggle their pressed
+             * state with each click.
+             * 
+             * @type Boolean
+             * @default false
+             */
+            sticky  : '=?',
+
+            /**
+             * Whether this key is currently pressed.
+             * 
+             * @type Boolean
+             * @default false
+             */
+            pressed : '=?'
+
+        },
+
+        templateUrl: 'app/textInput/templates/guacKey.html',
+        controller: ['$scope', '$rootScope',
+            function guacKey($scope, $rootScope) {
+
+            // Not sticky by default
+            $scope.sticky = $scope.sticky || false;
+
+            // Unpressed by default
+            $scope.pressed = $scope.pressed || false;
+
+            /**
+             * Presses and releases this key, sending the corresponding keydown
+             * and keyup events. In the case of sticky keys, the pressed state
+             * is toggled, and only a single keydown/keyup event will be sent,
+             * depending on the current state.
+             */
+            $scope.updateKey = function updateKey() {
+
+                // If sticky, toggle pressed state
+                if ($scope.sticky)
+                    $scope.pressed = !$scope.pressed;
+
+                // For all non-sticky keys, press and release key immediately
+                else {
+                    $rootScope.$broadcast('guacSyntheticKeydown', $scope.keysym);
+                    $rootScope.$broadcast('guacSyntheticKeyup', $scope.keysym);
+                }
+
+            };
+
+            // Send keyup/keydown when pressed state is altered
+            $scope.$watch('pressed', function updatePressedState(isPressed, wasPressed) {
+
+                // If the key is pressed now, send keydown
+                if (isPressed)
+                    $rootScope.$broadcast('guacSyntheticKeydown', $scope.keysym);
+
+                // If the key was pressed, but is not pressed any longer, send keyup
+                else if (wasPressed)
+                    $rootScope.$broadcast('guacSyntheticKeyup', $scope.keysym);
+
+            });
+
+        }]
+
+    };
+}]);
diff --git a/guacamole/src/main/webapp/app/textInput/directives/guacTextInput.js b/guacamole/src/main/webapp/app/textInput/directives/guacTextInput.js
new file mode 100644
index 0000000..00d3fd2
--- /dev/null
+++ b/guacamole/src/main/webapp/app/textInput/directives/guacTextInput.js
@@ -0,0 +1,362 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * A directive which displays the Guacamole text input method.
+ */
+angular.module('textInput').directive('guacTextInput', [function guacTextInput() {
+
+    return {
+        restrict: 'E',
+        replace: true,
+        scope: {
+
+            /**
+             * Whether the text input UI should have focus. Setting this value
+             * is not guaranteed to work, due to browser limitations.
+             * 
+             * @type Boolean
+             */
+            needsFocus : '=?'
+
+        },
+
+        templateUrl: 'app/textInput/templates/guacTextInput.html',
+        controller: ['$scope', '$rootScope', '$element', '$timeout',
+            function guacTextInput($scope, $rootScope, $element, $timeout) {
+
+            /**
+             * The number of characters to include on either side of text input
+             * content, to allow the user room to use backspace and delete.
+             *
+             * @type Number
+             */
+            var TEXT_INPUT_PADDING = 4;
+
+            /**
+             * The Unicode codepoint of the character to use for padding on
+             * either side of text input content.
+             *
+             * @type Number
+             */
+            var TEXT_INPUT_PADDING_CODEPOINT = 0x200B;
+
+            /**
+             * Keys which should be allowed through to the client when in text
+             * input mode, providing corresponding key events are received.
+             * Keys in this set will be allowed through to the server.
+             * 
+             * @type Object.<Number, Boolean>
+             */
+            var ALLOWED_KEYS = {
+                0xFE03: true, /* AltGr */
+                0xFF08: true, /* Backspace */
+                0xFF09: true, /* Tab */
+                0xFF0D: true, /* Enter */
+                0xFF1B: true, /* Escape */
+                0xFF50: true, /* Home */
+                0xFF51: true, /* Left */
+                0xFF52: true, /* Up */
+                0xFF53: true, /* Right */
+                0xFF54: true, /* Down */
+                0xFF57: true, /* End */
+                0xFF64: true, /* Insert */
+                0xFFBE: true, /* F1 */
+                0xFFBF: true, /* F2 */
+                0xFFC0: true, /* F3 */
+                0xFFC1: true, /* F4 */
+                0xFFC2: true, /* F5 */
+                0xFFC3: true, /* F6 */
+                0xFFC4: true, /* F7 */
+                0xFFC5: true, /* F8 */
+                0xFFC6: true, /* F9 */
+                0xFFC7: true, /* F10 */
+                0xFFC8: true, /* F11 */
+                0xFFC9: true, /* F12 */
+                0xFFE1: true, /* Left shift */
+                0xFFE2: true, /* Right shift */
+                0xFFE3: true, /* Left ctrl */
+                0xFFE4: true, /* Right ctrl */
+                0xFFE9: true, /* Left alt */
+                0xFFEA: true, /* Right alt */
+                0xFFFF: true  /* Delete */
+            };
+
+            /**
+             * Recently-sent text, ordered from oldest to most recent.
+             *
+             * @type String[]
+             */
+            $scope.sentText = [];
+
+            /**
+             * Whether the "Alt" key is currently pressed within the text input
+             * interface.
+             * 
+             * @type Boolean
+             */
+            $scope.altPressed = false;
+
+            /**
+             * Whether the "Ctrl" key is currently pressed within the text
+             * input interface.
+             * 
+             * @type Boolean
+             */
+            $scope.ctrlPressed = false;
+
+            /**
+             * The text area input target.
+             *
+             * @type Element
+             */
+            var target = $element.find('.target')[0];
+
+            /**
+             * Whether the text input target currently has focus. Setting this
+             * attribute has no effect, but any bound property will be updated
+             * as focus is gained or lost.
+             *
+             * @type Boolean
+             */
+            var hasFocus = false;
+
+            target.onfocus = function targetFocusGained() {
+                hasFocus = true;
+                resetTextInputTarget(TEXT_INPUT_PADDING);
+            };
+
+            target.onblur = function targetFocusLost() {
+                hasFocus = false;
+                target.focus();
+            };
+
+            /**
+             * Whether composition is currently active within the text input
+             * target element, such as when an IME is in use.
+             *
+             * @type Boolean
+             */
+            var composingText = false;
+
+            target.addEventListener("compositionstart", function targetComposeStart(e) {
+                composingText = true;
+            }, false);
+
+            target.addEventListener("compositionend", function targetComposeEnd(e) {
+                composingText = false;
+            }, false);
+
+            /**
+             * Translates a given Unicode codepoint into the corresponding X11
+             * keysym.
+             * 
+             * @param {Number} codepoint
+             *     The Unicode codepoint to translate.
+             *
+             * @returns {Number}
+             *     The X11 keysym that corresponds to the given Unicode
+             *     codepoint, or null if no such keysym exists.
+             */
+            var keysymFromCodepoint = function keysymFromCodepoint(codepoint) {
+
+                // Keysyms for control characters
+                if (codepoint <= 0x1F || (codepoint >= 0x7F && codepoint <= 0x9F))
+                    return 0xFF00 | codepoint;
+
+                // Keysyms for ASCII chars
+                if (codepoint >= 0x0000 && codepoint <= 0x00FF)
+                    return codepoint;
+
+                // Keysyms for Unicode
+                if (codepoint >= 0x0100 && codepoint <= 0x10FFFF)
+                    return 0x01000000 | codepoint;
+
+                return null;
+
+            };
+
+            /**
+             * Presses and releases the key corresponding to the given keysym,
+             * as if typed by the user.
+             * 
+             * @param {Number} keysym The keysym of the key to send.
+             */
+            var sendKeysym = function sendKeysym(keysym) {
+                $rootScope.$broadcast('guacSyntheticKeydown', keysym);
+                $rootScope.$broadcast('guacSyntheticKeyup', keysym);
+            };
+
+            /**
+             * Presses and releases the key having the keysym corresponding to
+             * the Unicode codepoint given, as if typed by the user.
+             * 
+             * @param {Number} codepoint
+             *     The Unicode codepoint of the key to send.
+             */
+            var sendCodepoint = function sendCodepoint(codepoint) {
+
+                if (codepoint === 10) {
+                    sendKeysym(0xFF0D);
+                    releaseStickyKeys();
+                    return;
+                }
+
+                var keysym = keysymFromCodepoint(codepoint);
+                if (keysym) {
+                    sendKeysym(keysym);
+                    releaseStickyKeys();
+                }
+
+            };
+
+            /**
+             * Translates each character within the given string to keysyms and
+             * sends each, in order, as if typed by the user.
+             * 
+             * @param {String} content
+             *     The string to send.
+             */
+            var sendString = function sendString(content) {
+
+                var sentText = "";
+
+                // Send each codepoint within the string
+                for (var i=0; i<content.length; i++) {
+                    var codepoint = content.charCodeAt(i);
+                    if (codepoint !== TEXT_INPUT_PADDING_CODEPOINT) {
+                        sentText += String.fromCharCode(codepoint);
+                        sendCodepoint(codepoint);
+                    }
+                }
+
+                // Display the text that was sent
+                $scope.$apply(function addSentText() {
+                    $scope.sentText.push(sentText);
+                });
+
+                // Remove text after one second
+                $timeout(function removeSentText() {
+                    $scope.sentText.shift();
+                }, 1000);
+
+            };
+
+            /**
+             * Releases all currently-held sticky keys within the text input UI.
+             */
+            var releaseStickyKeys = function releaseStickyKeys() {
+
+                // Reset all sticky keys
+                $scope.$apply(function clearAllStickyKeys() {
+                    $scope.altPressed = false;
+                    $scope.ctrlPressed = false;
+                });
+
+            };
+
+            /**
+             * Removes all content from the text input target, replacing it
+             * with the given number of padding characters. Padding of the
+             * requested size is added on both sides of the cursor, thus the
+             * overall number of characters added will be twice the number
+             * specified.
+             * 
+             * @param {Number} padding
+             *     The number of characters to pad the text area with.
+             */
+            var resetTextInputTarget = function resetTextInputTarget(padding) {
+
+                var paddingChar = String.fromCharCode(TEXT_INPUT_PADDING_CODEPOINT);
+
+                // Pad text area with an arbitrary, non-typable character (so there is something
+                // to delete with backspace or del), and position cursor in middle.
+                target.value = new Array(padding*2 + 1).join(paddingChar);
+                target.setSelectionRange(padding, padding);
+
+            };
+
+            target.addEventListener("input", function(e) {
+
+                // Ignore input events during text composition
+                if (composingText)
+                    return;
+
+                var i;
+                var content = target.value;
+                var expectedLength = TEXT_INPUT_PADDING*2;
+
+                // If content removed, update
+                if (content.length < expectedLength) {
+
+                    // Calculate number of backspaces and send
+                    var backspaceCount = TEXT_INPUT_PADDING - target.selectionStart;
+                    for (i = 0; i < backspaceCount; i++)
+                        sendKeysym(0xFF08);
+
+                    // Calculate number of deletes and send
+                    var deleteCount = expectedLength - content.length - backspaceCount;
+                    for (i = 0; i < deleteCount; i++)
+                        sendKeysym(0xFFFF);
+
+                }
+
+                else
+                    sendString(content);
+
+                // Reset content
+                resetTextInputTarget(TEXT_INPUT_PADDING);
+                e.preventDefault();
+
+            }, false);
+
+            // Do not allow event target contents to be selected during input
+            target.addEventListener("selectstart", function(e) {
+                e.preventDefault();
+            }, false);
+
+            // Attempt to change focus depending on need
+            $scope.$watch('needsFocus', function focusDesireChanged(focusNeeded) {
+
+                if (focusNeeded)
+                    target.focus();
+                else
+                    target.blur();
+
+            });
+
+            // If the text input UI has focus, prevent keydown events
+            $scope.$on('guacBeforeKeydown', function filterKeydown(event, keysym) {
+                if (hasFocus && !ALLOWED_KEYS[keysym])
+                    event.preventDefault();
+            });
+
+            // If the text input UI has focus, prevent keyup events
+            $scope.$on('guacBeforeKeyup', function filterKeyup(event, keysym) {
+                if (hasFocus && !ALLOWED_KEYS[keysym])
+                    event.preventDefault();
+            });
+
+        }]
+
+    };
+}]);
diff --git a/guacamole/src/main/webapp/app/textInput/styles/textInput.css b/guacamole/src/main/webapp/app/textInput/styles/textInput.css
new file mode 100644
index 0000000..e324329
--- /dev/null
+++ b/guacamole/src/main/webapp/app/textInput/styles/textInput.css
@@ -0,0 +1,125 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+.text-input {
+    width: 100%;
+    background: #222;
+    color: white;
+}
+
+.text-input .text-input-field,
+.text-input .text-input-buttons {
+    display: inline-block;
+    vertical-align: middle;
+}
+
+.text-input .text-input-field {
+    width: 30%;
+    overflow: hidden;
+    white-space: nowrap;
+}
+
+.text-input .text-input-buttons {
+    width: 70%;
+    text-align: right;
+}
+
+.text-input .target {
+
+    border: none;
+    border-radius: 0;
+
+    display: inline-block;
+    vertical-align: middle;
+    color: white;
+    font-size: 12pt;
+    width: 100%;
+    height: auto;
+    resize: none;
+    outline: none;
+
+    margin: 0;
+    padding: 0.25em;
+    padding-left: 0;
+    background: transparent;
+    overflow: hidden;
+
+}
+
+.text-input.open {
+    display: block;
+}
+
+.text-input .sent-history {
+    display: inline-block;
+    vertical-align: middle;
+    padding: 0.25em;
+    padding-right: 0;
+}
+
+.text-input .sent-history .sent-text {
+    display: inline-block;
+    vertical-align: baseline;
+    white-space: pre;
+    font-size: 12pt;
+
+    animation: fadeout 1s linear;
+    -webkit-animation: fadeout 1s linear;
+    opacity: 0;
+}
+
+.text-input .text-input-buttons button {
+
+    box-shadow: none;
+    padding: 0.25em;
+    max-width: 20%;
+    margin: 0.1em;
+    min-width: 3em;
+
+    background: #444;
+
+    border: 0.125em solid #666;
+    -moz-border-radius:    0.25em;
+    -webkit-border-radius: 0.25em;
+    -khtml-border-radius:  0.25em;
+    border-radius:         0.25em;
+
+    color: white;
+    font-weight: lighter;
+    text-align: center;
+
+    text-shadow:  1px  1px 0 rgba(0, 0, 0, 0.25),
+                  1px -1px 0 rgba(0, 0, 0, 0.25),
+                 -1px  1px 0 rgba(0, 0, 0, 0.25),
+                 -1px -1px 0 rgba(0, 0, 0, 0.25);
+
+}
+
+.text-input .text-input-buttons button:active {
+    background: #822;
+    border-color: #D44;
+}
+
+.text-input .text-input-buttons button.pressed {
+    background: #882;
+    border-color: #DD4;
+}
diff --git a/guacamole/src/main/webapp/app/textInput/templates/guacKey.html b/guacamole/src/main/webapp/app/textInput/templates/guacKey.html
new file mode 100644
index 0000000..7daa105
--- /dev/null
+++ b/guacamole/src/main/webapp/app/textInput/templates/guacKey.html
@@ -0,0 +1,25 @@
+<button class="key" ng-click="updateKey()" ng-class="{pressed: pressed, sticky: sticky}">
+    <!--
+       Copyright (C) 2014 Glyptodon LLC
+
+       Permission is hereby granted, free of charge, to any person obtaining a copy
+       of this software and associated documentation files (the "Software"), to deal
+       in the Software without restriction, including without limitation the rights
+       to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+       copies of the Software, and to permit persons to whom the Software is
+       furnished to do so, subject to the following conditions:
+
+       The above copyright notice and this permission notice shall be included in
+       all copies or substantial portions of the Software.
+
+       THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+       IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+       FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+       AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+       LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+       OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+       THE SOFTWARE.
+    -->
+
+    {{text | translate}}
+</button>
diff --git a/guacamole/src/main/webapp/app/textInput/templates/guacTextInput.html b/guacamole/src/main/webapp/app/textInput/templates/guacTextInput.html
new file mode 100644
index 0000000..a2511b7
--- /dev/null
+++ b/guacamole/src/main/webapp/app/textInput/templates/guacTextInput.html
@@ -0,0 +1,27 @@
+<div class="text-input">
+    <!--
+       Copyright (C) 2014 Glyptodon LLC
+
+       Permission is hereby granted, free of charge, to any person obtaining a copy
+       of this software and associated documentation files (the "Software"), to deal
+       in the Software without restriction, including without limitation the rights
+       to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+       copies of the Software, and to permit persons to whom the Software is
+       furnished to do so, subject to the following conditions:
+
+       The above copyright notice and this permission notice shall be included in
+       all copies or substantial portions of the Software.
+
+       THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+       IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+       FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+       AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+       LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+       OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+       THE SOFTWARE.
+    -->
+
+    <!-- Text input target -->
+    <div class="text-input-field"><div class="sent-history"><div class="sent-text" ng-repeat="text in sentText track by $index">{{text}}</div></div><textarea rows="1" class="target" autocorrect="off" autocapitalize="off"></textarea></div><div class="text-input-buttons"><guac-key keysym="65507" sticky="true" text="'CLIENT.NAME_KEY_CTRL'" pressed="ctrlPressed"></guac-key><guac-key keysym="65513" sticky="true" text="'CLIENT.NAME_KEY_ALT'" pressed="altPressed"></guac-key><guac-key keysym="65 [...]
+
+</div>
diff --git a/guacamole/src/main/webapp/app/textInput/textInputModule.js b/guacamole/src/main/webapp/app/textInput/textInputModule.js
new file mode 100644
index 0000000..89a1e79
--- /dev/null
+++ b/guacamole/src/main/webapp/app/textInput/textInputModule.js
@@ -0,0 +1,26 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Module for displaying the Guacamole text input method.
+ */
+angular.module('textInput', []);
diff --git a/guacamole/src/main/webapp/app/touch/directives/guacTouchDrag.js b/guacamole/src/main/webapp/app/touch/directives/guacTouchDrag.js
new file mode 100644
index 0000000..9cb8f45
--- /dev/null
+++ b/guacamole/src/main/webapp/app/touch/directives/guacTouchDrag.js
@@ -0,0 +1,192 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * A directive which allows handling of drag gestures on a particular element.
+ */
+angular.module('touch').directive('guacTouchDrag', [function guacTouchDrag() {
+
+    return {
+        restrict: 'A',
+
+        link: function linkGuacTouchDrag($scope, $element, $attrs) {
+
+            /**
+             * Called during a drag gesture as the user's finger is placed upon
+             * the element, moves, and is lifted from the element.
+             *
+             * @event
+             * @param {Boolean} inProgress
+             *     Whether the gesture is currently in progress. This will
+             *     always be true except when the gesture has ended, at which
+             *     point one final call will occur with this parameter set to
+             *     false.
+             *
+             * @param {Number} startX
+             *     The X location at which the drag gesture began.
+             *     
+             * @param {Number} startY
+             *     The Y location at which the drag gesture began.
+             *     
+             * @param {Number} currentX
+             *     The current X location of the user's finger.
+             *     
+             * @param {Number} currentY
+             *     The current Y location of the user's finger.
+             *     
+             * @param {Number} deltaX
+             *     The difference in X location relative to the start of the
+             *     gesture.
+             * 
+             * @param {Number} deltaY
+             *     The difference in Y location relative to the start of the
+             *     gesture.
+             * 
+             * @return {Boolean}
+             *     false if the default action of the touch event should be
+             *     prevented, any other value otherwise.
+             */
+            var guacTouchDrag = $scope.$eval($attrs.guacTouchDrag);
+
+            /**
+             * The element which will register the drag gesture.
+             *
+             * @type Element
+             */
+            var element = $element[0];
+
+            /**
+             * Whether a drag gesture is in progress.
+             * 
+             * @type Boolean
+             */
+            var inProgress = false;
+            
+            /**
+             * The starting X location of the drag gesture.
+             * 
+             * @type Number
+             */
+            var startX = null;
+
+            /**
+             * The starting Y location of the drag gesture.
+             * 
+             * @type Number
+             */
+            var startY = null;
+
+            /**
+             * The current X location of the drag gesture.
+             * 
+             * @type Number
+             */
+            var currentX = null;
+
+            /**
+             * The current Y location of the drag gesture.
+             * 
+             * @type Number
+             */
+            var currentY = null;
+
+            /**
+             * The change in X relative to drag start.
+             * 
+             * @type Number
+             */
+            var deltaX = 0;
+
+            /**
+             * The change in X relative to drag start.
+             * 
+             * @type Number
+             */
+            var deltaY = 0;
+
+            // When there is exactly one touch, monitor the change in location
+            element.addEventListener("touchmove", function dragTouchMove(e) {
+                if (e.touches.length === 1) {
+
+                    e.stopPropagation();
+
+                    // Get touch location
+                    var x = e.touches[0].clientX;
+                    var y = e.touches[0].clientY;
+
+                    // Init start location and deltas if gesture is starting
+                    if (!startX || !startY) {
+                        startX = currentX = x;
+                        startY = currentY = y;
+                        deltaX = 0;
+                        deltaY = 0;
+                        inProgress = true;
+                    }
+
+                    // Update deltas if gesture is in progress
+                    else if (inProgress) {
+                        deltaX = x - currentX;
+                        deltaY = y - currentY;
+                        currentX = x;
+                        currentY = y;
+                    }
+
+                    // Signal start/change in drag gesture
+                    if (inProgress && guacTouchDrag) {
+                        $scope.$apply(function dragChanged() {
+                            if (guacTouchDrag(true, startX, startY, currentX, currentY, deltaX, deltaY) === false)
+                                e.preventDefault();
+                        });
+                    }
+
+                }
+            }, false);
+
+            // Reset monitoring and fire end event when done
+            element.addEventListener("touchend", function dragTouchEnd(e) {
+
+                if (startX && startY && e.touches.length === 0) {
+
+                    e.stopPropagation();
+
+                    // Signal end of drag gesture
+                    if (inProgress && guacTouchDrag) {
+                        $scope.$apply(function dragComplete() {
+                            if (guacTouchDrag(true, startX, startY, currentX, currentY, deltaX, deltaY === false))
+                                e.preventDefault();
+                        });
+                    }
+
+                    startX = currentX = null;
+                    startY = currentY = null;
+                    deltaX = 0;
+                    deltaY = 0;
+                    inProgress = false;
+
+                }
+
+            }, false);
+
+        }
+
+    };
+}]);
diff --git a/guacamole/src/main/webapp/app/touch/directives/guacTouchPinch.js b/guacamole/src/main/webapp/app/touch/directives/guacTouchPinch.js
new file mode 100644
index 0000000..9a3efea
--- /dev/null
+++ b/guacamole/src/main/webapp/app/touch/directives/guacTouchPinch.js
@@ -0,0 +1,213 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * A directive which allows handling of pinch gestures (pinch-to-zoom, for
+ * example) on a particular element.
+ */
+angular.module('touch').directive('guacTouchPinch', [function guacTouchPinch() {
+
+    return {
+        restrict: 'A',
+
+        link: function linkGuacTouchPinch($scope, $element, $attrs) {
+
+            /**
+             * Called when a pinch gesture begins, changes, or ends.
+             *
+             * @event
+             * @param {Boolean} inProgress
+             *     Whether the gesture is currently in progress. This will
+             *     always be true except when the gesture has ended, at which
+             *     point one final call will occur with this parameter set to
+             *     false.
+             *
+             * @param {Number} startLength 
+             *     The initial distance between the two touches of the
+             *     pinch gesture, in pixels.
+             *
+             * @param {Number} currentLength 
+             *     The current distance between the two touches of the
+             *     pinch gesture, in pixels.
+             *
+             * @param {Number} centerX
+             *     The current X coordinate of the center of the pinch gesture.
+             *
+             * @param {Number} centerY
+             *     The current Y coordinate of the center of the pinch gesture.
+             * 
+             * @return {Boolean}
+             *     false if the default action of the touch event should be
+             *     prevented, any other value otherwise.
+             */
+            var guacTouchPinch = $scope.$eval($attrs.guacTouchPinch);
+
+            /**
+             * The element which will register the pinch gesture.
+             *
+             * @type Element
+             */
+            var element = $element[0];
+
+            /**
+             * The starting pinch distance, or null if the gesture has not yet
+             * started.
+             *
+             * @type Number
+             */
+            var startLength = null;
+
+            /**
+             * The current pinch distance, or null if the gesture has not yet
+             * started.
+             *
+             * @type Number
+             */
+            var currentLength = null;
+
+            /**
+             * The X coordinate of the current center of the pinch gesture.
+             *
+             * @type Number
+             */
+            var centerX = 0;
+
+            /**
+             * The Y coordinate of the current center of the pinch gesture.
+             * @type Number
+             */
+            var centerY = 0;
+
+            /**
+             * Given a touch event, calculates the distance between the first
+             * two touches in pixels.
+             *
+             * @param {TouchEvent} e
+             *     The touch event to use when performing distance calculation.
+             * 
+             * @return {Number}
+             *     The distance in pixels between the first two touches.
+             */
+            var pinchDistance = function pinchDistance(e) {
+
+                var touchA = e.touches[0];
+                var touchB = e.touches[1];
+
+                var deltaX = touchA.clientX - touchB.clientX;
+                var deltaY = touchA.clientY - touchB.clientY;
+
+                return Math.sqrt(deltaX*deltaX + deltaY*deltaY);
+
+            };
+
+            /**
+             * Given a touch event, calculates the center between the first two
+             * touches in pixels, returning the X coordinate of this center.
+             *
+             * @param {TouchEvent} e
+             *     The touch event to use when performing center calculation.
+             * 
+             * @return {Number}
+             *     The X coordinate of the center of the first two touches.
+             */
+            var pinchCenterX = function pinchCenterX(e) {
+
+                var touchA = e.touches[0];
+                var touchB = e.touches[1];
+
+                return (touchA.clientX + touchB.clientX) / 2;
+
+            };
+
+            /**
+             * Given a touch event, calculates the center between the first two
+             * touches in pixels, returning the Y coordinate of this center.
+             *
+             * @param {TouchEvent} e
+             *     The touch event to use when performing center calculation.
+             * 
+             * @return {Number}
+             *     The Y coordinate of the center of the first two touches.
+             */
+            var pinchCenterY = function pinchCenterY(e) {
+
+                var touchA = e.touches[0];
+                var touchB = e.touches[1];
+
+                return (touchA.clientY + touchB.clientY) / 2;
+
+            };
+
+            // When there are exactly two touches, monitor the distance between
+            // them, firing zoom events as appropriate
+            element.addEventListener("touchmove", function pinchTouchMove(e) {
+                if (e.touches.length === 2) {
+
+                    e.stopPropagation();
+
+                    // Calculate current zoom level
+                    currentLength = pinchDistance(e);
+
+                    // Calculate center
+                    centerX = pinchCenterX(e);
+                    centerY = pinchCenterY(e);
+
+                    // Init start length if pinch is not in progress
+                    if (!startLength)
+                        startLength = currentLength;
+
+                    // Notify of pinch status
+                    if (guacTouchPinch) {
+                        $scope.$apply(function pinchChanged() {
+                            if (guacTouchPinch(true, startLength, currentLength, centerX, centerY) === false)
+                                e.preventDefault();
+                        });
+                    }
+
+                }
+            }, false);
+
+            // Reset monitoring and fire end event when done
+            element.addEventListener("touchend", function pinchTouchEnd(e) {
+
+                if (startLength && e.touches.length < 2) {
+
+                    e.stopPropagation();
+
+                    // Notify of pinch end
+                    if (guacTouchPinch) {
+                        $scope.$apply(function pinchComplete() {
+                            if (guacTouchPinch(false, startLength, currentLength, centerX, centerY) === false)
+                                e.preventDefault();
+                        });
+                    }
+
+                    startLength = null;
+
+                }
+
+            }, false);
+
+        }
+
+    };
+}]);
diff --git a/guacamole/src/main/webapp/app/touch/touchModule.js b/guacamole/src/main/webapp/app/touch/touchModule.js
new file mode 100644
index 0000000..3a86c33
--- /dev/null
+++ b/guacamole/src/main/webapp/app/touch/touchModule.js
@@ -0,0 +1,26 @@
+/*
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+/**
+ * Module for handling common touch gestures, like panning or pinch-to-zoom.
+ */
+angular.module('touch', []);
diff --git a/guacamole/src/main/webapp/client.xhtml b/guacamole/src/main/webapp/client.xhtml
deleted file mode 100644
index 0034318..0000000
--- a/guacamole/src/main/webapp/client.xhtml
+++ /dev/null
@@ -1,151 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<!DOCTYPE html>
-
-<!--
-    Guacamole - Clientless Remote Desktop
-    Copyright (C) 2010  Michael Jumper
-
-    This program is free software: you can redistribute it and/or modify
-    it under the terms of the GNU Affero General Public License as published by
-    the Free Software Foundation, either version 3 of the License, or
-    (at your option) any later version.
-
-    This program is distributed in the hope that it will be useful,
-    but WITHOUT ANY WARRANTY; without even the implied warranty of
-    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-    GNU Affero General Public License for more details.
-
-    You should have received a copy of the GNU Affero General Public License
-    along with this program.  If not, see <http://www.gnu.org/licenses/>.
--->
-
-<html xmlns="http://www.w3.org/1999/xhtml">
-
-    <head>
-        <link rel="icon" type="image/png" href="images/guacamole-logo-64.png"/>
-        <link rel="stylesheet" type="text/css" href="styles/client.css"/>
-        <link rel="stylesheet" type="text/css" href="styles/keyboard.css"/>
-        <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, target-densitydpi=device-dpi"/>
-        <meta name="apple-mobile-web-app-capable" content="yes"/>
-        <title>Guacamole ${project.version}</title>
-    </head>
-
-    <body>
-
-        <!-- Display -->
-        <div class="displayOuter">
-            <div class="displayMiddle">
-                <div id="display">
-                </div>
-            </div>
-        </div>
-
-        <!-- Dimensional clone of viewport -->
-        <div id="viewportClone"/>
-
-        <!-- Notification area -->
-        <div id="notificationArea"/>
-
-        <!-- Images which should be preloaded -->
-        <div id="preload">
-            <img src="images/action-icons/guac-close.png"/>
-            <img src="images/progress.png"/>
-        </div>
-        
-        <script type="text/javascript" src="scripts/lib/blob/blob.js"></script>
-        <script type="text/javascript" src="scripts/lib/filesaver/filesaver.js"></script>
-
-        <!-- guacamole-common-js scripts -->
-        <script type="text/javascript" src="guacamole-common-js/keyboard.js"></script>
-        <script type="text/javascript" src="guacamole-common-js/mouse.js"></script>
-        <script type="text/javascript" src="guacamole-common-js/layer.js"></script>
-        <script type="text/javascript" src="guacamole-common-js/tunnel.js"></script>
-        <script type="text/javascript" src="guacamole-common-js/audio.js"></script>
-        <script type="text/javascript" src="guacamole-common-js/guacamole.js"></script>
-        <script type="text/javascript" src="guacamole-common-js/oskeyboard.js"></script>
-
-        <!-- guacamole-default-webapp scripts -->
-        <script type="text/javascript" src="scripts/session.js"></script>
-        <script type="text/javascript" src="scripts/history.js"></script>
-        <script type="text/javascript" src="scripts/guac-ui.js"></script>
-        <script type="text/javascript" src="scripts/client-ui.js"></script>
-
-        <!-- Init -->
-        <script type="text/javascript"> /* <![CDATA[ */
-
-            // Start connect after control returns from onload (allow browser
-            // to consider the page loaded).
-            window.onload = function() {
-                window.setTimeout(function() {
-
-                    var tunnel;
-
-                    // If WebSocket available, try to use it.
-                    if (window.WebSocket)
-                        tunnel = new Guacamole.ChainedTunnel(
-                            new Guacamole.WebSocketTunnel("websocket-tunnel"),
-                            new Guacamole.HTTPTunnel("tunnel")
-                        );
-
-                    // If no WebSocket, then use HTTP.
-                    else
-                        tunnel = new Guacamole.HTTPTunnel("tunnel")
-
-                    // Instantiate client
-                    var guac = new Guacamole.Client(tunnel);
-
-                    // Add client to UI
-                    guac.getDisplay().className = "software-cursor";
-                    GuacUI.Client.display.appendChild(guac.getDisplay());
-
-                    // Tie UI to client
-                    GuacUI.Client.attach(guac);
-
-                    try {
-
-                        // Calculate optimal width/height for display
-                        var optimal_width = window.innerWidth;
-                        var optimal_height = window.innerHeight;
-
-                        // Scale width/height to be at least 600x600
-                        if (optimal_width < 600 || optimal_height < 600) {
-                            var scale = Math.max(600 / optimal_width, 600 / optimal_height);
-                            optimal_width = Math.floor(optimal_width * scale);
-                            optimal_height = Math.floor(optimal_height * scale);
-                        }
-
-                        // Get entire query string, and pass to connect().
-                        // Normally, only the "id" parameter is required, but
-                        // all parameters should be preserved and passed on for
-                        // the sake of authentication.
-
-                        var connect_string =
-                            window.location.search.substring(1)
-                            + "&width="  + optimal_width
-                            + "&height=" + optimal_height;
-
-                        // Add audio mimetypes to connect_string
-                        GuacUI.Audio.supported.forEach(function(mimetype) {
-                            connect_string += "&audio=" + encodeURIComponent(mimetype);
-                        });
-
-                        // Add video mimetypes to connect_string
-                        GuacUI.Video.supported.forEach(function(mimetype) {
-                            connect_string += "&video=" + encodeURIComponent(mimetype);
-                        });
-
-                        guac.connect(connect_string);
-
-                    }
-                    catch (e) {
-                        GuacUI.Client.showError(e.message);
-                    }
-
-                }, 0);
-            };
-
-        /* ]]> */ </script>
-
-    </body>
-
-</html>
diff --git a/guacamole/src/main/webapp/fonts/carlito/Carlito-Bold.woff b/guacamole/src/main/webapp/fonts/carlito/Carlito-Bold.woff
new file mode 100644
index 0000000..0afd4f5
Binary files /dev/null and b/guacamole/src/main/webapp/fonts/carlito/Carlito-Bold.woff differ
diff --git a/guacamole/src/main/webapp/fonts/carlito/Carlito-Italic.woff b/guacamole/src/main/webapp/fonts/carlito/Carlito-Italic.woff
new file mode 100644
index 0000000..379b053
Binary files /dev/null and b/guacamole/src/main/webapp/fonts/carlito/Carlito-Italic.woff differ
diff --git a/guacamole/src/main/webapp/fonts/carlito/Carlito-Regular.woff b/guacamole/src/main/webapp/fonts/carlito/Carlito-Regular.woff
new file mode 100644
index 0000000..b824286
Binary files /dev/null and b/guacamole/src/main/webapp/fonts/carlito/Carlito-Regular.woff differ
diff --git a/guacamole/src/main/webapp/fonts/carlito/LICENSE b/guacamole/src/main/webapp/fonts/carlito/LICENSE
new file mode 100644
index 0000000..e999b31
--- /dev/null
+++ b/guacamole/src/main/webapp/fonts/carlito/LICENSE
@@ -0,0 +1,95 @@
+Copyright (c) 2010-2013 by tyPoland Lukasz Dziedzic with Reserved Font Name "Carlito".
+
+This Font Software is licensed under the SIL Open Font License,
+Version 1.1 as shown below.
+
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+
+PREAMBLE The goals of the Open Font License (OFL) are to stimulate
+worldwide development of collaborative font projects, to support the font
+creation efforts of academic and linguistic communities, and to provide
+a free and open framework in which fonts may be shared and improved in
+partnership with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves.
+The fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works.  The fonts and derivatives,
+however, cannot be released under any other type of license.  The
+requirement for fonts to remain under this license does not apply to
+any document created using the fonts or their derivatives.
+
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such.
+This may include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components
+as distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting ? in part or in whole ?
+any of the components of the Original Version, by changing formats or
+by porting the Font Software to a new environment.
+
+"Author" refers to any designer, engineer, programmer, technical writer
+or other person who contributed to the Font Software.
+
+
+PERMISSION & CONDITIONS
+
+Permission is hereby granted, free of charge, to any person obtaining a
+copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,in
+   Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+   redistributed and/or sold with any software, provided that each copy
+   contains the above copyright notice and this license. These can be
+   included either as stand-alone text files, human-readable headers or
+   in the appropriate machine-readable metadata fields within text or
+   binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+   Name(s) unless explicit written permission is granted by the
+   corresponding Copyright Holder. This restriction only applies to the
+   primary font name as presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+   Software shall not be used to promote, endorse or advertise any
+   Modified Version, except to acknowledge the contribution(s) of the
+   Copyright Holder(s) and the Author(s) or with their explicit written
+   permission.
+
+5) The Font Software, modified or unmodified, in part or in whole, must
+   be distributed entirely under this license, and must not be distributed
+   under any other license. The requirement for fonts to remain under
+   this license does not apply to any document created using the Font
+   Software.
+
+
+ 
+TERMINATION
+This license becomes null and void if any of the above conditions are not met.
+
+ 
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT.  IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER
+DEALINGS IN THE FONT SOFTWARE.
+
diff --git a/guacamole/src/main/webapp/generated/templates-main/templates.js b/guacamole/src/main/webapp/generated/templates-main/templates.js
new file mode 100644
index 0000000..cf02889
--- /dev/null
+++ b/guacamole/src/main/webapp/generated/templates-main/templates.js
@@ -0,0 +1,2311 @@
+angular.module('templates-main', ['app/client/templates/client.html', 'app/client/templates/file.html', 'app/client/templates/guacClient.html', 'app/client/templates/guacFileBrowser.html', 'app/client/templates/guacFileTransfer.html', 'app/client/templates/guacFileTransferManager.html', 'app/client/templates/guacThumbnail.html', 'app/client/templates/guacViewport.html', 'app/element/templates/blank.html', 'app/form/templates/checkboxField.html', 'app/form/templates/dateField.html', 'app/ [...]
+
+angular.module('app/client/templates/client.html', []).run(['$templateCache', function($templateCache) {
+	$templateCache.put('app/client/templates/client.html',
+	"<!--\n" +
+	"   Copyright (C) 2014 Glyptodon LLC\n" +
+	"\n" +
+	"   Permission is hereby granted, free of charge, to any person obtaining a copy\n" +
+	"   of this software and associated documentation files (the \"Software\"), to deal\n" +
+	"   in the Software without restriction, including without limitation the rights\n" +
+	"   to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n" +
+	"   copies of the Software, and to permit persons to whom the Software is\n" +
+	"   furnished to do so, subject to the following conditions:\n" +
+	"\n" +
+	"   The above copyright notice and this permission notice shall be included in\n" +
+	"   all copies or substantial portions of the Software.\n" +
+	"\n" +
+	"   THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n" +
+	"   IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n" +
+	"   FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n" +
+	"   AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n" +
+	"   LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n" +
+	"   OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\n" +
+	"   THE SOFTWARE.\n" +
+	"-->\n" +
+	"\n" +
+	"<guac-viewport>\n" +
+	"\n" +
+	"    <!-- Client view -->\n" +
+	"    <div class=\"client-view\">\n" +
+	"        <div class=\"client-view-content\">\n" +
+	"\n" +
+	"            <!-- Central portion of view -->\n" +
+	"            <div class=\"client-body\" guac-touch-drag=\"clientDrag\" guac-touch-pinch=\"clientPinch\">\n" +
+	"\n" +
+	"                <!-- Client -->\n" +
+	"                <guac-client client=\"client\"></guac-client>\n" +
+	"\n" +
+	"            </div>\n" +
+	"\n" +
+	"            <!-- Bottom portion of view -->\n" +
+	"            <div class=\"client-bottom\">\n" +
+	"\n" +
+	"                <!-- Text input -->\n" +
+	"                <div class=\"text-input-container\" ng-show=\"showTextInput\">\n" +
+	"                    <guac-text-input needs-focus=\"showTextInput\"></guac-text-input>\n" +
+	"                </div>\n" +
+	"\n" +
+	"                <!-- On-screen keyboard -->\n" +
+	"                <div class=\"keyboard-container\" ng-show=\"showOSK\">\n" +
+	"                    <guac-osk layout=\"'CLIENT.URL_OSK_LAYOUT' | translate\"></guac-osk>\n" +
+	"                </div>\n" +
+	"\n" +
+	"            </div>\n" +
+	"\n" +
+	"        </div>\n" +
+	"    </div>\n" +
+	"\n" +
+	"    <!-- File transfers -->\n" +
+	"    <div id=\"file-transfer-dialog\" ng-show=\"hasTransfers()\">\n" +
+	"        <guac-file-transfer-manager client=\"client\"></guac-file-transfer-manager>\n" +
+	"    </div>\n" +
+	"\n" +
+	"    <!-- Menu -->\n" +
+	"    <div class=\"menu\" ng-class=\"{open: menu.shown}\" id=\"guac-menu\">\n" +
+	"        <div class=\"menu-content\">\n" +
+	"\n" +
+	"            <!-- Stationary header -->\n" +
+	"            <div class=\"header\">\n" +
+	"                <h2>{{client.name}}</h2>\n" +
+	"                <guac-user-menu local-actions=\"clientMenuActions\"></guac-user-menu>\n" +
+	"            </div>\n" +
+	"\n" +
+	"            <!-- Scrollable body -->\n" +
+	"            <div class=\"menu-body\" guac-touch-drag=\"menuDrag\" guac-scroll=\"menu.scrollState\">\n" +
+	"\n" +
+	"                <!-- Clipboard -->\n" +
+	"                <div class=\"menu-section\" id=\"clipboard-settings\">\n" +
+	"                    <h3>{{'CLIENT.SECTION_HEADER_CLIPBOARD' | translate}}</h3>\n" +
+	"                    <div class=\"content\">\n" +
+	"                        <p class=\"description\">{{'CLIENT.HELP_CLIPBOARD' | translate}}</p>\n" +
+	"                        <textarea ng-model=\"client.clipboardData\" rows=\"10\" cols=\"40\" id=\"clipboard\"></textarea>\n" +
+	"                    </div>\n" +
+	"                </div>\n" +
+	"\n" +
+	"                <!-- Devices -->\n" +
+	"                <div class=\"menu-section\" id=\"devices\" ng-show=\"client.filesystems.length\">\n" +
+	"                    <h3>{{'CLIENT.SECTION_HEADER_DEVICES' | translate}}</h3>\n" +
+	"                    <div class=\"content\">\n" +
+	"                        <div class=\"device filesystem\" ng-repeat=\"filesystem in client.filesystems\" ng-click=\"showFilesystemMenu(filesystem)\">\n" +
+	"                            {{filesystem.name}}\n" +
+	"                        </div>\n" +
+	"                    </div>\n" +
+	"                </div>\n" +
+	"\n" +
+	"                <!-- Input method -->\n" +
+	"                <div class=\"menu-section\" id=\"keyboard-settings\">\n" +
+	"                    <h3>{{'CLIENT.SECTION_HEADER_INPUT_METHOD' | translate}}</h3>\n" +
+	"                    <div class=\"content\">\n" +
+	"\n" +
+	"                        <!-- No IME -->\n" +
+	"                        <div class=\"choice\">\n" +
+	"                            <label><input id=\"ime-none\" name=\"input-method\" ng-change=\"closeMenu()\" ng-model=\"menu.inputMethod\" type=\"radio\" value=\"none\"/> {{'CLIENT.NAME_INPUT_METHOD_NONE' | translate}}</label>\n" +
+	"                            <p class=\"caption\"><label for=\"ime-none\">{{'CLIENT.HELP_INPUT_METHOD_NONE' | translate}}</label></p>\n" +
+	"                        </div>\n" +
+	"\n" +
+	"                        <!-- Text input -->\n" +
+	"                        <div class=\"choice\">\n" +
+	"                            <div class=\"figure\"><label for=\"ime-text\"><img src=\"images/settings/tablet-keys.png\" alt=\"\"/></label></div>\n" +
+	"                            <label><input id=\"ime-text\" name=\"input-method\" ng-change=\"closeMenu()\" ng-model=\"menu.inputMethod\" type=\"radio\" value=\"text\"/> {{'CLIENT.NAME_INPUT_METHOD_TEXT' | translate}}</label>\n" +
+	"                            <p class=\"caption\"><label for=\"ime-text\">{{'CLIENT.HELP_INPUT_METHOD_TEXT' | translate}} </label></p>\n" +
+	"                        </div>\n" +
+	"\n" +
+	"                        <!-- Guac OSK -->\n" +
+	"                        <div class=\"choice\">\n" +
+	"                            <label><input id=\"ime-osk\" name=\"input-method\" ng-change=\"closeMenu()\" ng-model=\"menu.inputMethod\" type=\"radio\" value=\"osk\"/> {{'CLIENT.NAME_INPUT_METHOD_OSK' | translate}}</label>\n" +
+	"                            <p class=\"caption\"><label for=\"ime-osk\">{{'CLIENT.HELP_INPUT_METHOD_OSK' | translate}}</label></p>\n" +
+	"                        </div>\n" +
+	"\n" +
+	"                    </div>\n" +
+	"                </div>\n" +
+	"\n" +
+	"                <!-- Mouse mode -->\n" +
+	"                <div class=\"menu-section\" id=\"mouse-settings\">\n" +
+	"                    <h3>{{'CLIENT.SECTION_HEADER_MOUSE_MODE' | translate}}</h3>\n" +
+	"                    <div class=\"content\">\n" +
+	"                        <p class=\"description\">{{'CLIENT.HELP_MOUSE_MODE' | translate}}</p>\n" +
+	"\n" +
+	"                        <!-- Touchscreen -->\n" +
+	"                        <div class=\"choice\">\n" +
+	"                            <input name=\"mouse-mode\" ng-change=\"closeMenu()\" ng-model=\"client.clientProperties.emulateAbsoluteMouse\" type=\"radio\" ng-value=\"true\" checked=\"checked\" id=\"absolute\"/>\n" +
+	"                            <div class=\"figure\">\n" +
+	"                                <label for=\"absolute\"><img src=\"images/settings/touchscreen.png\" alt=\"{{'CLIENT.NAME_MOUSE_MODE_ABSOLUTE' | translate}}\"/></label>\n" +
+	"                                <p class=\"caption\"><label for=\"absolute\">{{'CLIENT.HELP_MOUSE_MODE_ABSOLUTE' | translate}}</label></p>\n" +
+	"                            </div>\n" +
+	"                        </div>\n" +
+	"\n" +
+	"                        <!-- Touchpad -->\n" +
+	"                        <div class=\"choice\">\n" +
+	"                            <input name=\"mouse-mode\" ng-change=\"closeMenu()\" ng-model=\"client.clientProperties.emulateAbsoluteMouse\" type=\"radio\" ng-value=\"false\" id=\"relative\"/>\n" +
+	"                            <div class=\"figure\">\n" +
+	"                                <label for=\"relative\"><img src=\"images/settings/touchpad.png\" alt=\"{{'CLIENT.NAME_MOUSE_MODE_RELATIVE' | translate}}\"/></label>\n" +
+	"                                <p class=\"caption\"><label for=\"relative\">{{'CLIENT.HELP_MOUSE_MODE_RELATIVE' | translate}}</label></p>\n" +
+	"                            </div>\n" +
+	"                        </div>\n" +
+	"\n" +
+	"                    </div>\n" +
+	"                </div>\n" +
+	"\n" +
+	"                <!-- Display options -->\n" +
+	"                <div class=\"menu-section\" id=\"display-settings\">\n" +
+	"                    <h3>{{'CLIENT.SECTION_HEADER_DISPLAY' | translate}}</h3>\n" +
+	"                    <div class=\"content\">\n" +
+	"                        <div id=\"zoom-settings\">\n" +
+	"                            <div ng-click=\"zoomOut()\" id=\"zoom-out\"><img src=\"images/settings/zoom-out.png\" alt=\"-\"/></div>\n" +
+	"                            <div id=\"zoom-state\">{{formattedScale()}}%</div>\n" +
+	"                            <div ng-click=\"zoomIn()\" id=\"zoom-in\"><img src=\"images/settings/zoom-in.png\" alt=\"+\"/></div>\n" +
+	"                        </div>\n" +
+	"                        <div><label><input ng-model=\"menu.autoFit\" ng-change=\"changeAutoFit()\" ng-disabled=\"autoFitDisabled()\" type=\"checkbox\" id=\"auto-fit\"/> {{'CLIENT.TEXT_ZOOM_AUTO_FIT' | translate}}</label></div>\n" +
+	"                    </div>\n" +
+	"                </div>\n" +
+	"\n" +
+	"            </div>\n" +
+	"\n" +
+	"        </div>\n" +
+	"    </div>\n" +
+	"\n" +
+	"    <!-- Filesystem menu -->\n" +
+	"    <div id=\"filesystem-menu\" class=\"menu\" ng-class=\"{open: isFilesystemMenuShown()}\">\n" +
+	"        <div class=\"menu-content\">\n" +
+	"\n" +
+	"            <!-- Stationary header -->\n" +
+	"            <div class=\"header\">\n" +
+	"                <h2>{{filesystemMenuContents.name}}</h2>\n" +
+	"                <button class=\"upload button\" guac-upload=\"uploadFiles\">{{'CLIENT.ACTION_UPLOAD_FILES' | translate}}</button>\n" +
+	"                <button class=\"back\" ng-click=\"hideFilesystemMenu()\">{{'CLIENT.ACTION_NAVIGATE_BACK' | translate}}</button>\n" +
+	"            </div>\n" +
+	"\n" +
+	"            <!-- Breadcrumbs -->\n" +
+	"            <div class=\"header breadcrumbs\"><div\n" +
+	"                    class=\"breadcrumb root\"\n" +
+	"                    ng-click=\"changeDirectory(filesystemMenuContents, filesystemMenuContents.root)\"></div><div\n" +
+	"                        class=\"breadcrumb\"\n" +
+	"                        ng-repeat=\"file in getPath(filesystemMenuContents.currentDirectory)\"\n" +
+	"                        ng-click=\"changeDirectory(filesystemMenuContents, file)\">{{file.name}}</div>\n" +
+	"            </div>\n" +
+	"\n" +
+	"            <!-- Scrollable body -->\n" +
+	"            <div class=\"menu-body\">\n" +
+	"                <guac-file-browser client=\"client\" filesystem=\"filesystemMenuContents\"></guac-file-browser>\n" +
+	"            </div>\n" +
+	"\n" +
+	"        </div>\n" +
+	"    </div>\n" +
+	"\n" +
+	"</guac-viewport>");
+}]);
+
+angular.module('app/client/templates/file.html', []).run(['$templateCache', function($templateCache) {
+	$templateCache.put('app/client/templates/file.html',
+	"<div class=\"list-item\">\n" +
+	"    <!--\n" +
+	"       Copyright (C) 2015 Glyptodon LLC\n" +
+	"\n" +
+	"       Permission is hereby granted, free of charge, to any person obtaining a copy\n" +
+	"       of this software and associated documentation files (the \"Software\"), to deal\n" +
+	"       in the Software without restriction, including without limitation the rights\n" +
+	"       to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n" +
+	"       copies of the Software, and to permit persons to whom the Software is\n" +
+	"       furnished to do so, subject to the following conditions:\n" +
+	"\n" +
+	"       The above copyright notice and this permission notice shall be included in\n" +
+	"       all copies or substantial portions of the Software.\n" +
+	"\n" +
+	"       THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n" +
+	"       IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n" +
+	"       FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n" +
+	"       AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n" +
+	"       LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n" +
+	"       OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\n" +
+	"       THE SOFTWARE.\n" +
+	"    -->\n" +
+	"\n" +
+	"    <!-- Filename and icon -->\n" +
+	"    <div class=\"caption\">\n" +
+	"        <div class=\"icon\"></div>\n" +
+	"        {{::name}}\n" +
+	"    </div>\n" +
+	"\n" +
+	"</div>");
+}]);
+
+angular.module('app/client/templates/guacClient.html', []).run(['$templateCache', function($templateCache) {
+	$templateCache.put('app/client/templates/guacClient.html',
+	"<div class=\"main\" guac-resize=\"mainElementResized\">\n" +
+	"    <!--\n" +
+	"       Copyright (C) 2014 Glyptodon LLC\n" +
+	"\n" +
+	"       Permission is hereby granted, free of charge, to any person obtaining a copy\n" +
+	"       of this software and associated documentation files (the \"Software\"), to deal\n" +
+	"       in the Software without restriction, including without limitation the rights\n" +
+	"       to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n" +
+	"       copies of the Software, and to permit persons to whom the Software is\n" +
+	"       furnished to do so, subject to the following conditions:\n" +
+	"\n" +
+	"       The above copyright notice and this permission notice shall be included in\n" +
+	"       all copies or substantial portions of the Software.\n" +
+	"\n" +
+	"       THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n" +
+	"       IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n" +
+	"       FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n" +
+	"       AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n" +
+	"       LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n" +
+	"       OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\n" +
+	"       THE SOFTWARE.\n" +
+	"    -->\n" +
+	"\n" +
+	"    <!-- Display -->\n" +
+	"    <div class=\"displayOuter\">\n" +
+	"\n" +
+	"        <div class=\"displayMiddle\">\n" +
+	"            <div class=\"display software-cursor\">\n" +
+	"            </div>\n" +
+	"        </div>\n" +
+	"\n" +
+	"    </div>\n" +
+	"\n" +
+	"</div>");
+}]);
+
+angular.module('app/client/templates/guacFileBrowser.html', []).run(['$templateCache', function($templateCache) {
+	$templateCache.put('app/client/templates/guacFileBrowser.html',
+	"<div class=\"file-browser\">\n" +
+	"    <!--\n" +
+	"       Copyright (C) 2015 Glyptodon LLC\n" +
+	"\n" +
+	"       Permission is hereby granted, free of charge, to any person obtaining a copy\n" +
+	"       of this software and associated documentation files (the \"Software\"), to deal\n" +
+	"       in the Software without restriction, including without limitation the rights\n" +
+	"       to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n" +
+	"       copies of the Software, and to permit persons to whom the Software is\n" +
+	"       furnished to do so, subject to the following conditions:\n" +
+	"\n" +
+	"       The above copyright notice and this permission notice shall be included in\n" +
+	"       all copies or substantial portions of the Software.\n" +
+	"\n" +
+	"       THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n" +
+	"       IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n" +
+	"       FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n" +
+	"       AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n" +
+	"       LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n" +
+	"       OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\n" +
+	"       THE SOFTWARE.\n" +
+	"    -->\n" +
+	"\n" +
+	"    <!-- Current directory contents -->\n" +
+	"    <div class=\"current-directory-contents\"></div>\n" +
+	"\n" +
+	"</div>");
+}]);
+
+angular.module('app/client/templates/guacFileTransfer.html', []).run(['$templateCache', function($templateCache) {
+	$templateCache.put('app/client/templates/guacFileTransfer.html',
+	"<div class=\"transfer\" ng-class=\"{'in-progress': isInProgress(), 'savable': isSavable(), 'error': hasError()}\" ng-click=\"save()\">\n" +
+	"    <!--\n" +
+	"       Copyright (C) 2014 Glyptodon LLC\n" +
+	"\n" +
+	"       Permission is hereby granted, free of charge, to any person obtaining a copy\n" +
+	"       of this software and associated documentation files (the \"Software\"), to deal\n" +
+	"       in the Software without restriction, including without limitation the rights\n" +
+	"       to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n" +
+	"       copies of the Software, and to permit persons to whom the Software is\n" +
+	"       furnished to do so, subject to the following conditions:\n" +
+	"\n" +
+	"       The above copyright notice and this permission notice shall be included in\n" +
+	"       all copies or substantial portions of the Software.\n" +
+	"\n" +
+	"       THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n" +
+	"       IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n" +
+	"       FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n" +
+	"       AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n" +
+	"       LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n" +
+	"       OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\n" +
+	"       THE SOFTWARE.\n" +
+	"    -->\n" +
+	"\n" +
+	"    <!-- Overall status of transfer -->\n" +
+	"    <div class=\"transfer-status\">\n" +
+	"\n" +
+	"        <!-- Filename and progress bar -->\n" +
+	"        <div class=\"filename\">\n" +
+	"            <div class=\"progress\"><div ng-style=\"{'width': getPercentDone() + '%'}\" class=\"bar\"></div></div>\n" +
+	"            {{transfer.filename}}\n" +
+	"        </div>\n" +
+	"\n" +
+	"        <!-- Error text -->\n" +
+	"        <p class=\"error-text\">{{getErrorText() | translate}}</p>\n" +
+	"\n" +
+	"    </div>\n" +
+	"\n" +
+	"    <!-- Progress/status text -->\n" +
+	"    <div class=\"text\"\n" +
+	"         translate=\"CLIENT.TEXT_FILE_TRANSFER_PROGRESS\"\n" +
+	"         translate-values=\"{PROGRESS: getProgressValue(), UNIT: getProgressUnit()}\"></div>\n" +
+	"\n" +
+	"</div>");
+}]);
+
+angular.module('app/client/templates/guacFileTransferManager.html', []).run(['$templateCache', function($templateCache) {
+	$templateCache.put('app/client/templates/guacFileTransferManager.html',
+	"<div class=\"transfer-manager\">\n" +
+	"    <!--\n" +
+	"       Copyright (C) 2014 Glyptodon LLC\n" +
+	"\n" +
+	"       Permission is hereby granted, free of charge, to any person obtaining a copy\n" +
+	"       of this software and associated documentation files (the \"Software\"), to deal\n" +
+	"       in the Software without restriction, including without limitation the rights\n" +
+	"       to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n" +
+	"       copies of the Software, and to permit persons to whom the Software is\n" +
+	"       furnished to do so, subject to the following conditions:\n" +
+	"\n" +
+	"       The above copyright notice and this permission notice shall be included in\n" +
+	"       all copies or substantial portions of the Software.\n" +
+	"\n" +
+	"       THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n" +
+	"       IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n" +
+	"       FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n" +
+	"       AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n" +
+	"       LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n" +
+	"       OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\n" +
+	"       THE SOFTWARE.\n" +
+	"    -->\n" +
+	"\n" +
+	"    <!-- File transfer manager header -->\n" +
+	"    <div class=\"header\">\n" +
+	"        <h2>{{'CLIENT.SECTION_HEADER_FILE_TRANSFERS' | translate}}</h2>\n" +
+	"        <button ng-click=\"clearCompletedTransfers()\">{{'CLIENT.ACTION_CLEAR_COMPLETED_TRANSFERS' | translate}}</button>\n" +
+	"    </div>\n" +
+	"\n" +
+	"    <!-- Sent/received files -->\n" +
+	"    <div class=\"transfer-manager-body\">\n" +
+	"        <div class=\"transfers\">\n" +
+	"            <guac-file-transfer\n" +
+	"                transfer=\"upload\"\n" +
+	"                ng-repeat=\"upload in client.uploads\">\n" +
+	"            </guac-file-transfer><guac-file-transfer\n" +
+	"                transfer=\"download\"\n" +
+	"                ng-repeat=\"download in client.downloads\">\n" +
+	"            </guac-file-transfer>\n" +
+	"        </div>\n" +
+	"    </div>\n" +
+	"\n" +
+	"</div>");
+}]);
+
+angular.module('app/client/templates/guacThumbnail.html', []).run(['$templateCache', function($templateCache) {
+	$templateCache.put('app/client/templates/guacThumbnail.html',
+	"<div class=\"thumbnail-main\" guac-resize=\"updateDisplayScale\">\n" +
+	"    <!--\n" +
+	"       Copyright (C) 2014 Glyptodon LLC\n" +
+	"\n" +
+	"       Permission is hereby granted, free of charge, to any person obtaining a copy\n" +
+	"       of this software and associated documentation files (the \"Software\"), to deal\n" +
+	"       in the Software without restriction, including without limitation the rights\n" +
+	"       to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n" +
+	"       copies of the Software, and to permit persons to whom the Software is\n" +
+	"       furnished to do so, subject to the following conditions:\n" +
+	"\n" +
+	"       The above copyright notice and this permission notice shall be included in\n" +
+	"       all copies or substantial portions of the Software.\n" +
+	"\n" +
+	"       THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n" +
+	"       IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n" +
+	"       FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n" +
+	"       AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n" +
+	"       LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n" +
+	"       OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\n" +
+	"       THE SOFTWARE.\n" +
+	"    -->\n" +
+	"\n" +
+	"    <!-- Display -->\n" +
+	"    <div class=\"display\">\n" +
+	"    </div>\n" +
+	"\n" +
+	"    <!-- Dummy background thumbnail -->\n" +
+	"    <img alt=\"\" ng-src=\"{{thumbnail}}\"/>\n" +
+	"\n" +
+	"</div>");
+}]);
+
+angular.module('app/client/templates/guacViewport.html', []).run(['$templateCache', function($templateCache) {
+	$templateCache.put('app/client/templates/guacViewport.html',
+	"<div class=\"viewport\" ng-transclude>\n" +
+	"    <!--\n" +
+	"       Copyright (C) 2014 Glyptodon LLC\n" +
+	"\n" +
+	"       Permission is hereby granted, free of charge, to any person obtaining a copy\n" +
+	"       of this software and associated documentation files (the \"Software\"), to deal\n" +
+	"       in the Software without restriction, including without limitation the rights\n" +
+	"       to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n" +
+	"       copies of the Software, and to permit persons to whom the Software is\n" +
+	"       furnished to do so, subject to the following conditions:\n" +
+	"\n" +
+	"       The above copyright notice and this permission notice shall be included in\n" +
+	"       all copies or substantial portions of the Software.\n" +
+	"\n" +
+	"       THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n" +
+	"       IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n" +
+	"       FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n" +
+	"       AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n" +
+	"       LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n" +
+	"       OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\n" +
+	"       THE SOFTWARE.\n" +
+	"    -->\n" +
+	"</div>");
+}]);
+
+angular.module('app/element/templates/blank.html', []).run(['$templateCache', function($templateCache) {
+	$templateCache.put('app/element/templates/blank.html',
+	"<!DOCTYPE html>\n" +
+	"<html>\n" +
+	"    <head>\n" +
+	"        <meta http-equiv=\"Content-Type\" content=\"text/html; charset=UTF-8\">\n" +
+	"        <title>_</title>\n" +
+	"    </head>\n" +
+	"    <!--\n" +
+	"       Copyright (C) 2015 Glyptodon LLC\n" +
+	"\n" +
+	"       Permission is hereby granted, free of charge, to any person obtaining a copy\n" +
+	"       of this software and associated documentation files (the \"Software\"), to deal\n" +
+	"       in the Software without restriction, including without limitation the rights\n" +
+	"       to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n" +
+	"       copies of the Software, and to permit persons to whom the Software is\n" +
+	"       furnished to do so, subject to the following conditions:\n" +
+	"\n" +
+	"       The above copyright notice and this permission notice shall be included in\n" +
+	"       all copies or substantial portions of the Software.\n" +
+	"\n" +
+	"       THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n" +
+	"       IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n" +
+	"       FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n" +
+	"       AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n" +
+	"       LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n" +
+	"       OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\n" +
+	"       THE SOFTWARE.\n" +
+	"    -->\n" +
+	"    <body></body>\n" +
+	"</html>");
+}]);
+
+angular.module('app/form/templates/checkboxField.html', []).run(['$templateCache', function($templateCache) {
+	$templateCache.put('app/form/templates/checkboxField.html',
+	"<input type=\"checkbox\" ng-model=\"typedValue\" autocorrect=\"off\" autocapitalize=\"off\"/>");
+}]);
+
+angular.module('app/form/templates/dateField.html', []).run(['$templateCache', function($templateCache) {
+	$templateCache.put('app/form/templates/dateField.html',
+	"<div class=\"date-field\">\n" +
+	"    <input type=\"date\"\n" +
+	"           ng-model=\"typedValue\"\n" +
+	"           ng-model-options=\"modelOptions\"\n" +
+	"           guac-lenient-date\n" +
+	"           placeholder=\"{{'FORM.FIELD_PLACEHOLDER_DATE' | translate}}\"\n" +
+	"           autocorrect=\"off\"\n" +
+	"           autocapitalize=\"off\"/>\n" +
+	"</div>");
+}]);
+
+angular.module('app/form/templates/form.html', []).run(['$templateCache', function($templateCache) {
+	$templateCache.put('app/form/templates/form.html',
+	"<div class=\"form-group\">\n" +
+	"    <div ng-repeat=\"form in forms\" class=\"form\">\n" +
+	"        <!--\n" +
+	"            Copyright 2015 Glyptodon LLC.\n" +
+	"\n" +
+	"            Permission is hereby granted, free of charge, to any person obtaining a copy\n" +
+	"            of this software and associated documentation files (the \"Software\"), to deal\n" +
+	"            in the Software without restriction, including without limitation the rights\n" +
+	"            to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n" +
+	"            copies of the Software, and to permit persons to whom the Software is\n" +
+	"            furnished to do so, subject to the following conditions:\n" +
+	"\n" +
+	"            The above copyright notice and this permission notice shall be included in\n" +
+	"            all copies or substantial portions of the Software.\n" +
+	"\n" +
+	"            THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n" +
+	"            IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n" +
+	"            FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n" +
+	"            AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n" +
+	"            LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n" +
+	"            OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\n" +
+	"            THE SOFTWARE.\n" +
+	"        -->\n" +
+	"\n" +
+	"        <!-- Form name -->\n" +
+	"        <h3 ng-show=\"form.name\">{{getSectionHeader(form) | translate}}</h3>\n" +
+	"\n" +
+	"        <!-- All fields in form -->\n" +
+	"        <div class=\"fields\">\n" +
+	"            <guac-form-field ng-repeat=\"field in form.fields\" namespace=\"namespace\"\n" +
+	"                             field=\"field\" model=\"values[field.name]\"></guac-form-field>\n" +
+	"        </div>\n" +
+	"\n" +
+	"    </div>\n" +
+	"</div>");
+}]);
+
+angular.module('app/form/templates/formField.html', []).run(['$templateCache', function($templateCache) {
+	$templateCache.put('app/form/templates/formField.html',
+	"<label class=\"labeled-field\" ng-class=\"{empty: !model}\">\n" +
+	"    <!--\n" +
+	"        Copyright 2014 Glyptodon LLC.\n" +
+	"\n" +
+	"        Permission is hereby granted, free of charge, to any person obtaining a copy\n" +
+	"        of this software and associated documentation files (the \"Software\"), to deal\n" +
+	"        in the Software without restriction, including without limitation the rights\n" +
+	"        to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n" +
+	"        copies of the Software, and to permit persons to whom the Software is\n" +
+	"        furnished to do so, subject to the following conditions:\n" +
+	"\n" +
+	"        The above copyright notice and this permission notice shall be included in\n" +
+	"        all copies or substantial portions of the Software.\n" +
+	"\n" +
+	"        THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n" +
+	"        IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n" +
+	"        FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n" +
+	"        AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n" +
+	"        LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n" +
+	"        OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\n" +
+	"        THE SOFTWARE.\n" +
+	"    -->\n" +
+	"\n" +
+	"    <!-- Field header -->\n" +
+	"    <span class=\"field-header\">{{getFieldHeader() | translate}}</span>\n" +
+	"\n" +
+	"    <!-- Field content -->\n" +
+	"    <div class=\"form-field\">\n" +
+	"    </div>\n" +
+	"\n" +
+	"</label>");
+}]);
+
+angular.module('app/form/templates/numberField.html', []).run(['$templateCache', function($templateCache) {
+	$templateCache.put('app/form/templates/numberField.html',
+	"<input type=\"number\" ng-model=\"typedValue\" autocorrect=\"off\" autocapitalize=\"off\"/>");
+}]);
+
+angular.module('app/form/templates/passwordField.html', []).run(['$templateCache', function($templateCache) {
+	$templateCache.put('app/form/templates/passwordField.html',
+	"<div class=\"password-field\">\n" +
+	"    <input type=\"{{passwordInputType}}\" ng-model=\"model\" ng-trim=\"false\" autocorrect=\"off\" autocapitalize=\"off\"/>\n" +
+	"    <div class=\"icon toggle-password\" ng-click=\"togglePassword()\" title=\"{{getTogglePasswordHelpText() | translate}}\"></div>\n" +
+	"</div>");
+}]);
+
+angular.module('app/form/templates/selectField.html', []).run(['$templateCache', function($templateCache) {
+	$templateCache.put('app/form/templates/selectField.html',
+	"<select ng-model=\"model\" ng-options=\"option as getFieldOption(option) | translate for option in field.options | orderBy: value\"></select>");
+}]);
+
+angular.module('app/form/templates/textAreaField.html', []).run(['$templateCache', function($templateCache) {
+	$templateCache.put('app/form/templates/textAreaField.html',
+	"<textarea ng-model=\"model\" autocorrect=\"off\" autocapitalize=\"off\"></textarea>");
+}]);
+
+angular.module('app/form/templates/textField.html', []).run(['$templateCache', function($templateCache) {
+	$templateCache.put('app/form/templates/textField.html',
+	"<input type=\"text\" ng-model=\"model\" autocorrect=\"off\" autocapitalize=\"off\"/>");
+}]);
+
+angular.module('app/form/templates/timeField.html', []).run(['$templateCache', function($templateCache) {
+	$templateCache.put('app/form/templates/timeField.html',
+	"<div class=\"time-field\">\n" +
+	"    <input type=\"time\"\n" +
+	"           ng-model=\"typedValue\"\n" +
+	"           ng-model-options=\"modelOptions\"\n" +
+	"           guac-lenient-time\n" +
+	"           placeholder=\"{{'FORM.FIELD_PLACEHOLDER_TIME' | translate}}\"\n" +
+	"           autocorrect=\"off\"\n" +
+	"           autocapitalize=\"off\"/>\n" +
+	"</div>");
+}]);
+
+angular.module('app/form/templates/timeZoneField.html', []).run(['$templateCache', function($templateCache) {
+	$templateCache.put('app/form/templates/timeZoneField.html',
+	"<div class=\"time-zone-field\">\n" +
+	"\n" +
+	"    <!-- Available time zone regions -->\n" +
+	"    <select class=\"time-zone-region\"\n" +
+	"            ng-model=\"region\"\n" +
+	"            ng-options=\"name for name in regions | orderBy: name\"></select>\n" +
+	"\n" +
+	"    <!-- Time zones within selected region -->\n" +
+	"    <select class=\"time-zone\"\n" +
+	"            ng-disabled=\"!region\"\n" +
+	"            ng-model=\"model\"\n" +
+	"            ng-options=\"name for (name, value) in timeZones[region] | orderBy: name\"></select>\n" +
+	"\n" +
+	"</div>");
+}]);
+
+angular.module('app/groupList/templates/guacGroupList.html', []).run(['$templateCache', function($templateCache) {
+	$templateCache.put('app/groupList/templates/guacGroupList.html',
+	"<div class=\"group-list\">\n" +
+	"    <!--\n" +
+	"       Copyright (C) 2014 Glyptodon LLC\n" +
+	"\n" +
+	"       Permission is hereby granted, free of charge, to any person obtaining a copy\n" +
+	"       of this software and associated documentation files (the \"Software\"), to deal\n" +
+	"       in the Software without restriction, including without limitation the rights\n" +
+	"       to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n" +
+	"       copies of the Software, and to permit persons to whom the Software is\n" +
+	"       furnished to do so, subject to the following conditions:\n" +
+	"\n" +
+	"       The above copyright notice and this permission notice shall be included in\n" +
+	"       all copies or substantial portions of the Software.\n" +
+	"\n" +
+	"       THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n" +
+	"       IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n" +
+	"       FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n" +
+	"       AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n" +
+	"       LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n" +
+	"       OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\n" +
+	"       THE SOFTWARE.\n" +
+	"    -->\n" +
+	"\n" +
+	"    <script type=\"text/ng-template\" id=\"nestedGroup.html\">\n" +
+	"\n" +
+	"        <!-- Connection -->\n" +
+	"        <div class=\"connection\" ng-show=\"isVisibleConnection(item)\">\n" +
+	"            <div class=\"caption\">\n" +
+	"                <ng-include src=\"connectionTemplate\"/>\n" +
+	"            </div>\n" +
+	"        </div>\n" +
+	"\n" +
+	"        <!-- Connection group -->\n" +
+	"        <div class=\"group\" ng-show=\"isVisibleConnectionGroup(item)\">\n" +
+	"            <div class=\"caption\">\n" +
+	"\n" +
+	"                <!-- Connection group icon -->\n" +
+	"                <div class=\"icon group type\" ng-click=\"toggleExpanded(item)\"\n" +
+	"                     ng-class=\"{expanded: item.isExpanded, empty: !item.children.length, balancer: item.isBalancing}\"></div>\n" +
+	"\n" +
+	"                <ng-include src=\"connectionGroupTemplate\"/>\n" +
+	"\n" +
+	"            </div>\n" +
+	"\n" +
+	"            <!-- Children of this group -->\n" +
+	"            <div class=\"children\" ng-show=\"item.isExpanded\">\n" +
+	"                <div class=\"list-item\" ng-repeat=\"item in item.children | orderBy : 'name'\" ng-include=\"'nestedGroup.html'\">\n" +
+	"            </div>\n" +
+	"\n" +
+	"        </div>\n" +
+	"\n" +
+	"    </script>\n" +
+	"\n" +
+	"    <!-- Root-level connections / groups -->\n" +
+	"    <div class=\"group-list-page\">\n" +
+	"        <div class=\"list-item\" ng-repeat=\"item in childrenPage\" ng-include=\"'nestedGroup.html'\"></div>\n" +
+	"    </div>\n" +
+	"\n" +
+	"    <!-- Pager for connections / groups -->\n" +
+	"    <guac-pager page=\"childrenPage\" items=\"rootItems | orderBy : 'name'\"\n" +
+	"                page-size=\"pageSize\"></guac-pager>\n" +
+	"\n" +
+	"</div>");
+}]);
+
+angular.module('app/groupList/templates/guacGroupListFilter.html', []).run(['$templateCache', function($templateCache) {
+	$templateCache.put('app/groupList/templates/guacGroupListFilter.html',
+	"<div class=\"group-list-filter filter\">\n" +
+	"    <!--\n" +
+	"       Copyright (C) 2015 Glyptodon LLC\n" +
+	"\n" +
+	"       Permission is hereby granted, free of charge, to any person obtaining a copy\n" +
+	"       of this software and associated documentation files (the \"Software\"), to deal\n" +
+	"       in the Software without restriction, including without limitation the rights\n" +
+	"       to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n" +
+	"       copies of the Software, and to permit persons to whom the Software is\n" +
+	"       furnished to do so, subject to the following conditions:\n" +
+	"\n" +
+	"       The above copyright notice and this permission notice shall be included in\n" +
+	"       all copies or substantial portions of the Software.\n" +
+	"\n" +
+	"       THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n" +
+	"       IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n" +
+	"       FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n" +
+	"       AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n" +
+	"       LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n" +
+	"       OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\n" +
+	"       THE SOFTWARE.\n" +
+	"    -->\n" +
+	"\n" +
+	"    <!-- Filter string -->\n" +
+	"    <input class=\"search-string\" placeholder=\"{{placeholder()}}\" type=\"text\" ng-model=\"searchString\"/>\n" +
+	"\n" +
+	"</div>");
+}]);
+
+angular.module('app/home/templates/connection.html', []).run(['$templateCache', function($templateCache) {
+	$templateCache.put('app/home/templates/connection.html',
+	"<a ng-href=\"#/client/{{context.getClientIdentifier(item)}}\">\n" +
+	"    <!--\n" +
+	"       Copyright (C) 2014 Glyptodon LLC\n" +
+	"\n" +
+	"       Permission is hereby granted, free of charge, to any person obtaining a copy\n" +
+	"       of this software and associated documentation files (the \"Software\"), to deal\n" +
+	"       in the Software without restriction, including without limitation the rights\n" +
+	"       to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n" +
+	"       copies of the Software, and to permit persons to whom the Software is\n" +
+	"       furnished to do so, subject to the following conditions:\n" +
+	"\n" +
+	"       The above copyright notice and this permission notice shall be included in\n" +
+	"       all copies or substantial portions of the Software.\n" +
+	"\n" +
+	"       THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n" +
+	"       IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n" +
+	"       FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n" +
+	"       AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n" +
+	"       LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n" +
+	"       OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\n" +
+	"       THE SOFTWARE.\n" +
+	"    -->\n" +
+	"\n" +
+	"    <div class=\"caption\" ng-class=\"{active: item.getActiveConnections()}\">\n" +
+	"\n" +
+	"        <!-- Connection icon -->\n" +
+	"        <div class=\"protocol\">\n" +
+	"            <div class=\"icon type\" ng-class=\"item.protocol\"></div>\n" +
+	"        </div>\n" +
+	"\n" +
+	"        <!-- Connection name -->\n" +
+	"        <span class=\"name\">{{item.name}}</span>\n" +
+	"        \n" +
+	"        <!-- Active user count -->\n" +
+	"        <span class=\"activeUserCount\" ng-show=\"item.getActiveConnections()\"\n" +
+	"            translate=\"HOME.INFO_ACTIVE_USER_COUNT\"\n" +
+	"            translate-values=\"{USERS: item.getActiveConnections()}\"></span>\n" +
+	"\n" +
+	"    </div>\n" +
+	"</a>");
+}]);
+
+angular.module('app/home/templates/connectionGroup.html', []).run(['$templateCache', function($templateCache) {
+	$templateCache.put('app/home/templates/connectionGroup.html',
+	"<span class=\"name\">\n" +
+	"    <!--\n" +
+	"       Copyright (C) 2014 Glyptodon LLC\n" +
+	"\n" +
+	"       Permission is hereby granted, free of charge, to any person obtaining a copy\n" +
+	"       of this software and associated documentation files (the \"Software\"), to deal\n" +
+	"       in the Software without restriction, including without limitation the rights\n" +
+	"       to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n" +
+	"       copies of the Software, and to permit persons to whom the Software is\n" +
+	"       furnished to do so, subject to the following conditions:\n" +
+	"\n" +
+	"       The above copyright notice and this permission notice shall be included in\n" +
+	"       all copies or substantial portions of the Software.\n" +
+	"\n" +
+	"       THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n" +
+	"       IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n" +
+	"       FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n" +
+	"       AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n" +
+	"       LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n" +
+	"       OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\n" +
+	"       THE SOFTWARE.\n" +
+	"    -->\n" +
+	"\n" +
+	"    <a ng-show=\"item.isBalancing\" ng-href=\"#/client/{{context.getClientIdentifier(item)}}\">{{item.name}}</a>\n" +
+	"    <span ng-show=\"!item.isBalancing\">{{item.name}}</span>\n" +
+	"</span>");
+}]);
+
+angular.module('app/home/templates/guacRecentConnections.html', []).run(['$templateCache', function($templateCache) {
+	$templateCache.put('app/home/templates/guacRecentConnections.html',
+	"<div>\n" +
+	"    <!--\n" +
+	"       Copyright (C) 2014 Glyptodon LLC\n" +
+	"\n" +
+	"       Permission is hereby granted, free of charge, to any person obtaining a copy\n" +
+	"       of this software and associated documentation files (the \"Software\"), to deal\n" +
+	"       in the Software without restriction, including without limitation the rights\n" +
+	"       to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n" +
+	"       copies of the Software, and to permit persons to whom the Software is\n" +
+	"       furnished to do so, subject to the following conditions:\n" +
+	"\n" +
+	"       The above copyright notice and this permission notice shall be included in\n" +
+	"       all copies or substantial portions of the Software.\n" +
+	"\n" +
+	"       THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n" +
+	"       IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n" +
+	"       FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n" +
+	"       AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n" +
+	"       LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n" +
+	"       OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\n" +
+	"       THE SOFTWARE.\n" +
+	"    -->\n" +
+	"\n" +
+	"    <!-- Text displayed if no recent connections exist -->\n" +
+	"    <p class=\"placeholder\" ng-hide=\"hasRecentConnections()\">{{'HOME.INFO_NO_RECENT_CONNECTIONS' | translate}}</p>\n" +
+	"\n" +
+	"    <!-- All active connections -->\n" +
+	"    <div ng-repeat=\"activeConnection in activeConnections\" class=\"connection\">\n" +
+	"        <a href=\"#/client/{{activeConnection.client.id}}\">\n" +
+	"\n" +
+	"            <!-- Connection thumbnail -->\n" +
+	"            <div class=\"thumbnail\">\n" +
+	"                <guac-thumbnail client=\"activeConnection.client\"></guac-thumbnail>\n" +
+	"            </div>\n" +
+	"\n" +
+	"            <!-- Connection name -->\n" +
+	"            <div class=\"caption\">\n" +
+	"                <span class=\"name\">{{activeConnection.name}}</span>\n" +
+	"            </div>\n" +
+	"\n" +
+	"        </a>\n" +
+	"    </div>\n" +
+	"    \n" +
+	"    <!-- All recent connections -->\n" +
+	"    <div ng-repeat=\"recentConnection in recentConnections\" class=\"connection\">\n" +
+	"        <a href=\"#/client/{{recentConnection.entry.id}}\">\n" +
+	"\n" +
+	"            <!-- Connection thumbnail -->\n" +
+	"            <div class=\"thumbnail\">\n" +
+	"                <img alt=\"{{recentConnection.name}}\" ng-src=\"{{recentConnection.entry.thumbnail}}\"/>\n" +
+	"            </div>\n" +
+	"\n" +
+	"            <!-- Connection name -->\n" +
+	"            <div class=\"caption\">\n" +
+	"                <span class=\"name\">{{recentConnection.name}}</span>\n" +
+	"            </div>\n" +
+	"\n" +
+	"        </a>\n" +
+	"    </div>\n" +
+	"\n" +
+	"</div>");
+}]);
+
+angular.module('app/home/templates/home.html', []).run(['$templateCache', function($templateCache) {
+	$templateCache.put('app/home/templates/home.html',
+	"<!--\n" +
+	"   Copyright (C) 2015 Glyptodon LLC\n" +
+	"\n" +
+	"   Permission is hereby granted, free of charge, to any person obtaining a copy\n" +
+	"   of this software and associated documentation files (the \"Software\"), to deal\n" +
+	"   in the Software without restriction, including without limitation the rights\n" +
+	"   to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n" +
+	"   copies of the Software, and to permit persons to whom the Software is\n" +
+	"   furnished to do so, subject to the following conditions:\n" +
+	"\n" +
+	"   The above copyright notice and this permission notice shall be included in\n" +
+	"   all copies or substantial portions of the Software.\n" +
+	"\n" +
+	"   THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n" +
+	"   IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n" +
+	"   FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n" +
+	"   AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n" +
+	"   LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n" +
+	"   OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\n" +
+	"   THE SOFTWARE.\n" +
+	"-->\n" +
+	"\n" +
+	"<div class=\"view\" ng-class=\"{loading: !isLoaded()}\">\n" +
+	"\n" +
+	"    <div class=\"connection-list-ui\">\n" +
+	"\n" +
+	"        <!-- The recent connections for this user -->\n" +
+	"        <div class=\"header\">\n" +
+	"            <h2>{{'HOME.SECTION_HEADER_RECENT_CONNECTIONS' | translate}}</h2>\n" +
+	"            <guac-user-menu></guac-user-menu>\n" +
+	"        </div>\n" +
+	"        <div class=\"recent-connections\">\n" +
+	"            <guac-recent-connections root-groups=\"rootConnectionGroups\"></guac-recent-connections>\n" +
+	"        </div>\n" +
+	"\n" +
+	"        <!-- All connections for this user -->\n" +
+	"        <div class=\"header\">\n" +
+	"            <h2>{{'HOME.SECTION_HEADER_ALL_CONNECTIONS' | translate}}</h2>\n" +
+	"            <guac-group-list-filter connection-groups=\"rootConnectionGroups\"\n" +
+	"                filtered-connection-groups=\"filteredRootConnectionGroups\"\n" +
+	"                placeholder=\"'HOME.FIELD_PLACEHOLDER_FILTER' | translate\"\n" +
+	"                connection-properties=\"filteredConnectionProperties\"\n" +
+	"                connection-group-properties=\"filteredConnectionGroupProperties\"></guac-group-list-filter>\n" +
+	"        </div>\n" +
+	"        <div class=\"all-connections\">\n" +
+	"            <guac-group-list\n" +
+	"                context=\"context\"\n" +
+	"                connection-groups=\"filteredRootConnectionGroups\"\n" +
+	"                connection-template=\"'app/home/templates/connection.html'\"\n" +
+	"                connection-group-template=\"'app/home/templates/connectionGroup.html'\"\n" +
+	"                page-size=\"20\"></guac-group-list>\n" +
+	"        </div>\n" +
+	"\n" +
+	"    </div>\n" +
+	"\n" +
+	"</div>");
+}]);
+
+angular.module('app/list/templates/guacFilter.html', []).run(['$templateCache', function($templateCache) {
+	$templateCache.put('app/list/templates/guacFilter.html',
+	"<div class=\"filter\">\n" +
+	"    <!--\n" +
+	"       Copyright (C) 2015 Glyptodon LLC\n" +
+	"\n" +
+	"       Permission is hereby granted, free of charge, to any person obtaining a copy\n" +
+	"       of this software and associated documentation files (the \"Software\"), to deal\n" +
+	"       in the Software without restriction, including without limitation the rights\n" +
+	"       to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n" +
+	"       copies of the Software, and to permit persons to whom the Software is\n" +
+	"       furnished to do so, subject to the following conditions:\n" +
+	"\n" +
+	"       The above copyright notice and this permission notice shall be included in\n" +
+	"       all copies or substantial portions of the Software.\n" +
+	"\n" +
+	"       THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n" +
+	"       IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n" +
+	"       FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n" +
+	"       AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n" +
+	"       LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n" +
+	"       OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\n" +
+	"       THE SOFTWARE.\n" +
+	"    -->\n" +
+	"\n" +
+	"    <!-- Filter string -->\n" +
+	"    <input class=\"search-string\" placeholder=\"{{placeholder()}}\" type=\"text\" ng-model=\"searchString\"/>\n" +
+	"\n" +
+	"</div>");
+}]);
+
+angular.module('app/list/templates/guacPager.html', []).run(['$templateCache', function($templateCache) {
+	$templateCache.put('app/list/templates/guacPager.html',
+	"<div class=\"pager\" ng-show=\"pageNumbers.length > 1\">\n" +
+	"    <!--\n" +
+	"       Copyright (C) 2015 Glyptodon LLC\n" +
+	"\n" +
+	"       Permission is hereby granted, free of charge, to any person obtaining a copy\n" +
+	"       of this software and associated documentation files (the \"Software\"), to deal\n" +
+	"       in the Software without restriction, including without limitation the rights\n" +
+	"       to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n" +
+	"       copies of the Software, and to permit persons to whom the Software is\n" +
+	"       furnished to do so, subject to the following conditions:\n" +
+	"\n" +
+	"       The above copyright notice and this permission notice shall be included in\n" +
+	"       all copies or substantial portions of the Software.\n" +
+	"\n" +
+	"       THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n" +
+	"       IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n" +
+	"       FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n" +
+	"       AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n" +
+	"       LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n" +
+	"       OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\n" +
+	"       THE SOFTWARE.\n" +
+	"    -->\n" +
+	"\n" +
+	"    <!-- First / Previous -->\n" +
+	"    <div class=\"first-page icon\" ng-class=\"{disabled: !canSelectPage(firstPage)}\"    ng-click=\"selectPage(firstPage)\"/>\n" +
+	"    <div class=\"prev-page icon\"  ng-class=\"{disabled: !canSelectPage(previousPage)}\" ng-click=\"selectPage(previousPage)\"/>\n" +
+	"\n" +
+	"    <!-- Indicator of the existence of pages before the first page number shown -->\n" +
+	"    <div class=\"more-pages\" ng-show=\"hasMorePagesBefore()\">...</div>\n" +
+	"    \n" +
+	"    <!-- Page numbers -->\n" +
+	"    <ul class=\"page-numbers\">\n" +
+	"        <li class=\"set-page\"\n" +
+	"            ng-class=\"{current: isSelected(pageNumber)}\"\n" +
+	"            ng-repeat=\"pageNumber in pageNumbers\"\n" +
+	"            ng-click=\"selectPage(pageNumber)\">{{pageNumber}}</li>\n" +
+	"    </ul>\n" +
+	"\n" +
+	"    <!-- Indicator of the existence of pages beyond the last page number shown -->\n" +
+	"    <div class=\"more-pages\" ng-show=\"hasMorePagesAfter()\">...</div>\n" +
+	"\n" +
+	"    <!-- Next / Last -->\n" +
+	"    <div class=\"next-page icon\" ng-class=\"{disabled: !canSelectPage(nextPage)}\" ng-click=\"selectPage(nextPage)\"/>\n" +
+	"    <div class=\"last-page icon\" ng-class=\"{disabled: !canSelectPage(lastPage)}\" ng-click=\"selectPage(lastPage)\"/>\n" +
+	"\n" +
+	"</div>");
+}]);
+
+angular.module('app/login/templates/login.html', []).run(['$templateCache', function($templateCache) {
+	$templateCache.put('app/login/templates/login.html',
+	"<div class=\"login-ui\" ng-class=\"{error: loginError, continuation: isContinuation(), initial: !isContinuation()}\" >\n" +
+	"    <!--\n" +
+	"    Copyright 2014 Glyptodon LLC.\n" +
+	"\n" +
+	"    Permission is hereby granted, free of charge, to any person obtaining a copy\n" +
+	"    of this software and associated documentation files (the \"Software\"), to deal\n" +
+	"    in the Software without restriction, including without limitation the rights\n" +
+	"    to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n" +
+	"    copies of the Software, and to permit persons to whom the Software is\n" +
+	"    furnished to do so, subject to the following conditions:\n" +
+	"\n" +
+	"    The above copyright notice and this permission notice shall be included in\n" +
+	"    all copies or substantial portions of the Software.\n" +
+	"\n" +
+	"    THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n" +
+	"    IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n" +
+	"    FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n" +
+	"    AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n" +
+	"    LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n" +
+	"    OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\n" +
+	"    THE SOFTWARE.\n" +
+	"    -->\n" +
+	"\n" +
+	"    <!-- Login error message -->\n" +
+	"    <p class=\"login-error\">{{loginError | translate}}</p>\n" +
+	"\n" +
+	"    <div class=\"login-dialog-middle\">\n" +
+	"\n" +
+	"        <div class=\"login-dialog\">\n" +
+	"\n" +
+	"            <form class=\"login-form\" ng-submit=\"login()\">\n" +
+	"\n" +
+	"                <!-- Guacamole version -->\n" +
+	"                <div class=\"logo\"></div>\n" +
+	"                <div class=\"version\">{{'APP.NAME' | translate}}</div>\n" +
+	"\n" +
+	"                <!-- Login message/instructions -->\n" +
+	"                <p ng-show=\"helpText\">{{helpText | translate}}</p>\n" +
+	"\n" +
+	"                <!-- Login fields -->\n" +
+	"                <div class=\"login-fields\">\n" +
+	"                    <guac-form namespace=\"'LOGIN'\" content=\"remainingFields\" model=\"enteredValues\"></guac-form>\n" +
+	"                </div>\n" +
+	"\n" +
+	"                <!-- Submit button -->\n" +
+	"                <div class=\"buttons\">\n" +
+	"                    <input type=\"submit\" name=\"login\" class=\"login\" value=\"{{'LOGIN.ACTION_LOGIN' | translate}}\"/>\n" +
+	"                    <input type=\"submit\" name=\"login\" class=\"continue-login\" value=\"{{'LOGIN.ACTION_CONTINUE' | translate}}\"/>\n" +
+	"                </div>\n" +
+	"\n" +
+	"            </form>\n" +
+	"\n" +
+	"        </div>\n" +
+	"\n" +
+	"    </div>\n" +
+	"\n" +
+	"</div>");
+}]);
+
+angular.module('app/manage/templates/connectionGroupPermission.html', []).run(['$templateCache', function($templateCache) {
+	$templateCache.put('app/manage/templates/connectionGroupPermission.html',
+	"<div class=\"choice\">\n" +
+	"    <!--\n" +
+	"       Copyright (C) 2014 Glyptodon LLC\n" +
+	"\n" +
+	"       Permission is hereby granted, free of charge, to any person obtaining a copy\n" +
+	"       of this software and associated documentation files (the \"Software\"), to deal\n" +
+	"       in the Software without restriction, including without limitation the rights\n" +
+	"       to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n" +
+	"       copies of the Software, and to permit persons to whom the Software is\n" +
+	"       furnished to do so, subject to the following conditions:\n" +
+	"\n" +
+	"       The above copyright notice and this permission notice shall be included in\n" +
+	"       all copies or substantial portions of the Software.\n" +
+	"\n" +
+	"       THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n" +
+	"       IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n" +
+	"       FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n" +
+	"       AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n" +
+	"       LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n" +
+	"       OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\n" +
+	"       THE SOFTWARE.\n" +
+	"    -->\n" +
+	"\n" +
+	"    <input type=\"checkbox\" ng-model=\"context.getPermissionFlags().connectionGroupPermissions.READ[item.identifier]\"\n" +
+	"                           ng-change=\"context.connectionGroupPermissionChanged(item.identifier)\"/>\n" +
+	"\n" +
+	"    <span class=\"name\">{{item.name}}</span>\n" +
+	"</div>");
+}]);
+
+angular.module('app/manage/templates/connectionPermission.html', []).run(['$templateCache', function($templateCache) {
+	$templateCache.put('app/manage/templates/connectionPermission.html',
+	"<div class=\"choice\">\n" +
+	"    <!--\n" +
+	"       Copyright (C) 2014 Glyptodon LLC\n" +
+	"\n" +
+	"       Permission is hereby granted, free of charge, to any person obtaining a copy\n" +
+	"       of this software and associated documentation files (the \"Software\"), to deal\n" +
+	"       in the Software without restriction, including without limitation the rights\n" +
+	"       to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n" +
+	"       copies of the Software, and to permit persons to whom the Software is\n" +
+	"       furnished to do so, subject to the following conditions:\n" +
+	"\n" +
+	"       The above copyright notice and this permission notice shall be included in\n" +
+	"       all copies or substantial portions of the Software.\n" +
+	"\n" +
+	"       THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n" +
+	"       IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n" +
+	"       FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n" +
+	"       AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n" +
+	"       LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n" +
+	"       OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\n" +
+	"       THE SOFTWARE.\n" +
+	"    -->\n" +
+	"\n" +
+	"    <!-- Connection icon -->\n" +
+	"    <div class=\"protocol\">\n" +
+	"        <div class=\"icon type\" ng-class=\"item.protocol\"></div>\n" +
+	"    </div>\n" +
+	"\n" +
+	"    <!-- Checkbox -->\n" +
+	"    <input type=\"checkbox\" ng-model=\"context.getPermissionFlags().connectionPermissions.READ[item.identifier]\"\n" +
+	"                           ng-change=\"context.connectionPermissionChanged(item.identifier)\"/>\n" +
+	"\n" +
+	"    <!-- Connection name -->\n" +
+	"    <span class=\"name\">{{item.name}}</span>\n" +
+	"\n" +
+	"</div>");
+}]);
+
+angular.module('app/manage/templates/locationChooser.html', []).run(['$templateCache', function($templateCache) {
+	$templateCache.put('app/manage/templates/locationChooser.html',
+	"<div class=\"location-chooser\">\n" +
+	"    <!--\n" +
+	"    Copyright 2014 Glyptodon LLC.\n" +
+	"\n" +
+	"    Permission is hereby granted, free of charge, to any person obtaining a copy\n" +
+	"    of this software and associated documentation files (the \"Software\"), to deal\n" +
+	"    in the Software without restriction, including without limitation the rights\n" +
+	"    to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n" +
+	"    copies of the Software, and to permit persons to whom the Software is\n" +
+	"    furnished to do so, subject to the following conditions:\n" +
+	"\n" +
+	"    The above copyright notice and this permission notice shall be included in\n" +
+	"    all copies or substantial portions of the Software.\n" +
+	"\n" +
+	"    THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n" +
+	"    IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n" +
+	"    FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n" +
+	"    AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n" +
+	"    LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n" +
+	"    OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\n" +
+	"    THE SOFTWARE.\n" +
+	"    -->\n" +
+	"\n" +
+	"    <!-- Chosen group name -->\n" +
+	"    <div ng-click=\"toggleMenu()\" class=\"location\">{{chosenConnectionGroupName}}</div>\n" +
+	"\n" +
+	"    <!-- Dropdown hierarchical menu of groups -->\n" +
+	"    <div ng-show=\"menuOpen\" class=\"dropdown\">\n" +
+	"        <guac-group-list\n" +
+	"            context=\"groupListContext\"\n" +
+	"            show-root-group=\"true\"\n" +
+	"            connection-groups=\"rootGroups\"\n" +
+	"            connection-group-template=\"'app/manage/templates/locationChooserConnectionGroup.html'\"/>\n" +
+	"    </div>\n" +
+	"\n" +
+	"</div>");
+}]);
+
+angular.module('app/manage/templates/locationChooserConnectionGroup.html', []).run(['$templateCache', function($templateCache) {
+	$templateCache.put('app/manage/templates/locationChooserConnectionGroup.html',
+	"<span class=\"name\" ng-click=\"context.chooseGroup(item.wrappedItem)\">\n" +
+	"    <!--\n" +
+	"       Copyright (C) 2014 Glyptodon LLC\n" +
+	"\n" +
+	"       Permission is hereby granted, free of charge, to any person obtaining a copy\n" +
+	"       of this software and associated documentation files (the \"Software\"), to deal\n" +
+	"       in the Software without restriction, including without limitation the rights\n" +
+	"       to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n" +
+	"       copies of the Software, and to permit persons to whom the Software is\n" +
+	"       furnished to do so, subject to the following conditions:\n" +
+	"\n" +
+	"       The above copyright notice and this permission notice shall be included in\n" +
+	"       all copies or substantial portions of the Software.\n" +
+	"\n" +
+	"       THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n" +
+	"       IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n" +
+	"       FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n" +
+	"       AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n" +
+	"       LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n" +
+	"       OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\n" +
+	"       THE SOFTWARE.\n" +
+	"    -->\n" +
+	"\n" +
+	"    {{item.name}}\n" +
+	"</span>");
+}]);
+
+angular.module('app/manage/templates/manageConnection.html', []).run(['$templateCache', function($templateCache) {
+	$templateCache.put('app/manage/templates/manageConnection.html',
+	"<!--\n" +
+	"Copyright 2014 Glyptodon LLC.\n" +
+	"\n" +
+	"Permission is hereby granted, free of charge, to any person obtaining a copy\n" +
+	"of this software and associated documentation files (the \"Software\"), to deal\n" +
+	"in the Software without restriction, including without limitation the rights\n" +
+	"to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n" +
+	"copies of the Software, and to permit persons to whom the Software is\n" +
+	"furnished to do so, subject to the following conditions:\n" +
+	"\n" +
+	"The above copyright notice and this permission notice shall be included in\n" +
+	"all copies or substantial portions of the Software.\n" +
+	"\n" +
+	"THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n" +
+	"IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n" +
+	"FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n" +
+	"AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n" +
+	"LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n" +
+	"OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\n" +
+	"THE SOFTWARE.\n" +
+	"-->\n" +
+	"\n" +
+	"<div class=\"view\" ng-class=\"{loading: !isLoaded()}\">\n" +
+	"\n" +
+	"    <!-- Main property editor -->\n" +
+	"    <div class=\"header\">\n" +
+	"        <h2>{{'MANAGE_CONNECTION.SECTION_HEADER_EDIT_CONNECTION' | translate}}</h2>\n" +
+	"        <guac-user-menu></guac-user-menu>\n" +
+	"    </div>\n" +
+	"    <div class=\"section\">\n" +
+	"        <table class=\"properties\">\n" +
+	"            \n" +
+	"            <!-- Edit connection name -->\n" +
+	"            <tr>\n" +
+	"                <th>{{'MANAGE_CONNECTION.FIELD_HEADER_NAME' | translate}}</th>\n" +
+	"              \n" +
+	"                <td><input type=\"text\" ng-model=\"connection.name\" autocorrect=\"off\" autocapitalize=\"off\"/></td>\n" +
+	"            </tr>\n" +
+	"            \n" +
+	"            <!-- Edit connection location -->\n" +
+	"            <tr>\n" +
+	"                <th>{{'MANAGE_CONNECTION.FIELD_HEADER_LOCATION' | translate}}</th>\n" +
+	"              \n" +
+	"                <td>\n" +
+	"                    <location-chooser\n" +
+	"                        data-data-source=\"selectedDataSource\" root-group=\"rootGroup\"\n" +
+	"                        value=\"connection.parentIdentifier\"></location-chooser>\n" +
+	"                </td>\n" +
+	"            </tr>\n" +
+	"            \n" +
+	"            \n" +
+	"            <!-- Edit connection protocol -->\n" +
+	"            <tr>\n" +
+	"                <th>{{'MANAGE_CONNECTION.FIELD_HEADER_PROTOCOL' | translate}}</th>\n" +
+	"                <td>\n" +
+	"                    <select ng-model=\"connection.protocol\" ng-options=\"name as getProtocolName(protocol.name) | translate for (name, protocol) in protocols | orderBy: name\"></select>\n" +
+	"                </td>\n" +
+	"            </tr>\n" +
+	"        </table>\n" +
+	"    </div>\n" +
+	"\n" +
+	"    <!-- Connection attributes section -->\n" +
+	"    <div class=\"attributes\">\n" +
+	"        <guac-form namespace=\"'CONNECTION_ATTRIBUTES'\" content=\"attributes\" model=\"connection.attributes\"></guac-form>\n" +
+	"    </div>\n" +
+	"\n" +
+	"    <!-- Connection parameters -->\n" +
+	"    <h2 class=\"header\">{{'MANAGE_CONNECTION.SECTION_HEADER_PARAMETERS' | translate}}</h2>\n" +
+	"    <div class=\"section connection-parameters\" ng-class=\"{loading: !parameters}\">\n" +
+	"        <guac-form namespace=\"getNamespace(connection.protocol)\"\n" +
+	"                   content=\"protocols[connection.protocol].forms\"\n" +
+	"                   model=\"parameters\"></guac-form>\n" +
+	"    </div>\n" +
+	"\n" +
+	"    <!-- Form action buttons -->\n" +
+	"    <div class=\"action-buttons\">\n" +
+	"        <button ng-show=\"canSaveConnection\" ng-click=\"saveConnection()\">{{'MANAGE_CONNECTION.ACTION_SAVE' | translate}}</button>\n" +
+	"        <button ng-show=\"canCloneConnection\" ng-click=\"cloneConnection()\">{{'MANAGE_CONNECTION.ACTION_CLONE' | translate}}</button>\n" +
+	"        <button ng-click=\"cancel()\">{{'MANAGE_CONNECTION.ACTION_CANCEL' | translate}}</button>\n" +
+	"        <button ng-show=\"canDeleteConnection\" ng-click=\"deleteConnection()\" class=\"danger\">{{'MANAGE_CONNECTION.ACTION_DELETE' | translate}}</button>\n" +
+	"    </div>\n" +
+	"\n" +
+	"    <!-- Connection history -->\n" +
+	"    <h2 class=\"header\">{{'MANAGE_CONNECTION.SECTION_HEADER_HISTORY' | translate}}</h2>\n" +
+	"    <div class=\"history section\" ng-class=\"{loading: !historyEntryWrappers}\">\n" +
+	"        <p ng-hide=\"historyEntryWrappers.length\">{{'MANAGE_CONNECTION.INFO_CONNECTION_NOT_USED' | translate}}</p>\n" +
+	"\n" +
+	"        <!-- History list -->\n" +
+	"        <table ng-show=\"historyEntryWrappers.length\">\n" +
+	"            <thead>\n" +
+	"                <tr>\n" +
+	"                    <th>{{'MANAGE_CONNECTION.TABLE_HEADER_HISTORY_USERNAME' | translate}}</th>\n" +
+	"                    <th>{{'MANAGE_CONNECTION.TABLE_HEADER_HISTORY_START' | translate}}</th>\n" +
+	"                    <th>{{'MANAGE_CONNECTION.TABLE_HEADER_HISTORY_DURATION' | translate}}</th>\n" +
+	"                </tr>\n" +
+	"            </thead>\n" +
+	"            <tbody>\n" +
+	"                <tr ng-repeat=\"wrapper in wrapperPage\">\n" +
+	"                    <td class=\"username\">{{wrapper.entry.username}}</td>\n" +
+	"                    <td class=\"start\">{{wrapper.entry.startDate | date:historyDateFormat}}</td>\n" +
+	"                    <td class=\"duration\"\n" +
+	"                        translate=\"{{wrapper.durationText}}\"\n" +
+	"                        translate-values=\"{VALUE: wrapper.duration.value, UNIT: wrapper.duration.unit}\"></td>\n" +
+	"                </tr>\n" +
+	"            </tbody>\n" +
+	"        </table>\n" +
+	"\n" +
+	"        <!-- Pager controls for history list -->\n" +
+	"        <guac-pager page=\"wrapperPage\" items=\"historyEntryWrappers\"></guac-pager>\n" +
+	"\n" +
+	"    </div>\n" +
+	"\n" +
+	"</div>");
+}]);
+
+angular.module('app/manage/templates/manageConnectionGroup.html', []).run(['$templateCache', function($templateCache) {
+	$templateCache.put('app/manage/templates/manageConnectionGroup.html',
+	"<!--\n" +
+	"Copyright 2014 Glyptodon LLC.\n" +
+	"\n" +
+	"Permission is hereby granted, free of charge, to any person obtaining a copy\n" +
+	"of this software and associated documentation files (the \"Software\"), to deal\n" +
+	"in the Software without restriction, including without limitation the rights\n" +
+	"to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n" +
+	"copies of the Software, and to permit persons to whom the Software is\n" +
+	"furnished to do so, subject to the following conditions:\n" +
+	"\n" +
+	"The above copyright notice and this permission notice shall be included in\n" +
+	"all copies or substantial portions of the Software.\n" +
+	"\n" +
+	"THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n" +
+	"IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n" +
+	"FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n" +
+	"AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n" +
+	"LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n" +
+	"OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\n" +
+	"THE SOFTWARE.\n" +
+	"-->\n" +
+	"\n" +
+	"<div class=\"view\" ng-class=\"{loading: !isLoaded()}\">\n" +
+	"\n" +
+	"    <!-- Main property editor -->\n" +
+	"    <div class=\"header\">\n" +
+	"        <h2>{{'MANAGE_CONNECTION_GROUP.SECTION_HEADER_EDIT_CONNECTION_GROUP' | translate}}</h2>\n" +
+	"        <guac-user-menu></guac-user-menu>\n" +
+	"    </div>\n" +
+	"    <div class=\"section\">\n" +
+	"        <table class=\"properties\">\n" +
+	"                        \n" +
+	"            <!-- Edit connection group name -->\n" +
+	"            <tr>\n" +
+	"                <th>{{'MANAGE_CONNECTION_GROUP.FIELD_HEADER_NAME' | translate}}</th>\n" +
+	"                          \n" +
+	"                <td><input type=\"text\" ng-model=\"connectionGroup.name\" autocorrect=\"off\" autocapitalize=\"off\"/></td>\n" +
+	"            </tr>\n" +
+	"                        \n" +
+	"            <!-- Edit connection group location -->\n" +
+	"            <tr>\n" +
+	"                <th>{{'MANAGE_CONNECTION_GROUP.FIELD_HEADER_LOCATION' | translate}}</th>\n" +
+	"                          \n" +
+	"                <td>\n" +
+	"                    <location-chooser\n" +
+	"                        data-data-source=\"selectedDataSource\" root-group=\"rootGroup\"\n" +
+	"                        value=\"connectionGroup.parentIdentifier\"></location-chooser>\n" +
+	"                </td>\n" +
+	"            </tr>\n" +
+	"                        \n" +
+	"                        \n" +
+	"            <!-- Edit connection group type -->\n" +
+	"            <tr>\n" +
+	"                <th>{{'MANAGE_CONNECTION_GROUP.FIELD_HEADER_TYPE' | translate}}</th>\n" +
+	"                <td>\n" +
+	"                    <select ng-model=\"connectionGroup.type\" ng-options=\"type.value as type.label | translate for type in types | orderBy: name\"></select>\n" +
+	"                </td>\n" +
+	"            </tr>\n" +
+	"        </table>\n" +
+	"    </div>\n" +
+	"\n" +
+	"    <!-- Connection group attributes section -->\n" +
+	"    <div class=\"attributes\">\n" +
+	"        <guac-form namespace=\"'CONNECTION_GROUP_ATTRIBUTES'\" content=\"attributes\" model=\"connectionGroup.attributes\"></guac-form>\n" +
+	"    </div>\n" +
+	"\n" +
+	"    <!-- Form action buttons -->\n" +
+	"    <div class=\"action-buttons\">\n" +
+	"        <button ng-show=\"canSaveConnectionGroup\" ng-click=\"saveConnectionGroup()\">{{'MANAGE_CONNECTION_GROUP.ACTION_SAVE' | translate}}</button>\n" +
+	"        <button ng-click=\"cancel()\">{{'MANAGE_CONNECTION_GROUP.ACTION_CANCEL' | translate}}</button>\n" +
+	"        <button ng-show=\"canDeleteConnectionGroup\" ng-click=\"deleteConnectionGroup()\" class=\"danger\">{{'MANAGE_CONNECTION_GROUP.ACTION_DELETE' | translate}}</button>\n" +
+	"    </div>\n" +
+	"\n" +
+	"</div>");
+}]);
+
+angular.module('app/manage/templates/manageUser.html', []).run(['$templateCache', function($templateCache) {
+	$templateCache.put('app/manage/templates/manageUser.html',
+	"<!--\n" +
+	"Copyright 2015 Glyptodon LLC.\n" +
+	"\n" +
+	"Permission is hereby granted, free of charge, to any person obtaining a copy\n" +
+	"of this software and associated documentation files (the \"Software\"), to deal\n" +
+	"in the Software without restriction, including without limitation the rights\n" +
+	"to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n" +
+	"copies of the Software, and to permit persons to whom the Software is\n" +
+	"furnished to do so, subject to the following conditions:\n" +
+	"\n" +
+	"The above copyright notice and this permission notice shall be included in\n" +
+	"all copies or substantial portions of the Software.\n" +
+	"\n" +
+	"THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n" +
+	"IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n" +
+	"FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n" +
+	"AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n" +
+	"LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n" +
+	"OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\n" +
+	"THE SOFTWARE.\n" +
+	"-->\n" +
+	"\n" +
+	"<div class=\"manage-user view\" ng-class=\"{loading: !isLoaded()}\">\n" +
+	"\n" +
+	"    <!-- User header and data source tabs -->\n" +
+	"    <div class=\"username header\">\n" +
+	"        <h2>{{'MANAGE_USER.SECTION_HEADER_EDIT_USER' | translate}}</h2>\n" +
+	"        <guac-user-menu></guac-user-menu>\n" +
+	"    </div>\n" +
+	"    <div class=\"page-tabs\">\n" +
+	"        <guac-page-list pages=\"accountPages\"></guac-page-list>\n" +
+	"    </div>\n" +
+	"\n" +
+	"    <!-- Warn if user is read-only -->\n" +
+	"    <div class=\"section\" ng-show=\"isReadOnly()\">\n" +
+	"        <p class=\"notice read-only\">{{'MANAGE_USER.INFO_READ_ONLY' | translate}}</p>\n" +
+	"    </div>\n" +
+	"\n" +
+	"    <!-- Sections applicable to non-read-only users -->\n" +
+	"    <div ng-show=\"!isReadOnly()\">\n" +
+	"\n" +
+	"        <!-- User password section -->\n" +
+	"        <div class=\"section\">\n" +
+	"            <table class=\"properties\">\n" +
+	"                <tr>\n" +
+	"                    <th>{{'MANAGE_USER.FIELD_HEADER_USERNAME' | translate}}</th>\n" +
+	"                    <td>\n" +
+	"                        <input ng-show=\"canEditUsername()\" ng-model=\"user.username\" type=\"text\"/>\n" +
+	"                        <span  ng-hide=\"canEditUsername()\">{{user.username}}</span>\n" +
+	"                    </td>\n" +
+	"                </tr>\n" +
+	"                <tr>\n" +
+	"                    <th>{{'MANAGE_USER.FIELD_HEADER_PASSWORD' | translate}}</th>\n" +
+	"                    <td><input ng-model=\"user.password\" type=\"password\" /></td>\n" +
+	"                </tr>\n" +
+	"                <tr>\n" +
+	"                    <th>{{'MANAGE_USER.FIELD_HEADER_PASSWORD_AGAIN' | translate}}</th>\n" +
+	"                    <td><input ng-model=\"passwordMatch\" type=\"password\" /></td>\n" +
+	"                </tr>\n" +
+	"            </table>\n" +
+	"        </div>\n" +
+	"\n" +
+	"        <!-- User attributes section -->\n" +
+	"        <div class=\"attributes\" ng-show=\"canChangeAttributes()\">\n" +
+	"            <guac-form namespace=\"'USER_ATTRIBUTES'\" content=\"attributes\" model=\"user.attributes\"></guac-form>\n" +
+	"        </div>\n" +
+	"\n" +
+	"        <!-- System permissions section -->\n" +
+	"        <div class=\"system-permissions\" ng-show=\"canChangePermissions()\">\n" +
+	"            <h2 class=\"header\">{{'MANAGE_USER.SECTION_HEADER_PERMISSIONS' | translate}}</h2>\n" +
+	"            <div class=\"section\">\n" +
+	"                <table class=\"properties\">\n" +
+	"                    <tr ng-repeat=\"systemPermissionType in systemPermissionTypes\"\n" +
+	"                        ng-show=\"canChangeSystemPermissions()\">\n" +
+	"                        <th>{{systemPermissionType.label | translate}}</th>\n" +
+	"                        <td><input type=\"checkbox\" ng-model=\"permissionFlags.systemPermissions[systemPermissionType.value]\"\n" +
+	"                                                   ng-change=\"systemPermissionChanged(systemPermissionType.value)\"/></td>\n" +
+	"                    </tr>\n" +
+	"                    <tr>\n" +
+	"                        <th>{{'MANAGE_USER.FIELD_HEADER_CHANGE_OWN_PASSWORD' | translate}}</th>\n" +
+	"                        <td><input type=\"checkbox\" ng-model=\"permissionFlags.userPermissions.UPDATE[user.username]\"\n" +
+	"                                                   ng-change=\"userPermissionChanged('UPDATE', user.username)\"/></td>\n" +
+	"                    </tr>\n" +
+	"                </table>\n" +
+	"            </div>\n" +
+	"        </div>\n" +
+	"\n" +
+	"        <!-- Connection permissions section -->\n" +
+	"        <div class=\"connection-permissions\" ng-show=\"canChangePermissions()\">\n" +
+	"            <div class=\"header\">\n" +
+	"                <h2>{{'MANAGE_USER.SECTION_HEADER_CONNECTIONS' | translate}}</h2>\n" +
+	"                <guac-group-list-filter connection-groups=\"rootGroups\"\n" +
+	"                    filtered-connection-groups=\"filteredRootGroups\"\n" +
+	"                    placeholder=\"'MANAGE_USER.FIELD_PLACEHOLDER_FILTER' | translate\"\n" +
+	"                    connection-properties=\"filteredConnectionProperties\"\n" +
+	"                    connection-group-properties=\"filteredConnectionGroupProperties\"></guac-group-list-filter>\n" +
+	"            </div>\n" +
+	"            <div class=\"section\">\n" +
+	"                <guac-group-list\n" +
+	"                    context=\"groupListContext\"\n" +
+	"                    connection-groups=\"filteredRootGroups\"\n" +
+	"                    connection-template=\"'app/manage/templates/connectionPermission.html'\"\n" +
+	"                    connection-group-template=\"'app/manage/templates/connectionGroupPermission.html'\"\n" +
+	"                    page-size=\"20\"/>\n" +
+	"            </div>\n" +
+	"        </div>\n" +
+	"\n" +
+	"        <!-- Form action buttons -->\n" +
+	"        <div class=\"action-buttons\">\n" +
+	"            <button ng-show=\"canSaveUser()\" ng-click=\"saveUser()\">{{'MANAGE_USER.ACTION_SAVE' | translate}}</button>\n" +
+	"            <button ng-click=\"cancel()\">{{'MANAGE_USER.ACTION_CANCEL' | translate}}</button>\n" +
+	"            <button ng-show=\"canDeleteUser()\" ng-click=\"deleteUser()\" class=\"danger\">{{'MANAGE_USER.ACTION_DELETE' | translate}}</button>\n" +
+	"        </div>\n" +
+	"\n" +
+	"    </div>\n" +
+	"\n" +
+	"</div>");
+}]);
+
+angular.module('app/navigation/templates/guacPageList.html', []).run(['$templateCache', function($templateCache) {
+	$templateCache.put('app/navigation/templates/guacPageList.html',
+	"<div class=\"page-list\" ng-show=\"levels.length\">\n" +
+	"    <!--\n" +
+	"       Copyright (C) 2015 Glyptodon LLC\n" +
+	"\n" +
+	"       Permission is hereby granted, free of charge, to any person obtaining a copy\n" +
+	"       of this software and associated documentation files (the \"Software\"), to deal\n" +
+	"       in the Software without restriction, including without limitation the rights\n" +
+	"       to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n" +
+	"       copies of the Software, and to permit persons to whom the Software is\n" +
+	"       furnished to do so, subject to the following conditions:\n" +
+	"\n" +
+	"       The above copyright notice and this permission notice shall be included in\n" +
+	"       all copies or substantial portions of the Software.\n" +
+	"\n" +
+	"       THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n" +
+	"       IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n" +
+	"       FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n" +
+	"       AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n" +
+	"       LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n" +
+	"       OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\n" +
+	"       THE SOFTWARE.\n" +
+	"    -->\n" +
+	"\n" +
+	"    <!-- Navigation links -->\n" +
+	"    <ul class=\"page-list-level\" ng-repeat=\"level in levels track by $index\">\n" +
+	"        <li ng-repeat=\"page in getPages(level)\" class=\"{{page.className}}\">\n" +
+	"            <a class=\"home\" ng-click=\"navigateToPage(page)\"\n" +
+	"               ng-class=\"{current: isCurrentPage(page)}\" href=\"#{{page.url}}\">\n" +
+	"                {{page.name | translate}}\n" +
+	"            </a>\n" +
+	"        </li>\n" +
+	"    </ul>\n" +
+	"\n" +
+	"</div>");
+}]);
+
+angular.module('app/navigation/templates/guacUserMenu.html', []).run(['$templateCache', function($templateCache) {
+	$templateCache.put('app/navigation/templates/guacUserMenu.html',
+	"<div class=\"user-menu\">\n" +
+	"    <!--\n" +
+	"       Copyright (C) 2015 Glyptodon LLC\n" +
+	"\n" +
+	"       Permission is hereby granted, free of charge, to any person obtaining a copy\n" +
+	"       of this software and associated documentation files (the \"Software\"), to deal\n" +
+	"       in the Software without restriction, including without limitation the rights\n" +
+	"       to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n" +
+	"       copies of the Software, and to permit persons to whom the Software is\n" +
+	"       furnished to do so, subject to the following conditions:\n" +
+	"\n" +
+	"       The above copyright notice and this permission notice shall be included in\n" +
+	"       all copies or substantial portions of the Software.\n" +
+	"\n" +
+	"       THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n" +
+	"       IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n" +
+	"       FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n" +
+	"       AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n" +
+	"       LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n" +
+	"       OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\n" +
+	"       THE SOFTWARE.\n" +
+	"    -->\n" +
+	"\n" +
+	"    <div class=\"user-menu-dropdown\" ng-class=\"{open: menuShown}\" ng-click=\"toggleMenu()\">\n" +
+	"        <div class=\"username\">{{username}}</div>\n" +
+	"        <div class=\"menu-indicator\"></div>\n" +
+	"        \n" +
+	"        <!-- Menu options -->\n" +
+	"        <div class=\"options\">\n" +
+	"            \n" +
+	"            <!-- Local actions -->\n" +
+	"            <ul class=\"action-list\">\n" +
+	"                <li ng-repeat=\"action in localActions\">\n" +
+	"                    <a ng-class=\"action.className\" ng-click=\"action.callback()\">\n" +
+	"                        {{action.name | translate}}\n" +
+	"                    </a>\n" +
+	"                </li>\n" +
+	"            </ul>\n" +
+	"\n" +
+	"            <!-- Navigation links -->\n" +
+	"            <guac-page-list pages=\"pages\"></guac-page-list>\n" +
+	"\n" +
+	"            <!-- Actions -->\n" +
+	"            <ul class=\"action-list\">\n" +
+	"                <li ng-repeat=\"action in actions\">\n" +
+	"                    <a ng-class=\"action.className\" ng-click=\"action.callback()\">\n" +
+	"                        {{action.name | translate}}\n" +
+	"                    </a>\n" +
+	"                </li>\n" +
+	"            </ul>\n" +
+	"\n" +
+	"        </div>\n" +
+	"    </div>\n" +
+	"\n" +
+	"</div>");
+}]);
+
+angular.module('app/notification/templates/guacNotification.html', []).run(['$templateCache', function($templateCache) {
+	$templateCache.put('app/notification/templates/guacNotification.html',
+	"<div class=\"notification\" ng-class=\"notification.className\">\n" +
+	"    <!--\n" +
+	"       Copyright (C) 2014 Glyptodon LLC\n" +
+	"\n" +
+	"       Permission is hereby granted, free of charge, to any person obtaining a copy\n" +
+	"       of this software and associated documentation files (the \"Software\"), to deal\n" +
+	"       in the Software without restriction, including without limitation the rights\n" +
+	"       to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n" +
+	"       copies of the Software, and to permit persons to whom the Software is\n" +
+	"       furnished to do so, subject to the following conditions:\n" +
+	"\n" +
+	"       The above copyright notice and this permission notice shall be included in\n" +
+	"       all copies or substantial portions of the Software.\n" +
+	"\n" +
+	"       THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n" +
+	"       IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n" +
+	"       FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n" +
+	"       AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n" +
+	"       LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n" +
+	"       OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\n" +
+	"       THE SOFTWARE.\n" +
+	"    -->\n" +
+	"\n" +
+	"    <!-- Notification title -->\n" +
+	"    <div ng-show=\"notification.title\" class=\"title-bar\">\n" +
+	"        <div class=\"title\">{{notification.title | translate}}</div>\n" +
+	"    </div>\n" +
+	"\n" +
+	"    <div class=\"body\">\n" +
+	"\n" +
+	"        <!-- Notification text -->\n" +
+	"        <p ng-show=\"notification.text\" class=\"text\">{{notification.text | translate}}</p>\n" +
+	"\n" +
+	"        <!-- Current progress -->\n" +
+	"        <div class=\"progress\" ng-show=\"notification.progress\"><div class=\"bar\" ng-show=\"progressPercent\" ng-style=\"{'width': progressPercent + '%'}\"></div><div\n" +
+	"                ng-show=\"notification.progress.text\"\n" +
+	"                translate=\"{{notification.progress.text}}\"\n" +
+	"                translate-values=\"{PROGRESS: notification.progress.value, UNIT: notification.progress.unit}\"></div></div>\n" +
+	"\n" +
+	"        <!-- Default action countdown text -->\n" +
+	"        <p class=\"countdown-text\"\n" +
+	"           ng-show=\"notification.countdown.text\"\n" +
+	"           translate=\"{{notification.countdown.text}}\"\n" +
+	"           translate-values=\"{REMAINING: timeRemaining}\"></p>\n" +
+	"\n" +
+	"    </div>\n" +
+	"\n" +
+	"    <!-- Buttons -->\n" +
+	"    <div ng-show=\"notification.actions.length\" class=\"buttons\">\n" +
+	"        <button ng-repeat=\"action in notification.actions\" ng-click=\"action.callback()\" ng-class=\"action.className\">{{action.name | translate}}</button>\n" +
+	"    </div>\n" +
+	"\n" +
+	"</div>");
+}]);
+
+angular.module('app/osk/templates/guacOsk.html', []).run(['$templateCache', function($templateCache) {
+	$templateCache.put('app/osk/templates/guacOsk.html',
+	"<div class=\"osk\" guac-resize=\"keyboardResized\">\n" +
+	"    <!--\n" +
+	"       Copyright (C) 2014 Glyptodon LLC\n" +
+	"\n" +
+	"       Permission is hereby granted, free of charge, to any person obtaining a copy\n" +
+	"       of this software and associated documentation files (the \"Software\"), to deal\n" +
+	"       in the Software without restriction, including without limitation the rights\n" +
+	"       to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n" +
+	"       copies of the Software, and to permit persons to whom the Software is\n" +
+	"       furnished to do so, subject to the following conditions:\n" +
+	"\n" +
+	"       The above copyright notice and this permission notice shall be included in\n" +
+	"       all copies or substantial portions of the Software.\n" +
+	"\n" +
+	"       THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n" +
+	"       IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n" +
+	"       FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n" +
+	"       AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n" +
+	"       LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n" +
+	"       OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\n" +
+	"       THE SOFTWARE.\n" +
+	"    -->\n" +
+	"</div>");
+}]);
+
+angular.module('app/settings/templates/connection.html', []).run(['$templateCache', function($templateCache) {
+	$templateCache.put('app/settings/templates/connection.html',
+	"<a ng-href=\"#/manage/{{item.dataSource}}/connections/{{item.identifier}}\">\n" +
+	"    <!--\n" +
+	"       Copyright (C) 2014 Glyptodon LLC\n" +
+	"\n" +
+	"       Permission is hereby granted, free of charge, to any person obtaining a copy\n" +
+	"       of this software and associated documentation files (the \"Software\"), to deal\n" +
+	"       in the Software without restriction, including without limitation the rights\n" +
+	"       to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n" +
+	"       copies of the Software, and to permit persons to whom the Software is\n" +
+	"       furnished to do so, subject to the following conditions:\n" +
+	"\n" +
+	"       The above copyright notice and this permission notice shall be included in\n" +
+	"       all copies or substantial portions of the Software.\n" +
+	"\n" +
+	"       THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n" +
+	"       IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n" +
+	"       FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n" +
+	"       AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n" +
+	"       LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n" +
+	"       OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\n" +
+	"       THE SOFTWARE.\n" +
+	"    -->\n" +
+	"\n" +
+	"    <div class=\"caption\" ng-class=\"{active: item.getActiveConnections()}\">\n" +
+	"\n" +
+	"        <!-- Connection icon -->\n" +
+	"        <div class=\"protocol\">\n" +
+	"            <div class=\"icon type\" ng-class=\"item.protocol\"></div>\n" +
+	"        </div>\n" +
+	"\n" +
+	"        <!-- Connection name -->\n" +
+	"        <span class=\"name\">{{item.name}}</span>\n" +
+	"\n" +
+	"        <!-- Active user count -->\n" +
+	"        <span class=\"activeUserCount\" ng-show=\"item.getActiveConnections()\"\n" +
+	"            translate=\"SETTINGS_CONNECTIONS.INFO_ACTIVE_USER_COUNT\"\n" +
+	"            translate-values=\"{USERS: item.getActiveConnections()}\"></span>\n" +
+	"        \n" +
+	"    </div>\n" +
+	"</a>");
+}]);
+
+angular.module('app/settings/templates/connectionGroup.html', []).run(['$templateCache', function($templateCache) {
+	$templateCache.put('app/settings/templates/connectionGroup.html',
+	"<a ng-href=\"#/manage/{{item.dataSource}}/connectionGroups/{{item.identifier}}\">\n" +
+	"    <!--\n" +
+	"       Copyright (C) 2014 Glyptodon LLC\n" +
+	"\n" +
+	"       Permission is hereby granted, free of charge, to any person obtaining a copy\n" +
+	"       of this software and associated documentation files (the \"Software\"), to deal\n" +
+	"       in the Software without restriction, including without limitation the rights\n" +
+	"       to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n" +
+	"       copies of the Software, and to permit persons to whom the Software is\n" +
+	"       furnished to do so, subject to the following conditions:\n" +
+	"\n" +
+	"       The above copyright notice and this permission notice shall be included in\n" +
+	"       all copies or substantial portions of the Software.\n" +
+	"\n" +
+	"       THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n" +
+	"       IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n" +
+	"       FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n" +
+	"       AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n" +
+	"       LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n" +
+	"       OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\n" +
+	"       THE SOFTWARE.\n" +
+	"    -->\n" +
+	"\n" +
+	"    <span class=\"name\">{{item.name}}</span>\n" +
+	"</a>");
+}]);
+
+angular.module('app/settings/templates/settings.html', []).run(['$templateCache', function($templateCache) {
+	$templateCache.put('app/settings/templates/settings.html',
+	"<!--\n" +
+	"Copyright 2015 Glyptodon LLC.\n" +
+	"\n" +
+	"Permission is hereby granted, free of charge, to any person obtaining a copy\n" +
+	"of this software and associated documentation files (the \"Software\"), to deal\n" +
+	"in the Software without restriction, including without limitation the rights\n" +
+	"to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n" +
+	"copies of the Software, and to permit persons to whom the Software is\n" +
+	"furnished to do so, subject to the following conditions:\n" +
+	"\n" +
+	"The above copyright notice and this permission notice shall be included in\n" +
+	"all copies or substantial portions of the Software.\n" +
+	"\n" +
+	"THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n" +
+	"IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n" +
+	"FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n" +
+	"AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n" +
+	"LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n" +
+	"OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\n" +
+	"THE SOFTWARE.\n" +
+	"-->\n" +
+	"\n" +
+	"<div class=\"view\">\n" +
+	"\n" +
+	"    <div class=\"header\">\n" +
+	"        <h2>{{'SETTINGS.SECTION_HEADER_SETTINGS' | translate}}</h2>\n" +
+	"        <guac-user-menu></guac-user-menu>\n" +
+	"    </div>\n" +
+	"\n" +
+	"    <!-- Available tabs -->\n" +
+	"    <div class=\"page-tabs\">\n" +
+	"        <guac-page-list pages=\"settingsPages\"></guac-page-list>\n" +
+	"    </div>\n" +
+	"\n" +
+	"    <!-- Selected tab -->\n" +
+	"    <guac-settings-users                ng-if=\"activeTab === 'users'\"></guac-settings-users>\n" +
+	"    <guac-settings-connections          ng-if=\"activeTab === 'connections'\"></guac-settings-connections>\n" +
+	"    <guac-settings-connection-history   ng-if=\"activeTab === 'history'\"></guac-settings-connection-history>\n" +
+	"    <guac-settings-sessions             ng-if=\"activeTab === 'sessions'\"></guac-settings-sessions>\n" +
+	"    <guac-settings-preferences          ng-if=\"activeTab === 'preferences'\"></guac-settings-preferences>\n" +
+	"\n" +
+	"</div>");
+}]);
+
+angular.module('app/settings/templates/settingsConnectionHistory.html', []).run(['$templateCache', function($templateCache) {
+	$templateCache.put('app/settings/templates/settingsConnectionHistory.html',
+	"<div class=\"settings section connectionHistory\">\n" +
+	"    <!--\n" +
+	"    Copyright 2015 Glyptodon LLC.\n" +
+	"\n" +
+	"    Permission is hereby granted, free of charge, to any person obtaining a copy\n" +
+	"    of this software and associated documentation files (the \"Software\"), to deal\n" +
+	"    in the Software without restriction, including without limitation the rights\n" +
+	"    to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n" +
+	"    copies of the Software, and to permit persons to whom the Software is\n" +
+	"    furnished to do so, subject to the following conditions:\n" +
+	"\n" +
+	"    The above copyright notice and this permission notice shall be included in\n" +
+	"    all copies or substantial portions of the Software.\n" +
+	"\n" +
+	"    THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n" +
+	"    IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n" +
+	"    FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n" +
+	"    AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n" +
+	"    LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n" +
+	"    OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\n" +
+	"    THE SOFTWARE.\n" +
+	"    -->\n" +
+	"\n" +
+	"    <!-- Connection history -->\n" +
+	"    <p>{{'SETTINGS_CONNECTION_HISTORY.HELP_CONNECTION_HISTORY' | translate}}</p>\n" +
+	"\n" +
+	"    <!-- Search controls -->\n" +
+	"    <form class=\"filter\" ng-submit=\"search()\">\n" +
+	"        <input class=\"search-string\" type=\"text\" placeholder=\"{{'SETTINGS_CONNECTION_HISTORY.FIELD_PLACEHOLDER_FILTER' | translate}}\" ng-model=\"searchString\" />\n" +
+	"        <input class=\"search-button\" type=\"submit\" value=\"{{'SETTINGS_CONNECTION_HISTORY.ACTION_SEARCH' | translate}}\" />\n" +
+	"    </form>\n" +
+	"\n" +
+	"    <!-- Search results -->\n" +
+	"    <div class=\"results\">\n" +
+	"\n" +
+	"        <!-- List of matching history records -->\n" +
+	"        <table class=\"sorted history-list\">\n" +
+	"            <thead>\n" +
+	"                <tr>\n" +
+	"                    <th guac-sort-order=\"order\" guac-sort-property=\"'username'\">\n" +
+	"                        {{'SETTINGS_CONNECTION_HISTORY.TABLE_HEADER_SESSION_USERNAME' | translate}}\n" +
+	"                    </th>\n" +
+	"                    <th guac-sort-order=\"order\" guac-sort-property=\"'startDate'\">\n" +
+	"                        {{'SETTINGS_CONNECTION_HISTORY.TABLE_HEADER_SESSION_STARTDATE' | translate}}\n" +
+	"                    </th>\n" +
+	"                    <th guac-sort-order=\"order\" guac-sort-property=\"'duration'\">\n" +
+	"                        {{'SETTINGS_CONNECTION_HISTORY.TABLE_HEADER_SESSION_DURATION' | translate}}\n" +
+	"                    </th>\n" +
+	"                    <th guac-sort-order=\"order\" guac-sort-property=\"'connectionName'\">\n" +
+	"                        {{'SETTINGS_CONNECTION_HISTORY.TABLE_HEADER_SESSION_CONNECTION_NAME' | translate}}\n" +
+	"                    </th>\n" +
+	"                </tr>\n" +
+	"            </thead>\n" +
+	"            <tbody ng-class=\"{loading: !isLoaded()}\">\n" +
+	"                <tr ng-repeat=\"historyEntryWrapper in historyEntryWrapperPage\" class=\"history\">\n" +
+	"                    <td>{{historyEntryWrapper.username}}</td>\n" +
+	"                    <td>{{historyEntryWrapper.startDate | date : dateFormat}}</td>\n" +
+	"                    <td translate=\"{{historyEntryWrapper.readableDurationText}}\"\n" +
+	"                        translate-values=\"{VALUE: historyEntryWrapper.readableDuration.value, UNIT: historyEntryWrapper.readableDuration.unit}\"></td>\n" +
+	"                    <td>{{historyEntryWrapper.connectionName}}</td>\n" +
+	"                </tr>\n" +
+	"            </tbody>\n" +
+	"        </table>\n" +
+	"\n" +
+	"        <!-- Text displayed if no history exists -->\n" +
+	"        <p class=\"placeholder\" ng-show=\"isHistoryEmpty()\">\n" +
+	"            {{'SETTINGS_CONNECTION_HISTORY.INFO_NO_HISTORY' | translate}}\n" +
+	"        </p>\n" +
+	"\n" +
+	"        <!-- Pager for history list -->\n" +
+	"        <guac-pager page=\"historyEntryWrapperPage\" page-size=\"25\"\n" +
+	"                    items=\"historyEntryWrappers | orderBy : order.predicate\"></guac-pager>\n" +
+	"    </div>\n" +
+	"\n" +
+	"</div>");
+}]);
+
+angular.module('app/settings/templates/settingsConnections.html', []).run(['$templateCache', function($templateCache) {
+	$templateCache.put('app/settings/templates/settingsConnections.html',
+	"<div class=\"settings section connections\" ng-class=\"{loading: !isLoaded()}\">\n" +
+	"    <!--\n" +
+	"    Copyright 2015 Glyptodon LLC.\n" +
+	"\n" +
+	"    Permission is hereby granted, free of charge, to any person obtaining a copy\n" +
+	"    of this software and associated documentation files (the \"Software\"), to deal\n" +
+	"    in the Software without restriction, including without limitation the rights\n" +
+	"    to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n" +
+	"    copies of the Software, and to permit persons to whom the Software is\n" +
+	"    furnished to do so, subject to the following conditions:\n" +
+	"\n" +
+	"    The above copyright notice and this permission notice shall be included in\n" +
+	"    all copies or substantial portions of the Software.\n" +
+	"\n" +
+	"    THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n" +
+	"    IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n" +
+	"    FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n" +
+	"    AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n" +
+	"    LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n" +
+	"    OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\n" +
+	"    THE SOFTWARE.\n" +
+	"    -->\n" +
+	"\n" +
+	"    <!-- Connection management -->\n" +
+	"    <p>{{'SETTINGS_CONNECTIONS.HELP_CONNECTIONS' | translate}}</p>\n" +
+	"\n" +
+	"    <!-- Connection management toolbar -->\n" +
+	"    <div class=\"toolbar\">\n" +
+	"\n" +
+	"        <!-- Form action buttons -->\n" +
+	"        <div class=\"action-buttons\">\n" +
+	"\n" +
+	"            <a class=\"add-connection button\"\n" +
+	"               ng-show=\"canCreateConnections()\"\n" +
+	"               href=\"#/manage/{{dataSource}}/connections/\">{{'SETTINGS_CONNECTIONS.ACTION_NEW_CONNECTION' | translate}}</a>\n" +
+	"\n" +
+	"            <a class=\"add-connection-group button\"\n" +
+	"               ng-show=\"canCreateConnectionGroups()\"\n" +
+	"               href=\"#/manage/{{dataSource}}/connectionGroups/\">{{'SETTINGS_CONNECTIONS.ACTION_NEW_CONNECTION_GROUP' | translate}}</a>\n" +
+	"\n" +
+	"        </div>\n" +
+	"\n" +
+	"        <!-- Connection filter -->\n" +
+	"        <guac-group-list-filter connection-groups=\"rootGroups\"\n" +
+	"            filtered-connection-groups=\"filteredRootGroups\"\n" +
+	"            placeholder=\"'SETTINGS_CONNECTIONS.FIELD_PLACEHOLDER_FILTER' | translate\"\n" +
+	"            connection-properties=\"filteredConnectionProperties\"\n" +
+	"            connection-group-properties=\"filteredConnectionGroupProperties\"></guac-group-list-filter>\n" +
+	"\n" +
+	"    </div>\n" +
+	"\n" +
+	"    <!-- List of accessible connections and groups -->\n" +
+	"    <div class=\"connection-list\">\n" +
+	"        <guac-group-list\n" +
+	"            page-size=\"25\"\n" +
+	"            connection-groups=\"filteredRootGroups\"\n" +
+	"            connection-template=\"'app/settings/templates/connection.html'\"\n" +
+	"            connection-group-template=\"'app/settings/templates/connectionGroup.html'\"/>\n" +
+	"    </div>\n" +
+	"</div>");
+}]);
+
+angular.module('app/settings/templates/settingsPreferences.html', []).run(['$templateCache', function($templateCache) {
+	$templateCache.put('app/settings/templates/settingsPreferences.html',
+	"<div class=\"preferences\" ng-class=\"{loading: !isLoaded()}\">\n" +
+	"    <!--\n" +
+	"    Copyright 2015 Glyptodon LLC.\n" +
+	"\n" +
+	"    Permission is hereby granted, free of charge, to any person obtaining a copy\n" +
+	"    of this software and associated documentation files (the \"Software\"), to deal\n" +
+	"    in the Software without restriction, including without limitation the rights\n" +
+	"    to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n" +
+	"    copies of the Software, and to permit persons to whom the Software is\n" +
+	"    furnished to do so, subject to the following conditions:\n" +
+	"\n" +
+	"    The above copyright notice and this permission notice shall be included in\n" +
+	"    all copies or substantial portions of the Software.\n" +
+	"\n" +
+	"    THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n" +
+	"    IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n" +
+	"    FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n" +
+	"    AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n" +
+	"    LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n" +
+	"    OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\n" +
+	"    THE SOFTWARE.\n" +
+	"    -->\n" +
+	"\n" +
+	"    <!-- Language settings -->\n" +
+	"    <div class=\"settings section language\">\n" +
+	"        <p>{{'SETTINGS_PREFERENCES.HELP_LANGUAGE' | translate}}</p>\n" +
+	"\n" +
+	"        <!-- Language selection -->\n" +
+	"        <div class=\"form\">\n" +
+	"            <table class=\"fields\">\n" +
+	"                <tr>\n" +
+	"                    <th>{{'SETTINGS_PREFERENCES.FIELD_HEADER_LANGUAGE' | translate}}</th>\n" +
+	"                    <td><select ng-model=\"preferences.language\" ng-change=\"changeLanguage()\" ng-options=\"key as name for (key, name) in languages | orderBy: name\"></select></td>\n" +
+	"                </tr>\n" +
+	"            </table>\n" +
+	"        </div>\n" +
+	"    </div>\n" +
+	"    \n" +
+	"    <!-- Password update -->\n" +
+	"    <h2 class=\"header\" ng-show=\"canChangePassword\">{{'SETTINGS_PREFERENCES.SECTION_HEADER_UPDATE_PASSWORD' | translate}}</h2>\n" +
+	"    <div class=\"settings section update-password\" ng-show=\"canChangePassword\">\n" +
+	"        <p>{{'SETTINGS_PREFERENCES.HELP_UPDATE_PASSWORD' | translate}}</p>\n" +
+	"\n" +
+	"        <!-- Password editor -->\n" +
+	"        <div class=\"form\">\n" +
+	"            <table class=\"fields\">\n" +
+	"                <tr>\n" +
+	"                    <th>{{'SETTINGS_PREFERENCES.FIELD_HEADER_PASSWORD_OLD' | translate}}</th>\n" +
+	"                    <td><input ng-model=\"oldPassword\" type=\"password\" /></td>\n" +
+	"                </tr>\n" +
+	"                <tr>\n" +
+	"                    <th>{{'SETTINGS_PREFERENCES.FIELD_HEADER_PASSWORD_NEW' | translate}}</th>\n" +
+	"                    <td><input ng-model=\"newPassword\" type=\"password\" /></td>\n" +
+	"                </tr>\n" +
+	"                <tr>\n" +
+	"                    <th>{{'SETTINGS_PREFERENCES.FIELD_HEADER_PASSWORD_NEW_AGAIN' | translate}}</th>\n" +
+	"                    <td><input ng-model=\"newPasswordMatch\" type=\"password\" /></td>\n" +
+	"                </tr>\n" +
+	"            </table>\n" +
+	"        </div>\n" +
+	"\n" +
+	"        <!-- Form action buttons -->\n" +
+	"        <div class=\"action-buttons\">\n" +
+	"            <button class=\"change-password\" ng-click=\"updatePassword()\">{{'SETTINGS_PREFERENCES.ACTION_UPDATE_PASSWORD' | translate}}</button>\n" +
+	"        </div>\n" +
+	"    </div>\n" +
+	"\n" +
+	"    <!-- Input method -->\n" +
+	"    <h2 class=\"header\">{{'SETTINGS_PREFERENCES.SECTION_HEADER_DEFAULT_INPUT_METHOD' | translate}}</h2>\n" +
+	"    <div class=\"settings section input-method\">\n" +
+	"        <p>{{'SETTINGS_PREFERENCES.HELP_DEFAULT_INPUT_METHOD' | translate}}</p>\n" +
+	"        <div class=\"choices\">\n" +
+	"\n" +
+	"            <!-- No IME -->\n" +
+	"            <div class=\"choice\">\n" +
+	"                <label><input id=\"ime-none\" name=\"input-method\" ng-model=\"preferences.inputMethod\" type=\"radio\" value=\"none\"/> {{'SETTINGS_PREFERENCES.NAME_INPUT_METHOD_NONE' | translate}}</label>\n" +
+	"                <p class=\"caption\"><label for=\"ime-none\">{{'SETTINGS_PREFERENCES.HELP_INPUT_METHOD_NONE' | translate}}</label></p>\n" +
+	"            </div>\n" +
+	"\n" +
+	"            <!-- Text input -->\n" +
+	"            <div class=\"choice\">\n" +
+	"                <label><input id=\"ime-text\" name=\"input-method\" ng-model=\"preferences.inputMethod\" type=\"radio\" value=\"text\"/> {{'SETTINGS_PREFERENCES.NAME_INPUT_METHOD_TEXT' | translate}}</label>\n" +
+	"                <p class=\"caption\"><label for=\"ime-text\">{{'SETTINGS_PREFERENCES.HELP_INPUT_METHOD_TEXT' | translate}} </label></p>\n" +
+	"            </div>\n" +
+	"\n" +
+	"            <!-- Guac OSK -->\n" +
+	"            <div class=\"choice\">\n" +
+	"                <label><input id=\"ime-osk\" name=\"input-method\" ng-model=\"preferences.inputMethod\" type=\"radio\" value=\"osk\"/> {{'SETTINGS_PREFERENCES.NAME_INPUT_METHOD_OSK' | translate}}</label>\n" +
+	"                <p class=\"caption\"><label for=\"ime-osk\">{{'SETTINGS_PREFERENCES.HELP_INPUT_METHOD_OSK' | translate}}</label></p>\n" +
+	"            </div>\n" +
+	"\n" +
+	"        </div>\n" +
+	"    </div>\n" +
+	"\n" +
+	"    <!-- Mouse mode -->\n" +
+	"    <h2 class=\"header\">{{'SETTINGS_PREFERENCES.SECTION_HEADER_DEFAULT_MOUSE_MODE' | translate}}</h2>\n" +
+	"    <div class=\"settings section mouse-mode\">\n" +
+	"        <p>{{'SETTINGS_PREFERENCES.HELP_DEFAULT_MOUSE_MODE' | translate}}</p>\n" +
+	"        <div class=\"choices\">\n" +
+	"\n" +
+	"            <!-- Touchscreen -->\n" +
+	"            <div class=\"choice\">\n" +
+	"                <input name=\"mouse-mode\" ng-model=\"preferences.emulateAbsoluteMouse\" type=\"radio\" ng-value=\"true\" checked=\"checked\" id=\"absolute\"/>\n" +
+	"                <div class=\"figure\">\n" +
+	"                    <label for=\"absolute\"><img src=\"images/settings/touchscreen.png\" alt=\"{{'SETTINGS_PREFERENCES.NAME_MOUSE_MODE_ABSOLUTE' | translate}}\"/></label>\n" +
+	"                    <p class=\"caption\"><label for=\"absolute\">{{'SETTINGS_PREFERENCES.HELP_MOUSE_MODE_ABSOLUTE' | translate}}</label></p>\n" +
+	"                </div>\n" +
+	"            </div>\n" +
+	"\n" +
+	"            <!-- Touchpad -->\n" +
+	"            <div class=\"choice\">\n" +
+	"                <input name=\"mouse-mode\" ng-model=\"preferences.emulateAbsoluteMouse\" type=\"radio\" ng-value=\"false\" id=\"relative\"/>\n" +
+	"                <div class=\"figure\">\n" +
+	"                    <label for=\"relative\"><img src=\"images/settings/touchpad.png\" alt=\"{{'SETTINGS_PREFERENCES.NAME_MOUSE_MODE_RELATIVE' | translate}}\"/></label>\n" +
+	"                    <p class=\"caption\"><label for=\"relative\">{{'SETTINGS_PREFERENCES.HELP_MOUSE_MODE_RELATIVE' | translate}}</label></p>\n" +
+	"                </div>\n" +
+	"            </div>\n" +
+	"\n" +
+	"        </div>\n" +
+	"    </div>\n" +
+	"\n" +
+	"</div>");
+}]);
+
+angular.module('app/settings/templates/settingsSessions.html', []).run(['$templateCache', function($templateCache) {
+	$templateCache.put('app/settings/templates/settingsSessions.html',
+	"<div class=\"settings section sessions\" ng-class=\"{loading: !isLoaded()}\">\n" +
+	"    <!--\n" +
+	"    Copyright 2015 Glyptodon LLC.\n" +
+	"\n" +
+	"    Permission is hereby granted, free of charge, to any person obtaining a copy\n" +
+	"    of this software and associated documentation files (the \"Software\"), to deal\n" +
+	"    in the Software without restriction, including without limitation the rights\n" +
+	"    to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n" +
+	"    copies of the Software, and to permit persons to whom the Software is\n" +
+	"    furnished to do so, subject to the following conditions:\n" +
+	"\n" +
+	"    The above copyright notice and this permission notice shall be included in\n" +
+	"    all copies or substantial portions of the Software.\n" +
+	"\n" +
+	"    THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n" +
+	"    IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n" +
+	"    FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n" +
+	"    AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n" +
+	"    LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n" +
+	"    OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\n" +
+	"    THE SOFTWARE.\n" +
+	"    -->\n" +
+	"\n" +
+	"    <!-- User Session management -->\n" +
+	"    <p>{{'SETTINGS_SESSIONS.HELP_SESSIONS' | translate}}</p>\n" +
+	"\n" +
+	"    <!-- Form action buttons -->\n" +
+	"    <div class=\"action-buttons\">\n" +
+	"        <button class=\"delete-sessions danger\" ng-disabled=\"!canDeleteSessions()\" ng-click=\"deleteSessions()\">{{'SETTINGS_SESSIONS.ACTION_DELETE' | translate}}</button>\n" +
+	"    </div>\n" +
+	"\n" +
+	"    <!-- Session filter -->\n" +
+	"    <guac-filter filtered-items=\"filteredWrappers\" items=\"wrappers\"\n" +
+	"                 placeholder=\"'SETTINGS_SESSIONS.FIELD_PLACEHOLDER_FILTER' | translate\"\n" +
+	"                 properties=\"filteredWrapperProperties\"></guac-filter>\n" +
+	"\n" +
+	"    <!-- List of current user sessions -->\n" +
+	"    <table class=\"sorted session-list\">\n" +
+	"        <thead>\n" +
+	"            <tr>\n" +
+	"                <th class=\"select-session\"></th>\n" +
+	"                <th guac-sort-order=\"wrapperOrder\" guac-sort-property=\"'activeConnection.username'\">\n" +
+	"                    {{'SETTINGS_SESSIONS.TABLE_HEADER_SESSION_USERNAME' | translate}}\n" +
+	"                </th>\n" +
+	"                <th guac-sort-order=\"wrapperOrder\" guac-sort-property=\"'startDate'\">\n" +
+	"                    {{'SETTINGS_SESSIONS.TABLE_HEADER_SESSION_STARTDATE' | translate}}\n" +
+	"                </th>\n" +
+	"                <th guac-sort-order=\"wrapperOrder\" guac-sort-property=\"'activeConnection.remoteHost'\">\n" +
+	"                    {{'SETTINGS_SESSIONS.TABLE_HEADER_SESSION_REMOTEHOST' | translate}}\n" +
+	"                </th>\n" +
+	"                <th guac-sort-order=\"wrapperOrder\" guac-sort-property=\"'name'\">\n" +
+	"                    {{'SETTINGS_SESSIONS.TABLE_HEADER_SESSION_CONNECTION_NAME' | translate}}\n" +
+	"                </th>\n" +
+	"            </tr>\n" +
+	"        </thead>\n" +
+	"        <tbody>\n" +
+	"            <tr ng-repeat=\"wrapper in wrapperPage\" class=\"session\">\n" +
+	"                <td class=\"select-session\">\n" +
+	"                    <input ng-change=\"wrapperSelectionChange(wrapper)\" type=\"checkbox\" ng-model=\"wrapper.checked\" />\n" +
+	"                </td>\n" +
+	"                <td>{{wrapper.activeConnection.username}}</td>\n" +
+	"                <td>{{wrapper.startDate}}</td>\n" +
+	"                <td>{{wrapper.activeConnection.remoteHost}}</td>\n" +
+	"                <td>{{wrapper.name}}</td>\n" +
+	"            </tr>\n" +
+	"        </tbody>\n" +
+	"    </table>\n" +
+	"\n" +
+	"    <!-- Text displayed if no sessions exist -->\n" +
+	"    <p class=\"placeholder\" ng-hide=\"wrapperPage.length\">\n" +
+	"        {{'SETTINGS_SESSIONS.INFO_NO_SESSIONS' | translate}}\n" +
+	"    </p>\n" +
+	"\n" +
+	"    <!-- Pager for session list -->\n" +
+	"    <guac-pager page=\"wrapperPage\" page-size=\"25\"\n" +
+	"                items=\"filteredWrappers | orderBy : wrapperOrder.predicate\"></guac-pager>\n" +
+	"</div>");
+}]);
+
+angular.module('app/settings/templates/settingsUsers.html', []).run(['$templateCache', function($templateCache) {
+	$templateCache.put('app/settings/templates/settingsUsers.html',
+	"<div class=\"settings section users\" ng-class=\"{loading: !isLoaded()}\">\n" +
+	"    <!--\n" +
+	"    Copyright 2015 Glyptodon LLC.\n" +
+	"\n" +
+	"    Permission is hereby granted, free of charge, to any person obtaining a copy\n" +
+	"    of this software and associated documentation files (the \"Software\"), to deal\n" +
+	"    in the Software without restriction, including without limitation the rights\n" +
+	"    to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n" +
+	"    copies of the Software, and to permit persons to whom the Software is\n" +
+	"    furnished to do so, subject to the following conditions:\n" +
+	"\n" +
+	"    The above copyright notice and this permission notice shall be included in\n" +
+	"    all copies or substantial portions of the Software.\n" +
+	"\n" +
+	"    THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n" +
+	"    IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n" +
+	"    FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n" +
+	"    AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n" +
+	"    LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n" +
+	"    OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\n" +
+	"    THE SOFTWARE.\n" +
+	"    -->\n" +
+	"\n" +
+	"    <!-- User management -->\n" +
+	"    <p>{{'SETTINGS_USERS.HELP_USERS' | translate}}</p>\n" +
+	"\n" +
+	"\n" +
+	"    <!-- User management toolbar -->\n" +
+	"    <div class=\"toolbar\">\n" +
+	"\n" +
+	"        <!-- Form action buttons -->\n" +
+	"        <div class=\"action-buttons\">\n" +
+	"            <a class=\"add-user button\" ng-show=\"canCreateUsers()\"\n" +
+	"               href=\"#/manage/{{getDefaultDataSource()}}/users/\">{{'SETTINGS_USERS.ACTION_NEW_USER' | translate}}</a>\n" +
+	"        </div>\n" +
+	"\n" +
+	"        <!-- User filter -->\n" +
+	"        <guac-filter filtered-items=\"filteredManageableUsers\" items=\"manageableUsers\"\n" +
+	"                     placeholder=\"'SETTINGS_USERS.FIELD_PLACEHOLDER_FILTER' | translate\"\n" +
+	"                     properties=\"filteredUserProperties\"></guac-filter>\n" +
+	"\n" +
+	"    </div>\n" +
+	"\n" +
+	"    <!-- List of users this user has access to -->\n" +
+	"    <div class=\"user-list\">\n" +
+	"        <div ng-repeat=\"manageableUser in manageableUserPage\" class=\"user list-item\">\n" +
+	"            <a ng-href=\"#/manage/{{manageableUser.dataSource}}/users/{{manageableUser.user.username}}\">\n" +
+	"                <div class=\"caption\">\n" +
+	"                    <div class=\"icon user\"></div>\n" +
+	"                    <span class=\"name\">{{manageableUser.user.username}}</span>\n" +
+	"                </div>\n" +
+	"            </a>\n" +
+	"        </div>\n" +
+	"    </div>\n" +
+	"\n" +
+	"    <!-- Pager controls for user list -->\n" +
+	"    <guac-pager page=\"manageableUserPage\" page-size=\"25\"\n" +
+	"                items=\"filteredManageableUsers | orderBy : 'user.username'\"></guac-pager>\n" +
+	"\n" +
+	"</div>");
+}]);
+
+angular.module('app/textInput/templates/guacKey.html', []).run(['$templateCache', function($templateCache) {
+	$templateCache.put('app/textInput/templates/guacKey.html',
+	"<button class=\"key\" ng-click=\"updateKey()\" ng-class=\"{pressed: pressed, sticky: sticky}\">\n" +
+	"    <!--\n" +
+	"       Copyright (C) 2014 Glyptodon LLC\n" +
+	"\n" +
+	"       Permission is hereby granted, free of charge, to any person obtaining a copy\n" +
+	"       of this software and associated documentation files (the \"Software\"), to deal\n" +
+	"       in the Software without restriction, including without limitation the rights\n" +
+	"       to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n" +
+	"       copies of the Software, and to permit persons to whom the Software is\n" +
+	"       furnished to do so, subject to the following conditions:\n" +
+	"\n" +
+	"       The above copyright notice and this permission notice shall be included in\n" +
+	"       all copies or substantial portions of the Software.\n" +
+	"\n" +
+	"       THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n" +
+	"       IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n" +
+	"       FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n" +
+	"       AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n" +
+	"       LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n" +
+	"       OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\n" +
+	"       THE SOFTWARE.\n" +
+	"    -->\n" +
+	"\n" +
+	"    {{text | translate}}\n" +
+	"</button>");
+}]);
+
+angular.module('app/textInput/templates/guacTextInput.html', []).run(['$templateCache', function($templateCache) {
+	$templateCache.put('app/textInput/templates/guacTextInput.html',
+	"<div class=\"text-input\">\n" +
+	"    <!--\n" +
+	"       Copyright (C) 2014 Glyptodon LLC\n" +
+	"\n" +
+	"       Permission is hereby granted, free of charge, to any person obtaining a copy\n" +
+	"       of this software and associated documentation files (the \"Software\"), to deal\n" +
+	"       in the Software without restriction, including without limitation the rights\n" +
+	"       to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n" +
+	"       copies of the Software, and to permit persons to whom the Software is\n" +
+	"       furnished to do so, subject to the following conditions:\n" +
+	"\n" +
+	"       The above copyright notice and this permission notice shall be included in\n" +
+	"       all copies or substantial portions of the Software.\n" +
+	"\n" +
+	"       THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n" +
+	"       IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n" +
+	"       FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n" +
+	"       AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n" +
+	"       LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n" +
+	"       OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\n" +
+	"       THE SOFTWARE.\n" +
+	"    -->\n" +
+	"\n" +
+	"    <!-- Text input target -->\n" +
+	"    <div class=\"text-input-field\"><div class=\"sent-history\"><div class=\"sent-text\" ng-repeat=\"text in sentText track by $index\">{{text}}</div></div><textarea rows=\"1\" class=\"target\" autocorrect=\"off\" autocapitalize=\"off\"></textarea></div><div class=\"text-input-buttons\"><guac-key keysym=\"65507\" sticky=\"true\" text=\"'CLIENT.NAME_KEY_CTRL'\" pressed=\"ctrlPressed\"></guac-key><guac-key keysym=\"65513\" sticky=\"true\" text=\"'CLIENT.NAME_KEY_ALT'\" pressed=\"altPress [...]
+	"\n" +
+	"</div>");
+}]);
+
diff --git a/guacamole/src/main/webapp/images/action-icons/guac-back.png b/guacamole/src/main/webapp/images/action-icons/guac-back.png
new file mode 100644
index 0000000..6435f4a
Binary files /dev/null and b/guacamole/src/main/webapp/images/action-icons/guac-back.png differ
diff --git a/guacamole/src/main/webapp/images/action-icons/guac-close.png b/guacamole/src/main/webapp/images/action-icons/guac-close.png
deleted file mode 100644
index ef29d0f..0000000
Binary files a/guacamole/src/main/webapp/images/action-icons/guac-close.png and /dev/null differ
diff --git a/guacamole/src/main/webapp/images/action-icons/guac-config-dark.png b/guacamole/src/main/webapp/images/action-icons/guac-config-dark.png
new file mode 100644
index 0000000..132a253
Binary files /dev/null and b/guacamole/src/main/webapp/images/action-icons/guac-config-dark.png differ
diff --git a/guacamole/src/main/webapp/images/action-icons/guac-config.png b/guacamole/src/main/webapp/images/action-icons/guac-config.png
index eb91fc0..5c4700b 100644
Binary files a/guacamole/src/main/webapp/images/action-icons/guac-config.png and b/guacamole/src/main/webapp/images/action-icons/guac-config.png differ
diff --git a/guacamole/src/main/webapp/images/action-icons/guac-group-add.png b/guacamole/src/main/webapp/images/action-icons/guac-group-add.png
new file mode 100644
index 0000000..be9b630
Binary files /dev/null and b/guacamole/src/main/webapp/images/action-icons/guac-group-add.png differ
diff --git a/guacamole/src/main/webapp/images/action-icons/guac-hide-pass.png b/guacamole/src/main/webapp/images/action-icons/guac-hide-pass.png
new file mode 100644
index 0000000..816d0b2
Binary files /dev/null and b/guacamole/src/main/webapp/images/action-icons/guac-hide-pass.png differ
diff --git a/guacamole/src/main/webapp/images/action-icons/guac-home-dark.png b/guacamole/src/main/webapp/images/action-icons/guac-home-dark.png
new file mode 100644
index 0000000..e1e35d4
Binary files /dev/null and b/guacamole/src/main/webapp/images/action-icons/guac-home-dark.png differ
diff --git a/guacamole/src/main/webapp/images/action-icons/guac-home.png b/guacamole/src/main/webapp/images/action-icons/guac-home.png
new file mode 100644
index 0000000..d5613d4
Binary files /dev/null and b/guacamole/src/main/webapp/images/action-icons/guac-home.png differ
diff --git a/guacamole/src/main/webapp/images/action-icons/guac-key-dark.png b/guacamole/src/main/webapp/images/action-icons/guac-key-dark.png
new file mode 100644
index 0000000..90ef0ff
Binary files /dev/null and b/guacamole/src/main/webapp/images/action-icons/guac-key-dark.png differ
diff --git a/guacamole/src/main/webapp/images/action-icons/guac-key.png b/guacamole/src/main/webapp/images/action-icons/guac-key.png
new file mode 100644
index 0000000..89ea62b
Binary files /dev/null and b/guacamole/src/main/webapp/images/action-icons/guac-key.png differ
diff --git a/guacamole/src/main/webapp/images/action-icons/guac-logout-dark.png b/guacamole/src/main/webapp/images/action-icons/guac-logout-dark.png
new file mode 100644
index 0000000..bfb9e11
Binary files /dev/null and b/guacamole/src/main/webapp/images/action-icons/guac-logout-dark.png differ
diff --git a/guacamole/src/main/webapp/images/action-icons/guac-logout.png b/guacamole/src/main/webapp/images/action-icons/guac-logout.png
new file mode 100644
index 0000000..5793014
Binary files /dev/null and b/guacamole/src/main/webapp/images/action-icons/guac-logout.png differ
diff --git a/guacamole/src/main/webapp/images/action-icons/guac-monitor-add.png b/guacamole/src/main/webapp/images/action-icons/guac-monitor-add.png
index 0c9ea96..2ed062a 100644
Binary files a/guacamole/src/main/webapp/images/action-icons/guac-monitor-add.png and b/guacamole/src/main/webapp/images/action-icons/guac-monitor-add.png differ
diff --git a/guacamole/src/main/webapp/images/action-icons/guac-show-pass.png b/guacamole/src/main/webapp/images/action-icons/guac-show-pass.png
new file mode 100644
index 0000000..ce7ee2b
Binary files /dev/null and b/guacamole/src/main/webapp/images/action-icons/guac-show-pass.png differ
diff --git a/guacamole/src/main/webapp/images/action-icons/guac-user-add.png b/guacamole/src/main/webapp/images/action-icons/guac-user-add.png
index 8b2d80e..8a9f22f 100644
Binary files a/guacamole/src/main/webapp/images/action-icons/guac-user-add.png and b/guacamole/src/main/webapp/images/action-icons/guac-user-add.png differ
diff --git a/guacamole/src/main/webapp/images/arrows/arrows-d.png b/guacamole/src/main/webapp/images/arrows/arrows-d.png
deleted file mode 100644
index 15b1a77..0000000
Binary files a/guacamole/src/main/webapp/images/arrows/arrows-d.png and /dev/null differ
diff --git a/guacamole/src/main/webapp/images/arrows/arrows-l.png b/guacamole/src/main/webapp/images/arrows/arrows-l.png
deleted file mode 100644
index 91f8150..0000000
Binary files a/guacamole/src/main/webapp/images/arrows/arrows-l.png and /dev/null differ
diff --git a/guacamole/src/main/webapp/images/arrows/arrows-r.png b/guacamole/src/main/webapp/images/arrows/arrows-r.png
deleted file mode 100644
index 3ab9d5b..0000000
Binary files a/guacamole/src/main/webapp/images/arrows/arrows-r.png and /dev/null differ
diff --git a/guacamole/src/main/webapp/images/arrows/arrows-u.png b/guacamole/src/main/webapp/images/arrows/arrows-u.png
deleted file mode 100644
index 057cccf..0000000
Binary files a/guacamole/src/main/webapp/images/arrows/arrows-u.png and /dev/null differ
diff --git a/guacamole/src/main/webapp/images/arrows/down.png b/guacamole/src/main/webapp/images/arrows/down.png
new file mode 100644
index 0000000..cfa9885
Binary files /dev/null and b/guacamole/src/main/webapp/images/arrows/down.png differ
diff --git a/guacamole/src/main/webapp/images/arrows/up.png b/guacamole/src/main/webapp/images/arrows/up.png
new file mode 100644
index 0000000..e751af8
Binary files /dev/null and b/guacamole/src/main/webapp/images/arrows/up.png differ
diff --git a/guacamole/src/main/webapp/images/checkmark.png b/guacamole/src/main/webapp/images/checkmark.png
new file mode 100644
index 0000000..c54feb2
Binary files /dev/null and b/guacamole/src/main/webapp/images/checkmark.png differ
diff --git a/guacamole/src/main/webapp/images/circle-arrows.png b/guacamole/src/main/webapp/images/circle-arrows.png
new file mode 100644
index 0000000..e5c33a1
Binary files /dev/null and b/guacamole/src/main/webapp/images/circle-arrows.png differ
diff --git a/guacamole/src/main/webapp/images/cog.png b/guacamole/src/main/webapp/images/cog.png
new file mode 100644
index 0000000..f5eb681
Binary files /dev/null and b/guacamole/src/main/webapp/images/cog.png differ
diff --git a/guacamole/src/main/webapp/images/drive.png b/guacamole/src/main/webapp/images/drive.png
new file mode 100644
index 0000000..916d58e
Binary files /dev/null and b/guacamole/src/main/webapp/images/drive.png differ
diff --git a/guacamole/src/main/webapp/images/file.png b/guacamole/src/main/webapp/images/file.png
new file mode 100644
index 0000000..d45e227
Binary files /dev/null and b/guacamole/src/main/webapp/images/file.png differ
diff --git a/guacamole/src/main/webapp/images/folder-closed.png b/guacamole/src/main/webapp/images/folder-closed.png
new file mode 100644
index 0000000..46a918a
Binary files /dev/null and b/guacamole/src/main/webapp/images/folder-closed.png differ
diff --git a/guacamole/src/main/webapp/images/folder-open.png b/guacamole/src/main/webapp/images/folder-open.png
new file mode 100644
index 0000000..f04b0be
Binary files /dev/null and b/guacamole/src/main/webapp/images/folder-open.png differ
diff --git a/guacamole/src/main/webapp/images/folder-up.png b/guacamole/src/main/webapp/images/folder-up.png
new file mode 100644
index 0000000..5271b2e
Binary files /dev/null and b/guacamole/src/main/webapp/images/folder-up.png differ
diff --git a/guacamole/src/main/webapp/images/guac-tricolor.png b/guacamole/src/main/webapp/images/guac-tricolor.png
new file mode 100644
index 0000000..5ff25d1
Binary files /dev/null and b/guacamole/src/main/webapp/images/guac-tricolor.png differ
diff --git a/guacamole/src/main/webapp/images/guacamole-logo-24.png b/guacamole/src/main/webapp/images/guacamole-logo-24.png
deleted file mode 100644
index 3652598..0000000
Binary files a/guacamole/src/main/webapp/images/guacamole-logo-24.png and /dev/null differ
diff --git a/guacamole/src/main/webapp/images/lock.png b/guacamole/src/main/webapp/images/lock.png
new file mode 100644
index 0000000..399d511
Binary files /dev/null and b/guacamole/src/main/webapp/images/lock.png differ
diff --git a/guacamole/src/main/webapp/images/guacamole-logo-144.png b/guacamole/src/main/webapp/images/logo-144.png
similarity index 100%
rename from guacamole/src/main/webapp/images/guacamole-logo-144.png
rename to guacamole/src/main/webapp/images/logo-144.png
diff --git a/guacamole/src/main/webapp/images/guacamole-logo-64.png b/guacamole/src/main/webapp/images/logo-64.png
similarity index 100%
rename from guacamole/src/main/webapp/images/guacamole-logo-64.png
rename to guacamole/src/main/webapp/images/logo-64.png
diff --git a/guacamole/src/main/webapp/images/magnifier.png b/guacamole/src/main/webapp/images/magnifier.png
new file mode 100644
index 0000000..1526120
Binary files /dev/null and b/guacamole/src/main/webapp/images/magnifier.png differ
diff --git a/guacamole/src/main/webapp/images/plus.png b/guacamole/src/main/webapp/images/plus.png
new file mode 100644
index 0000000..14bedbe
Binary files /dev/null and b/guacamole/src/main/webapp/images/plus.png differ
diff --git a/guacamole/src/main/webapp/images/settings/tablet-keys.png b/guacamole/src/main/webapp/images/settings/tablet-keys.png
new file mode 100644
index 0000000..14eb577
Binary files /dev/null and b/guacamole/src/main/webapp/images/settings/tablet-keys.png differ
diff --git a/guacamole/src/main/webapp/images/settings/touchpad.png b/guacamole/src/main/webapp/images/settings/touchpad.png
new file mode 100644
index 0000000..3facb5d
Binary files /dev/null and b/guacamole/src/main/webapp/images/settings/touchpad.png differ
diff --git a/guacamole/src/main/webapp/images/settings/touchscreen.png b/guacamole/src/main/webapp/images/settings/touchscreen.png
new file mode 100644
index 0000000..11ad40d
Binary files /dev/null and b/guacamole/src/main/webapp/images/settings/touchscreen.png differ
diff --git a/guacamole/src/main/webapp/images/settings/zoom-in.png b/guacamole/src/main/webapp/images/settings/zoom-in.png
new file mode 100644
index 0000000..ef36301
Binary files /dev/null and b/guacamole/src/main/webapp/images/settings/zoom-in.png differ
diff --git a/guacamole/src/main/webapp/images/settings/zoom-out.png b/guacamole/src/main/webapp/images/settings/zoom-out.png
new file mode 100644
index 0000000..358632c
Binary files /dev/null and b/guacamole/src/main/webapp/images/settings/zoom-out.png differ
diff --git a/guacamole/src/main/webapp/images/x.png b/guacamole/src/main/webapp/images/x.png
new file mode 100644
index 0000000..294b939
Binary files /dev/null and b/guacamole/src/main/webapp/images/x.png differ
diff --git a/guacamole/src/main/webapp/index.html b/guacamole/src/main/webapp/index.html
new file mode 100644
index 0000000..2c69db2
--- /dev/null
+++ b/guacamole/src/main/webapp/index.html
@@ -0,0 +1,60 @@
+<!DOCTYPE html>
+<html ng-app="index" ng-controller="indexController">
+    <head>
+        <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
+        <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, target-densitydpi=medium-dpi"/>
+        <meta name="mobile-web-app-capable" content="yes"/>
+        <meta name="apple-mobile-web-app-capable" content="yes"/>
+        <link rel="icon" type="image/png" href="images/logo-64.png"/>
+        <link rel="icon" type="image/png" sizes="144x144" href="images/logo-144.png"/>
+        <link rel="apple-touch-icon" type="image/png" href="images/logo-144.png"/>
+        <link rel="stylesheet" type="text/css" href="app.css?v=${project.version}">
+        <title ng-bind="page.title | translate"></title>
+    </head>
+    <!--
+        Copyright 2015 Glyptodon LLC.
+
+        Permission is hereby granted, free of charge, to any person obtaining a copy
+        of this software and associated documentation files (the "Software"), to deal
+        in the Software without restriction, including without limitation the rights
+        to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+        copies of the Software, and to permit persons to whom the Software is
+        furnished to do so, subject to the following conditions:
+
+        The above copyright notice and this permission notice shall be included in
+        all copies or substantial portions of the Software.
+
+        THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+        IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+        FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+        AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+        LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+        OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+        THE SOFTWARE.
+    -->
+    <body ng-class="page.bodyClassName">
+
+        <!-- Content for logged-in users -->
+        <div ng-if="!expectedCredentials">
+        
+            <!-- Global status/error dialog -->
+            <div ng-class="{shown: guacNotification.getStatus()}" class="status-outer">
+                <div class="status-middle">
+                    <guac-notification notification="guacNotification.getStatus()"></guac-notification>
+                </div>
+            </div>
+            
+            <div id="content" ng-view>
+            </div>
+            
+        </div>
+
+        <!-- Login screen for logged-out users -->
+        <guac-login ng-show="expectedCredentials"
+                    help-text="loginHelpText"
+                    form="expectedCredentials"
+                    values="acceptedCredentials"></guac-login>
+        
+        <script type="text/javascript" src="app.js?v=${project.version}"></script>
+    </body>
+</html>
diff --git a/guacamole/src/main/webapp/index.xhtml b/guacamole/src/main/webapp/index.xhtml
deleted file mode 100644
index 87d28b0..0000000
--- a/guacamole/src/main/webapp/index.xhtml
+++ /dev/null
@@ -1,149 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<!DOCTYPE html>
-
-<!--
-    Guacamole - Clientless Remote Desktop
-    Copyright (C) 2010  Michael Jumper
-
-    This program is free software: you can redistribute it and/or modify
-    it under the terms of the GNU Affero General Public License as published by
-    the Free Software Foundation, either version 3 of the License, or
-    (at your option) any later version.
-
-    This program is distributed in the hope that it will be useful,
-    but WITHOUT ANY WARRANTY; without even the implied warranty of
-    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-    GNU Affero General Public License for more details.
-
-    You should have received a copy of the GNU Affero General Public License
-    along with this program.  If not, see <http://www.gnu.org/licenses/>.
--->
-
-<html xmlns="http://www.w3.org/1999/xhtml">
-
-    <head>
-        <link rel="icon" type="image/png" href="images/guacamole-logo-64.png"/>
-        <link rel="apple-touch-icon" type="image/png" href="images/guacamole-logo-144.png"/>
-        <link rel="stylesheet" type="text/css" href="styles/ui.css"/>
-        <link rel="stylesheet" type="text/css" href="styles/login.css"/>
-        <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, target-densitydpi=medium-dpi"/>
-        <title>Guacamole ${project.version}</title>
-    </head>
-
-    <body>
-
-        <div id="login-ui" style="display: none">
-            <div id="login-dialog-middle">
-
-                <div id="login-dialog">
-
-                    <p id="login-error"></p>
-
-                    <form id="login-form" action="#" method="post">
-
-                        <div id="login-fields">
-                            <table>
-                                <tr>
-                                    <th>Username</th>
-                                    <td><input type="text" name="username" id="username" autofocus="autofocus"/></td>
-                                </tr>
-                                <tr>
-                                    <th>Password</th>
-                                    <td><input type="password" name="password" id="password"/></td>
-                                </tr>
-                            </table>
-
-                            <img class="logo" src="images/guac-mono-192.png" alt=""/>
-                        </div>
-
-                        <div id="buttons">
-                            <input type="submit" name="login" id="login" value="Login"/>
-                        </div>
-
-                    </form>
-                </div>
-
-            </div>
-        </div>
-
-        <!-- Connection list UI -->
-        <div id="connection-list-ui" style="display: none">
-
-            <div id="logout-panel">
-                <button id="manage">Manage</button>
-                <button id="logout">Logout</button>
-            </div>
-           
-            <h2>Recent Connections</h2>
-            <div id="recent-connections">
-                <p id="no-recent">No recent connections.</p>
-            </div>
-
-            <h2>All Connections</h2>
-            <div id="all-connections">
-            </div>
-
-            <h2>Clipboard</h2>
-            <div id="clipboardDiv">
-                <p>
-                Text copied/cut within Guacamole will appear here. Changes to the text will affect the remote clipboard, and will be pastable within the remote desktop. Use the textbox below as an interface between the client and server clipboards.
-                </p>
-                <textarea rows="10" cols="40" id="clipboard"></textarea>
-            </div>
-
-            <h2>Settings</h2>
-            <div id="settings">
-
-                <dl>
-                    <!-- Auto-fit display -->
-                    <dt>
-                        <input type="checkbox" id="auto-fit"/>
-                        Auto-fit display to browser window
-                    </dt>
-                    <dd>
-                        <p>
-                            If checked, remote displays are automatically
-                            scaled to exactly fit within the browser window. If
-                            unchecked, remote displays are always shown at their
-                            natural resolution, even if doing so causes the
-                            display to extend beyond the bounds of the window.
-                        </p>
-                    </dd>
-
-                    <!-- Enable/disable sound -->
-                    <dt>
-                        <input type="checkbox" id="disable-sound"/>
-                        Disable sound
-                    </dt>
-                    <dd>
-                        <p>
-                            If on a device or network where bandwidth usage must
-                            be kept to a minimum, you may wish to check this box
-                            and disable sound. This can also be necessary if a
-                            device doesn't actually support sound, but claims
-                            to, resulting in wasted bandwidth.
-                        </p>
-                        <p>
-                            <strong>Changing this setting will only affect
-                            future connections.</strong>
-                        </p>
-                    </dd>
-                </dl>
-
-            </div>
-
-        </div>
-
-        <div id="version-dialog">
-            Guacamole ${project.version}
-        </div>
-
-        <script type="text/javascript" src="scripts/service.js"></script>
-        <script type="text/javascript" src="scripts/session.js"></script>
-        <script type="text/javascript" src="scripts/history.js"></script>
-        <script type="text/javascript" src="scripts/guac-ui.js"></script>
-        <script type="text/javascript" src="scripts/root-ui.js"></script>
-
-    </body>
-
-</html>
diff --git a/guacamole/src/main/webapp/layouts/de-de-qwertz.json b/guacamole/src/main/webapp/layouts/de-de-qwertz.json
new file mode 100644
index 0000000..2b746aa
--- /dev/null
+++ b/guacamole/src/main/webapp/layouts/de-de-qwertz.json
@@ -0,0 +1,450 @@
+{
+
+    "language" : "de_DE",
+    "type"     : "qwertz",
+    "width"    : 23,
+
+    "keys" : {
+
+        "Esc"   : 65307,
+        "F1"    : 65470,
+        "F2"    : 65471,
+        "F3"    : 65472,
+        "F4"    : 65473,
+        "F5"    : 65474,
+        "F6"    : 65475,
+        "F7"    : 65476,
+        "F8"    : 65477,
+        "F9"    : 65478,
+        "F10"   : 65479,
+        "F11"   : 65480,
+        "F12"   : 65481,
+
+        "Space" : " ",
+
+        "Back" : [{
+            "title"  : "⟵",
+            "keysym" : 65288
+        }],
+        "Tab" : [{
+            "title"  : "Tab ↹",
+            "keysym" : 65289
+        }],
+        "Enter" : [{
+            "title"  : "↵",
+            "keysym" : 65293
+        }],
+        "Home" : [{
+            "title"  : "Pos 1",
+            "keysym" : 65360
+        }],
+        "PgUp" : [{
+            "title"  : "Bild ↑",
+            "keysym" : 65365
+        }],
+        "PgDn" : [{
+            "title"  : "Bild ↓",
+            "keysym" : 65366
+        }],
+        "End" : [{
+            "title"  : "Ende",
+            "keysym" : 65367
+        }],
+        "Ins" : [{
+            "title"  : "Einfg",
+            "keysym" : 65379
+        }],
+        "Del" : [{
+            "title"  : "Entf",
+            "keysym" : 65535
+        }],
+
+        "Left" : [{
+            "title"  : "←",
+            "keysym" : 65361
+        }],
+        "Up" : [{
+            "title"  : "↑",
+            "keysym" : 65362
+        }],
+        "Right" : [{
+            "title"  : "→",
+            "keysym" : 65363
+        }],
+        "Down" : [{
+            "title"  : "↓",
+            "keysym" : 65364
+        }],
+
+        "Menu" : [{
+            "title"    : "Menu",
+            "modifier" : "super",
+            "keysym"   : 65383
+        }],
+        "LShift" : [{
+            "title"    : "Shift",
+            "modifier" : "shift",
+            "keysym"   : 65505
+        }],
+        "RShift" : [{
+            "title"    : "Shift",
+            "modifier" : "shift",
+            "keysym"   : 65506
+        }],
+        "LCtrl" : [{
+            "title"    : "Strg",
+            "modifier" : "control",
+            "keysym"   : 65507
+        }],
+        "RCtrl" : [{
+            "title"    : "Strg",
+            "modifier" : "control",
+            "keysym"   : 65508
+        }],
+        "Caps" : [{
+            "title"    : "Caps",
+            "modifier" : "caps",
+            "keysym"   : 65509
+        }],
+        "LAlt" : [{
+            "title"    : "Alt",
+            "modifier" : "alt",
+            "keysym"   : 65513
+        }],
+        "AltGr" : [{
+            "title"    : "AltGr",
+            "modifier" : "alt-gr",
+            "keysym"   : 65027
+        }],
+        "Super" : [{
+            "title"    : "Super",
+            "modifier" : "super",
+            "keysym"   : 65515
+        }],
+
+        "^" : [
+            { "title" : "^", "requires" : [         ] },
+            { "title" : "°", "requires" : [ "shift" ] }
+        ],
+        "1" : [
+            { "title" : "1", "requires" : [         ] },
+            { "title" : "!", "requires" : [ "shift" ] }
+        ],
+        "2" : [
+            { "title" : "2",  "requires" : [          ] },
+            { "title" : "\"", "requires" : [ "shift"  ] },
+            { "title" : "²",  "requires" : [ "alt-gr" ] }
+        ],
+        "3" : [
+            { "title" : "3", "requires" : [          ] },
+            { "title" : "§", "requires" : [ "shift"  ] },
+            { "title" : "³", "requires" : [ "alt-gr" ] }
+        ],
+        "4" : [
+            { "title" : "4", "requires" : [         ] },
+            { "title" : "$", "requires" : [ "shift" ] }
+        ],
+        "5" : [
+            { "title" : "5", "requires" : [         ] },
+            { "title" : "%", "requires" : [ "shift" ] }
+        ],
+        "6" : [
+            { "title" : "6", "requires" : [         ] },
+            { "title" : "&", "requires" : [ "shift" ] }
+        ],
+        "7" : [
+            { "title" : "7", "requires" : [          ] },
+            { "title" : "/", "requires" : [ "shift"  ] },
+            { "title" : "{", "requires" : [ "alt-gr" ] }
+        ],
+        "8" : [
+            { "title" : "8", "requires" : [          ] },
+            { "title" : "(", "requires" : [ "shift"  ] },
+            { "title" : "[", "requires" : [ "alt-gr" ] }
+        ],
+        "9" : [
+            { "title" : "9", "requires" : [          ] },
+            { "title" : ")", "requires" : [ "shift"  ] },
+            { "title" : "]", "requires" : [ "alt-gr" ] }
+        ],
+        "0" : [
+            { "title" : "0", "requires" : [          ] },
+            { "title" : "=", "requires" : [ "shift"  ] },
+            { "title" : "}", "requires" : [ "alt-gr" ] }
+        ],
+        "ß" : [
+            { "title" : "ß",  "requires" : [          ] },
+            { "title" : "?",  "requires" : [ "shift"  ] },
+            { "title" : "\\", "requires" : [ "alt-gr" ] }
+        ],
+        "´" : [
+            { "title" : "´", "requires" : [         ] },
+            { "title" : "`", "requires" : [ "shift" ] }
+        ],
+        "+" : [
+            { "title" : "+", "requires" : [          ] },
+            { "title" : "*", "requires" : [ "shift"  ] },
+            { "title" : "~", "requires" : [ "alt-gr" ] }
+        ],
+        "#" : [
+            { "title" : "#", "requires" : [         ] },
+            { "title" : "'", "requires" : [ "shift" ] }
+        ],
+        "<" : [
+            { "title" : "<", "requires" : [         ] },
+            { "title" : ">", "requires" : [ "shift" ] },
+            { "title" : "|", "requires" : [ "alt-gr" ] }
+        ],
+        "," : [
+            { "title" : ",", "requires" : [         ] },
+            { "title" : ";", "requires" : [ "shift" ] }
+        ],
+        "." : [
+            { "title" : ".",  "requires" : [         ] },
+            { "title" : ":",  "requires" : [ "shift" ] }
+        ],
+        "-" : [
+            { "title" : "-", "requires" : [         ] },
+            { "title" : "_", "requires" : [ "shift" ] }
+        ],
+
+        "q" : [
+            { "title" : "q", "requires" : [                 ] },
+            { "title" : "Q", "requires" : [ "caps"          ] },
+            { "title" : "Q", "requires" : [ "shift"         ] },
+            { "title" : "q", "requires" : [ "caps", "shift" ] },
+            { "title" : "@", "requires" : [ "alt-gr"        ] }
+        ],
+        "w" : [
+            { "title" : "w", "requires" : [                 ] },
+            { "title" : "W", "requires" : [ "caps"          ] },
+            { "title" : "W", "requires" : [ "shift"         ] },
+            { "title" : "w", "requires" : [ "caps", "shift" ] }
+        ],
+        "e" : [
+            { "title" : "e", "requires" : [                 ] },
+            { "title" : "E", "requires" : [ "caps"          ] },
+            { "title" : "E", "requires" : [ "shift"         ] },
+            { "title" : "e", "requires" : [ "caps", "shift" ] },
+            { "title" : "€", "requires" : [ "alt-gr"        ] }
+        ],
+        "r" : [
+            { "title" : "r", "requires" : [                 ] },
+            { "title" : "R", "requires" : [ "caps"          ] },
+            { "title" : "R", "requires" : [ "shift"         ] },
+            { "title" : "r", "requires" : [ "caps", "shift" ] }
+        ],
+        "t" : [
+            { "title" : "t", "requires" : [                 ] },
+            { "title" : "T", "requires" : [ "caps"          ] },
+            { "title" : "T", "requires" : [ "shift"         ] },
+            { "title" : "t", "requires" : [ "caps", "shift" ] }
+        ],
+        "z" : [
+            { "title" : "z", "requires" : [                 ] },
+            { "title" : "Z", "requires" : [ "caps"          ] },
+            { "title" : "Z", "requires" : [ "shift"         ] },
+            { "title" : "z", "requires" : [ "caps", "shift" ] }
+        ],
+        "u" : [
+            { "title" : "u", "requires" : [                 ] },
+            { "title" : "U", "requires" : [ "caps"          ] },
+            { "title" : "U", "requires" : [ "shift"         ] },
+            { "title" : "u", "requires" : [ "caps", "shift" ] }
+        ],
+        "i" : [
+            { "title" : "i", "requires" : [                 ] },
+            { "title" : "I", "requires" : [ "caps"          ] },
+            { "title" : "I", "requires" : [ "shift"         ] },
+            { "title" : "i", "requires" : [ "caps", "shift" ] }
+        ],
+        "o" : [
+            { "title" : "o", "requires" : [                 ] },
+            { "title" : "O", "requires" : [ "caps"          ] },
+            { "title" : "O", "requires" : [ "shift"         ] },
+            { "title" : "o", "requires" : [ "caps", "shift" ] }
+        ],
+        "p" : [
+            { "title" : "p", "requires" : [                 ] },
+            { "title" : "P", "requires" : [ "caps"          ] },
+            { "title" : "P", "requires" : [ "shift"         ] },
+            { "title" : "p", "requires" : [ "caps", "shift" ] }
+        ],
+        "ü" : [
+            { "title" : "ü", "requires" : [                 ] },
+            { "title" : "Ü", "requires" : [ "caps"          ] },
+            { "title" : "Ü", "requires" : [ "shift"         ] },
+            { "title" : "ü", "requires" : [ "caps", "shift" ] }
+        ],
+        "a" : [
+            { "title" : "a", "requires" : [                 ] },
+            { "title" : "A", "requires" : [ "caps"          ] },
+            { "title" : "A", "requires" : [ "shift"         ] },
+            { "title" : "a", "requires" : [ "caps", "shift" ] }
+        ],
+        "s" : [
+            { "title" : "s", "requires" : [                 ] },
+            { "title" : "S", "requires" : [ "caps"          ] },
+            { "title" : "S", "requires" : [ "shift"         ] },
+            { "title" : "s", "requires" : [ "caps", "shift" ] }
+        ],
+        "d" : [
+            { "title" : "d", "requires" : [                 ] },
+            { "title" : "D", "requires" : [ "caps"          ] },
+            { "title" : "D", "requires" : [ "shift"         ] },
+            { "title" : "d", "requires" : [ "caps", "shift" ] }
+        ],
+        "f" : [
+            { "title" : "f", "requires" : [                 ] },
+            { "title" : "F", "requires" : [ "caps"          ] },
+            { "title" : "F", "requires" : [ "shift"         ] },
+            { "title" : "f", "requires" : [ "caps", "shift" ] }
+        ],
+        "g" : [
+            { "title" : "g", "requires" : [                 ] },
+            { "title" : "G", "requires" : [ "caps"          ] },
+            { "title" : "G", "requires" : [ "shift"         ] },
+            { "title" : "g", "requires" : [ "caps", "shift" ] }
+        ],
+        "h" : [
+            { "title" : "h", "requires" : [                 ] },
+            { "title" : "H", "requires" : [ "caps"          ] },
+            { "title" : "H", "requires" : [ "shift"         ] },
+            { "title" : "h", "requires" : [ "caps", "shift" ] }
+        ],
+        "j" : [
+            { "title" : "j", "requires" : [                 ] },
+            { "title" : "J", "requires" : [ "caps"          ] },
+            { "title" : "J", "requires" : [ "shift"         ] },
+            { "title" : "j", "requires" : [ "caps", "shift" ] }
+        ],
+        "k" : [
+            { "title" : "k", "requires" : [                 ] },
+            { "title" : "K", "requires" : [ "caps"          ] },
+            { "title" : "K", "requires" : [ "shift"         ] },
+            { "title" : "k", "requires" : [ "caps", "shift" ] }
+        ],
+        "l" : [
+            { "title" : "l", "requires" : [                 ] },
+            { "title" : "L", "requires" : [ "caps"          ] },
+            { "title" : "L", "requires" : [ "shift"         ] },
+            { "title" : "l", "requires" : [ "caps", "shift" ] }
+        ],
+        "ö" : [
+            { "title" : "ö", "requires" : [                 ] },
+            { "title" : "Ö", "requires" : [ "caps"          ] },
+            { "title" : "Ö", "requires" : [ "shift"         ] },
+            { "title" : "ö", "requires" : [ "caps", "shift" ] }
+        ],
+        "ä" : [
+            { "title" : "ä", "requires" : [                 ] },
+            { "title" : "Ä", "requires" : [ "caps"          ] },
+            { "title" : "Ä", "requires" : [ "shift"         ] },
+            { "title" : "ä", "requires" : [ "caps", "shift" ] }
+        ],
+        "y" : [
+            { "title" : "y", "requires" : [                 ] },
+            { "title" : "Y", "requires" : [ "caps"          ] },
+            { "title" : "Y", "requires" : [ "shift"         ] },
+            { "title" : "y", "requires" : [ "caps", "shift" ] }
+        ],
+        "x" : [
+            { "title" : "x", "requires" : [                 ] },
+            { "title" : "X", "requires" : [ "caps"          ] },
+            { "title" : "X", "requires" : [ "shift"         ] },
+            { "title" : "x", "requires" : [ "caps", "shift" ] }
+        ],
+        "c" : [
+            { "title" : "c", "requires" : [                 ] },
+            { "title" : "C", "requires" : [ "caps"          ] },
+            { "title" : "C", "requires" : [ "shift"         ] },
+            { "title" : "c", "requires" : [ "caps", "shift" ] }
+        ],
+        "v" : [
+            { "title" : "v", "requires" : [                 ] },
+            { "title" : "V", "requires" : [ "caps"          ] },
+            { "title" : "V", "requires" : [ "shift"         ] },
+            { "title" : "v", "requires" : [ "caps", "shift" ] }
+        ],
+        "b" : [
+            { "title" : "b", "requires" : [                 ] },
+            { "title" : "B", "requires" : [ "caps"          ] },
+            { "title" : "B", "requires" : [ "shift"         ] },
+            { "title" : "b", "requires" : [ "caps", "shift" ] }
+        ],
+        "n" : [
+            { "title" : "n", "requires" : [                 ] },
+            { "title" : "N", "requires" : [ "caps"          ] },
+            { "title" : "N", "requires" : [ "shift"         ] },
+            { "title" : "n", "requires" : [ "caps", "shift" ] }
+        ],
+        "m" : [
+            { "title" : "m", "requires" : [                 ] },
+            { "title" : "M", "requires" : [ "caps"          ] },
+            { "title" : "M", "requires" : [ "shift"         ] },
+            { "title" : "m", "requires" : [ "caps", "shift" ] },
+            { "title" : "µ", "requires" : [ "alt-gr"        ] }
+        ]
+
+    },
+
+    "layout" : [
+
+        [ "Esc", 0.7, "F1", "F2",  "F3",  "F4",
+                 0.7, "F5", "F6",  "F7",  "F8",
+                 0.7, "F9", "F10", "F11", "F12" ],
+
+        [ 0.1 ],
+
+        {
+            "main" : {
+                "alpha" : [
+
+                    [ "^", "1", "2", "3", "4", "5", "6", "7", "8", "9", "0", "ß", "´",      "Back" ],
+                    [ "Tab", "q", "w", "e", "r", "t", "z", "u", "i", "o", "p", "ü", "+",   1,  0.6 ],
+                    [ "Caps",  "a", "s", "d", "f", "g", "h", "j", "k", "l", "ö", "ä", "#", "Enter" ],
+                    [ "LShift", "<", "y", "x", "c", "v",  "b", "n",  "m", ",", ".", "-",  "RShift" ],
+                    [ "LCtrl", "Super", "LAlt",         "Space",          "AltGr", "Menu", "RCtrl" ]
+
+                ],
+
+                "movement" : [
+                    [ "Ins",  "Home", "PgUp"  ],
+                    [ "Del",  "End",  "PgDn"  ],
+                    [           1             ],
+                    [          "Up"           ],
+                    [ "Left", "Down", "Right" ]
+                ]
+            }
+        }
+
+    ],
+
+    "keyWidths" : {
+
+        "Back"   : 2,
+        "Tab"    : 1.5,
+        "\\"     : 1.5,
+        "Caps"   : 1.75,
+        "Enter"  : 1.25,
+        "LShift" : 2,
+        "RShift" : 2.1,
+
+        "LCtrl" : 1.6,
+        "Super" : 1.6,
+        "LAlt"  : 1.6,
+        "Space" : 6.1,
+        "AltGr" : 1.6,
+        "Menu"  : 1.6,
+        "RCtrl" : 1.6,
+
+        "Ins"  : 1.6,
+        "Home" : 1.6,
+        "PgUp" : 1.6,
+        "Del"  : 1.6,
+        "End"  : 1.6,
+        "PgDn" : 1.6
+
+    }
+
+}
diff --git a/guacamole/src/main/webapp/layouts/en-us-qwerty-mobile.xml b/guacamole/src/main/webapp/layouts/en-us-qwerty-mobile.xml
deleted file mode 100644
index 0be8167..0000000
--- a/guacamole/src/main/webapp/layouts/en-us-qwerty-mobile.xml
+++ /dev/null
@@ -1,312 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-
-<!DOCTYPE keyboard PUBLIC
-    "-//Guacamole/Guacamole Onscreen Keyboard DTD 0.6.0//EN"
-    "http://guac-dev.org/pub/dtd/guacamole-osk-0.6.0.dtd">
-
-<!--
-    Guacamole - Clientless Remote Desktop
-    Copyright (C) 2010  Michael Jumper
-
-    This program is free software: you can redistribute it and/or modify
-    it under the terms of the GNU Affero General Public License as published by
-    the Free Software Foundation, either version 3 of the License, or
-    (at your option) any later version.
-
-    This program is distributed in the hope that it will be useful,
-    but WITHOUT ANY WARRANTY; without even the implied warranty of
-    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-    GNU Affero General Public License for more details.
-
-    You should have received a copy of the GNU Affero General Public License
-    along with this program.  If not, see <http://www.gnu.org/licenses/>.
--->
-
-<keyboard lang="en_US" layout="qwerty" size="16.3">
-    <row>
-        <key size="1.5">
-            <cap keysym="0xFF09">Tab</cap>
-        </key>
-        <gap size="0.1"/>
-        <key>
-            <cap>q</cap>
-            <cap if="numsym">1</cap>
-            <cap if="shift">Q</cap>
-            <cap if="numsym,shift">q</cap>
-        </key>
-        <gap size="0.1"/>
-        <key>
-            <cap>w</cap>
-            <cap if="numsym">2</cap>
-            <cap if="shift">W</cap>
-            <cap if="numsym,shift">w</cap>
-        </key>
-        <gap size="0.1"/>
-        <key>
-            <cap>e</cap>
-            <cap if="numsym">3</cap>
-            <cap if="shift">E</cap>
-            <cap if="numsym,shift">e</cap>
-        </key>
-        <gap size="0.1"/>
-        <key>
-            <cap>r</cap>
-            <cap if="numsym">4</cap>
-            <cap if="shift">R</cap>
-            <cap if="numsym,shift">r</cap>
-        </key>
-        <gap size="0.1"/>
-        <key>
-            <cap>t</cap>
-            <cap if="numsym">5</cap>
-            <cap if="shift">T</cap>
-            <cap if="numsym,shift">t</cap>
-        </key>
-        <gap size="0.1"/>
-        <key>
-            <cap>y</cap>
-            <cap if="numsym">6</cap>
-            <cap if="shift">Y</cap>
-            <cap if="numsym,shift">y</cap>
-        </key>
-        <gap size="0.1"/>
-        <key>
-            <cap>u</cap>
-            <cap if="numsym">7</cap>
-            <cap if="shift">U</cap>
-            <cap if="numsym,shift">u</cap>
-        </key>
-        <gap size="0.1"/>
-        <key>
-            <cap>i</cap>
-            <cap if="numsym">8</cap>
-            <cap if="shift">I</cap>
-            <cap if="numsym,shift">i</cap>
-        </key>
-        <gap size="0.1"/>
-        <key>
-            <cap>o</cap>
-            <cap if="numsym">9</cap>
-            <cap if="shift">O</cap>
-            <cap if="numsym,shift">o</cap>
-        </key>
-        <gap size="0.1"/>
-        <key>
-            <cap>p</cap>
-            <cap if="numsym">0</cap>
-            <cap if="shift">P</cap>
-            <cap if="numsym,shift">p</cap>
-        </key>
-        <gap size="0.1"/>
-        <key>
-            <cap>[</cap>
-            <cap if="shift">{</cap>
-        </key>
-        <gap size="0.1"/>
-        <key>
-            <cap>]</cap>
-            <cap if="shift">}</cap>
-        </key>
-        <gap size="0.1"/>
-        <key size="1.5">
-            <cap keysym="0xFF08">Back</cap>
-        </key>
-    </row>
-
-    <row><gap size="0.1"/></row>
-
-    <row>
-        <key size="1.85" class="numsym">
-            <cap modifier="numsym" sticky="true">?123</cap>
-        </key>
-        <gap size="0.1"/>
-        <key>
-            <cap>a</cap>
-            <cap if="numsym">#</cap>
-            <cap if="shift">A</cap>
-            <cap if="numsym,shift">a</cap>
-        </key>
-        <gap size="0.1"/>
-        <key>
-            <cap>s</cap>
-            <cap if="numsym">$</cap>
-            <cap if="shift">S</cap>
-            <cap if="numsym,shift">s</cap>
-        </key>
-        <gap size="0.1"/>
-        <key>
-            <cap>d</cap>
-            <cap if="numsym">%</cap>
-            <cap if="shift">D</cap>
-            <cap if="numsym,shift">d</cap>
-        </key>
-        <gap size="0.1"/>
-        <key>
-            <cap>f</cap>
-            <cap if="numsym">&</cap>
-            <cap if="shift">F</cap>
-            <cap if="numsym,shift">f</cap>
-        </key>
-        <gap size="0.1"/>
-        <key>
-            <cap>g</cap>
-            <cap if="numsym">*</cap>
-            <cap if="shift">G</cap>
-            <cap if="numsym,shift">g</cap>
-        </key>
-        <gap size="0.1"/>
-        <key>
-            <cap>h</cap>
-            <cap if="numsym">-</cap>
-            <cap if="shift">H</cap>
-            <cap if="numsym,shift">h</cap>
-        </key>
-        <gap size="0.1"/>
-        <key>
-            <cap>j</cap>
-            <cap if="numsym">+</cap>
-            <cap if="shift">J</cap>
-            <cap if="numsym,shift">j</cap>
-        </key>
-        <gap size="0.1"/>
-        <key>
-            <cap>k</cap>
-            <cap if="numsym">(</cap>
-            <cap if="shift">K</cap>
-            <cap if="numsym,shift">k</cap>
-        </key>
-        <gap size="0.1"/>
-        <key>
-            <cap>l</cap>
-            <cap if="numsym">)</cap>
-            <cap if="shift">L</cap>
-            <cap if="numsym,shift">l</cap>
-        </key>
-        <gap size="0.1"/>
-        <key>
-            <cap>;</cap>
-            <cap if="shift">:</cap>
-        </key>
-        <gap size="0.1"/>
-        <key>
-            <cap>'</cap>
-            <cap if="shift">"</cap>
-        </key>
-        <gap size="0.1"/>
-        <key size="2.25">
-            <cap keysym="0xFF0D">Enter</cap>
-        </key>
-    </row>
-
-    <row><gap size="0.1"/></row>
-
-    <row>
-        <key size="2.1" class="shift">
-            <cap modifier="shift" keysym="0xFFE1">Shift</cap>
-        </key>
-        <gap size="0.1"/>
-        <key>
-            <cap>z</cap>
-            <cap if="numsym"><</cap>
-            <cap if="shift">Z</cap>
-            <cap if="numsym,shift">z</cap>
-        </key>
-        <gap size="0.1"/>
-        <key>
-            <cap>x</cap>
-            <cap if="numsym">></cap>
-            <cap if="shift">X</cap>
-            <cap if="numsym,shift">x</cap>
-        </key>
-        <gap size="0.1"/>
-        <key>
-            <cap>c</cap>
-            <cap if="numsym">=</cap>
-            <cap if="shift">C</cap>
-            <cap if="numsym,shift">c</cap>
-        </key>
-        <gap size="0.1"/>
-        <key>
-            <cap>v</cap>
-            <cap if="numsym">'</cap>
-            <cap if="shift">V</cap>
-            <cap if="numsym,shift">v</cap>
-        </key>
-        <gap size="0.1"/>
-        <key>
-            <cap>b</cap>
-            <cap if="numsym">;</cap>
-            <cap if="shift">B</cap>
-            <cap if="numsym,shift">b</cap>
-        </key>
-        <gap size="0.1"/>
-        <key>
-            <cap>n</cap>
-            <cap if="numsym">,</cap>
-            <cap if="shift">N</cap>
-            <cap if="numsym,shift">n</cap>
-        </key>
-        <gap size="0.1"/>
-        <key>
-            <cap>m</cap>
-            <cap if="numsym">.</cap>
-            <cap if="shift">M</cap>
-            <cap if="numsym,shift">m</cap>
-        </key>
-        <gap size="0.1"/>
-        <key>
-            <cap>,</cap>
-            <cap if="numsym">!</cap>
-            <cap if="shift">!</cap>
-            <cap if="numsym,shift">!</cap>
-        </key>
-        <gap size="0.1"/>
-        <key>
-            <cap>.</cap>
-            <cap if="numsym">?</cap>
-            <cap if="shift">?</cap>
-            <cap if="numsym,shift">?</cap>
-        </key>
-        <gap size="0.1"/>
-        <key>
-            <cap>/</cap>
-            <cap if="shift">?</cap>
-        </key>
-        <gap size="0.1"/>
-        <key size="3.1" class="shift">
-            <cap modifier="shift" keysym="0xFFE2">Shift</cap>
-        </key>
-    </row>
-
-    <row><gap size="0.1"/></row>
-
-    <row>
-        <key size="1.6" class="control">
-            <cap modifier="control" keysym="0xFFE3">Ctrl</cap>
-        </key>
-        <gap size="0.1"/>
-        <key size="1.6" class="super">
-            <cap modifier="super" keysym="0xFFEB">Super</cap>
-        </key>
-        <gap size="0.1"/>
-        <key size="1.6" class="alt">
-            <cap modifier="alt" keysym="0xFFE9">Alt</cap>
-        </key>
-        <gap size="0.1"/>
-        <key size="6.1">
-            <cap> </cap>
-        </key>
-        <gap size="0.1"/>
-        <key size="1.6" class="alt">
-            <cap modifier="alt" keysym="0xFFEA">Alt</cap>
-        </key>
-        <gap size="0.1"/>
-        <key size="1.6">
-            <cap keysym="0xFF67">Menu</cap>
-        </key>
-        <gap size="0.1"/>
-        <key size="1.6" class="control">
-            <cap modifier="control" keysym="0xFFE4">Ctrl</cap>
-        </key>
-    </row>
-</keyboard>
diff --git a/guacamole/src/main/webapp/layouts/en-us-qwerty.json b/guacamole/src/main/webapp/layouts/en-us-qwerty.json
new file mode 100644
index 0000000..13b2ddb
--- /dev/null
+++ b/guacamole/src/main/webapp/layouts/en-us-qwerty.json
@@ -0,0 +1,399 @@
+{
+
+    "language" : "en_US",
+    "type"     : "qwerty",
+    "width"    : 22,
+
+    "keys" : {
+
+        "Back"  : 65288,
+        "Tab"   : 65289,
+        "Enter" : 65293,
+        "Esc"   : 65307,
+        "Home"  : 65360,
+        "PgUp"  : 65365,
+        "PgDn"  : 65366,
+        "End"   : 65367,
+        "Ins"   : 65379,
+        "F1"    : 65470,
+        "F2"    : 65471,
+        "F3"    : 65472,
+        "F4"    : 65473,
+        "F5"    : 65474,
+        "F6"    : 65475,
+        "F7"    : 65476,
+        "F8"    : 65477,
+        "F9"    : 65478,
+        "F10"   : 65479,
+        "F11"   : 65480,
+        "F12"   : 65481,
+        "Del"   : 65535,
+
+        "Space" : " ",
+
+        "Left" : [{
+            "title"  : "←",
+            "keysym" : 65361
+        }],
+        "Up" : [{
+            "title"  : "↑",
+            "keysym" : 65362
+        }],
+        "Right" : [{
+            "title"  : "→",
+            "keysym" : 65363
+        }],
+        "Down" : [{
+            "title"  : "↓",
+            "keysym" : 65364
+        }],
+
+        "Menu" : [{
+            "title"    : "Menu",
+            "keysym"   : 65383
+        }],
+        "LShift" : [{
+            "title"    : "Shift",
+            "modifier" : "shift",
+            "keysym"   : 65505
+        }],
+        "RShift" : [{
+            "title"    : "Shift",
+            "modifier" : "shift",
+            "keysym"   : 65506
+        }],
+        "LCtrl" : [{
+            "title"    : "Ctrl",
+            "modifier" : "control",
+            "keysym"   : 65507
+        }],
+        "RCtrl" : [{
+            "title"    : "Ctrl",
+            "modifier" : "control",
+            "keysym"   : 65508
+        }],
+        "Caps" : [{
+            "title"    : "Caps",
+            "modifier" : "caps",
+            "keysym"   : 65509
+        }],
+        "LAlt" : [{
+            "title"    : "Alt",
+            "modifier" : "alt",
+            "keysym"   : 65513
+        }],
+        "RAlt" : [{
+            "title"    : "Alt",
+            "modifier" : "alt",
+            "keysym"   : 65514
+        }],
+        "Super" : [{
+            "title"    : "Super",
+            "modifier" : "super",
+            "keysym"   : 65515
+        }],
+
+        "`" : [
+            { "title" : "`", "requires" : [         ] },
+            { "title" : "~", "requires" : [ "shift" ] }
+        ],
+        "1" : [
+            { "title" : "1", "requires" : [         ] },
+            { "title" : "!", "requires" : [ "shift" ] }
+        ],
+        "2" : [
+            { "title" : "2", "requires" : [         ] },
+            { "title" : "@", "requires" : [ "shift" ] }
+        ],
+        "3" : [
+            { "title" : "3", "requires" : [         ] },
+            { "title" : "#", "requires" : [ "shift" ] }
+        ],
+        "4" : [
+            { "title" : "4", "requires" : [         ] },
+            { "title" : "$", "requires" : [ "shift" ] }
+        ],
+        "5" : [
+            { "title" : "5", "requires" : [         ] },
+            { "title" : "%", "requires" : [ "shift" ] }
+        ],
+        "6" : [
+            { "title" : "6", "requires" : [         ] },
+            { "title" : "^", "requires" : [ "shift" ] }
+        ],
+        "7" : [
+            { "title" : "7", "requires" : [         ] },
+            { "title" : "&", "requires" : [ "shift" ] }
+        ],
+        "8" : [
+            { "title" : "8", "requires" : [         ] },
+            { "title" : "*", "requires" : [ "shift" ] }
+        ],
+        "9" : [
+            { "title" : "9", "requires" : [         ] },
+            { "title" : "(", "requires" : [ "shift" ] }
+        ],
+        "0" : [
+            { "title" : "0", "requires" : [         ] },
+            { "title" : ")", "requires" : [ "shift" ] }
+        ],
+        "-" : [
+            { "title" : "-", "requires" : [         ] },
+            { "title" : "_", "requires" : [ "shift" ] }
+        ],
+        "=" : [
+            { "title" : "=", "requires" : [         ] },
+            { "title" : "+", "requires" : [ "shift" ] }
+        ],
+        "," : [
+            { "title" : ",", "requires" : [         ] },
+            { "title" : "<", "requires" : [ "shift" ] }
+        ],
+        "." : [
+            { "title" : ".", "requires" : [         ] },
+            { "title" : ">", "requires" : [ "shift" ] }
+        ],
+        "/" : [
+            { "title" : "/", "requires" : [         ] },
+            { "title" : "?", "requires" : [ "shift" ] }
+        ],
+        "[" : [
+            { "title" : "[", "requires" : [         ] },
+            { "title" : "{", "requires" : [ "shift" ] }
+        ],
+        "]" : [
+            { "title" : "]", "requires" : [         ] },
+            { "title" : "}", "requires" : [ "shift" ] }
+        ],
+        "\\" : [
+            { "title" : "\\", "requires" : [         ] },
+            { "title" : "|",  "requires" : [ "shift" ] }
+        ],
+        ";" : [
+            { "title" : ";", "requires" : [         ] },
+            { "title" : ":", "requires" : [ "shift" ] }
+        ],
+        "'" : [
+            { "title" : "'",  "requires" : [         ] },
+            { "title" : "\"", "requires" : [ "shift" ] }
+        ],
+
+        "q" : [
+            { "title" : "q", "requires" : [                 ] },
+            { "title" : "Q", "requires" : [ "caps"          ] },
+            { "title" : "Q", "requires" : [ "shift"         ] },
+            { "title" : "q", "requires" : [ "caps", "shift" ] }
+        ],
+        "w" : [
+            { "title" : "w", "requires" : [                 ] },
+            { "title" : "W", "requires" : [ "caps"          ] },
+            { "title" : "W", "requires" : [ "shift"         ] },
+            { "title" : "w", "requires" : [ "caps", "shift" ] }
+        ],
+        "e" : [
+            { "title" : "e", "requires" : [                 ] },
+            { "title" : "E", "requires" : [ "caps"          ] },
+            { "title" : "E", "requires" : [ "shift"         ] },
+            { "title" : "e", "requires" : [ "caps", "shift" ] }
+        ],
+        "r" : [
+            { "title" : "r", "requires" : [                 ] },
+            { "title" : "R", "requires" : [ "caps"          ] },
+            { "title" : "R", "requires" : [ "shift"         ] },
+            { "title" : "r", "requires" : [ "caps", "shift" ] }
+        ],
+        "t" : [
+            { "title" : "t", "requires" : [                 ] },
+            { "title" : "T", "requires" : [ "caps"          ] },
+            { "title" : "T", "requires" : [ "shift"         ] },
+            { "title" : "t", "requires" : [ "caps", "shift" ] }
+        ],
+        "y" : [
+            { "title" : "y", "requires" : [                 ] },
+            { "title" : "Y", "requires" : [ "caps"          ] },
+            { "title" : "Y", "requires" : [ "shift"         ] },
+            { "title" : "y", "requires" : [ "caps", "shift" ] }
+        ],
+        "u" : [
+            { "title" : "u", "requires" : [                 ] },
+            { "title" : "U", "requires" : [ "caps"          ] },
+            { "title" : "U", "requires" : [ "shift"         ] },
+            { "title" : "u", "requires" : [ "caps", "shift" ] }
+        ],
+        "i" : [
+            { "title" : "i", "requires" : [                 ] },
+            { "title" : "I", "requires" : [ "caps"          ] },
+            { "title" : "I", "requires" : [ "shift"         ] },
+            { "title" : "i", "requires" : [ "caps", "shift" ] }
+        ],
+        "o" : [
+            { "title" : "o", "requires" : [                 ] },
+            { "title" : "O", "requires" : [ "caps"          ] },
+            { "title" : "O", "requires" : [ "shift"         ] },
+            { "title" : "o", "requires" : [ "caps", "shift" ] }
+        ],
+        "p" : [
+            { "title" : "p", "requires" : [                 ] },
+            { "title" : "P", "requires" : [ "caps"          ] },
+            { "title" : "P", "requires" : [ "shift"         ] },
+            { "title" : "p", "requires" : [ "caps", "shift" ] }
+        ],
+        "a" : [
+            { "title" : "a", "requires" : [                 ] },
+            { "title" : "A", "requires" : [ "caps"          ] },
+            { "title" : "A", "requires" : [ "shift"         ] },
+            { "title" : "a", "requires" : [ "caps", "shift" ] }
+        ],
+        "s" : [
+            { "title" : "s", "requires" : [                 ] },
+            { "title" : "S", "requires" : [ "caps"          ] },
+            { "title" : "S", "requires" : [ "shift"         ] },
+            { "title" : "s", "requires" : [ "caps", "shift" ] }
+        ],
+        "d" : [
+            { "title" : "d", "requires" : [                 ] },
+            { "title" : "D", "requires" : [ "caps"          ] },
+            { "title" : "D", "requires" : [ "shift"         ] },
+            { "title" : "d", "requires" : [ "caps", "shift" ] }
+        ],
+        "f" : [
+            { "title" : "f", "requires" : [                 ] },
+            { "title" : "F", "requires" : [ "caps"          ] },
+            { "title" : "F", "requires" : [ "shift"         ] },
+            { "title" : "f", "requires" : [ "caps", "shift" ] }
+        ],
+        "g" : [
+            { "title" : "g", "requires" : [                 ] },
+            { "title" : "G", "requires" : [ "caps"          ] },
+            { "title" : "G", "requires" : [ "shift"         ] },
+            { "title" : "g", "requires" : [ "caps", "shift" ] }
+        ],
+        "h" : [
+            { "title" : "h", "requires" : [                 ] },
+            { "title" : "H", "requires" : [ "caps"          ] },
+            { "title" : "H", "requires" : [ "shift"         ] },
+            { "title" : "h", "requires" : [ "caps", "shift" ] }
+        ],
+        "j" : [
+            { "title" : "j", "requires" : [                 ] },
+            { "title" : "J", "requires" : [ "caps"          ] },
+            { "title" : "J", "requires" : [ "shift"         ] },
+            { "title" : "j", "requires" : [ "caps", "shift" ] }
+        ],
+        "k" : [
+            { "title" : "k", "requires" : [                 ] },
+            { "title" : "K", "requires" : [ "caps"          ] },
+            { "title" : "K", "requires" : [ "shift"         ] },
+            { "title" : "k", "requires" : [ "caps", "shift" ] }
+        ],
+        "l" : [
+            { "title" : "l", "requires" : [                 ] },
+            { "title" : "L", "requires" : [ "caps"          ] },
+            { "title" : "L", "requires" : [ "shift"         ] },
+            { "title" : "l", "requires" : [ "caps", "shift" ] }
+        ],
+        "z" : [
+            { "title" : "z", "requires" : [                 ] },
+            { "title" : "Z", "requires" : [ "caps"          ] },
+            { "title" : "Z", "requires" : [ "shift"         ] },
+            { "title" : "z", "requires" : [ "caps", "shift" ] }
+        ],
+        "x" : [
+            { "title" : "x", "requires" : [                 ] },
+            { "title" : "X", "requires" : [ "caps"          ] },
+            { "title" : "X", "requires" : [ "shift"         ] },
+            { "title" : "x", "requires" : [ "caps", "shift" ] }
+        ],
+        "c" : [
+            { "title" : "c", "requires" : [                 ] },
+            { "title" : "C", "requires" : [ "caps"          ] },
+            { "title" : "C", "requires" : [ "shift"         ] },
+            { "title" : "c", "requires" : [ "caps", "shift" ] }
+        ],
+        "v" : [
+            { "title" : "v", "requires" : [                 ] },
+            { "title" : "V", "requires" : [ "caps"          ] },
+            { "title" : "V", "requires" : [ "shift"         ] },
+            { "title" : "v", "requires" : [ "caps", "shift" ] }
+        ],
+        "b" : [
+            { "title" : "b", "requires" : [                 ] },
+            { "title" : "B", "requires" : [ "caps"          ] },
+            { "title" : "B", "requires" : [ "shift"         ] },
+            { "title" : "b", "requires" : [ "caps", "shift" ] }
+        ],
+        "n" : [
+            { "title" : "n", "requires" : [                 ] },
+            { "title" : "N", "requires" : [ "caps"          ] },
+            { "title" : "N", "requires" : [ "shift"         ] },
+            { "title" : "n", "requires" : [ "caps", "shift" ] }
+        ],
+        "m" : [
+            { "title" : "m", "requires" : [                 ] },
+            { "title" : "M", "requires" : [ "caps"          ] },
+            { "title" : "M", "requires" : [ "shift"         ] },
+            { "title" : "m", "requires" : [ "caps", "shift" ] }
+        ]
+
+    },
+
+    "layout" : [
+
+        [ "Esc", 0.7, "F1", "F2",  "F3",  "F4",
+                 0.7, "F5", "F6",  "F7",  "F8",
+                 0.7, "F9", "F10", "F11", "F12" ],
+
+        [ 0.1 ],
+
+        {
+            "main" : {
+                "alpha" : [
+
+                    [ "`", "1", "2", "3", "4", "5", "6", "7", "8", "9", "0", "-", "=", "Back" ],
+                    [ "Tab", "q", "w", "e", "r", "t", "y", "u", "i", "o", "p", "[", "]", "\\" ],
+                    [ "Caps",  "a", "s", "d", "f", "g", "h", "j", "k", "l", ";", "'", "Enter" ],
+                    [ "LShift", "z", "x", "c", "v",  "b", "n",  "m", ",", ".", "/",  "RShift" ],
+                    [ "LCtrl", "Super", "LAlt",       "Space",        "RAlt", "Menu", "RCtrl" ]
+
+                ],
+
+                "movement" : [
+                    [ "Ins",  "Home", "PgUp"  ],
+                    [ "Del",  "End",  "PgDn"  ],
+                    [           1             ],
+                    [          "Up"           ],
+                    [ "Left", "Down", "Right" ]
+                ]
+            }
+        }
+
+    ],
+
+    "keyWidths" : {
+
+        "Back"   : 2,
+        "Tab"    : 1.5,
+        "\\"     : 1.5,
+        "Caps"   : 1.85,
+        "Enter"  : 2.25,
+        "LShift" : 2.1,
+        "RShift" : 3.1,
+
+        "LCtrl" : 1.6,
+        "Super" : 1.6,
+        "LAlt"  : 1.6,
+        "Space" : 6.1,
+        "RAlt"  : 1.6,
+        "Menu"  : 1.6,
+        "RCtrl" : 1.6,
+
+        "Ins"  : 1.6,
+        "Home" : 1.6,
+        "PgUp" : 1.6,
+        "Del"  : 1.6,
+        "End"  : 1.6,
+        "PgDn" : 1.6
+
+    }
+
+}
diff --git a/guacamole/src/main/webapp/layouts/en-us-qwerty.xml b/guacamole/src/main/webapp/layouts/en-us-qwerty.xml
deleted file mode 100644
index 8a02944..0000000
--- a/guacamole/src/main/webapp/layouts/en-us-qwerty.xml
+++ /dev/null
@@ -1,496 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-
-<!DOCTYPE keyboard PUBLIC
-    "-//Guacamole/Guacamole Onscreen Keyboard DTD 0.6.0//EN"
-    "http://guac-dev.org/pub/dtd/guacamole-osk-0.6.0.dtd">
-
-<!--
-    Guacamole - Clientless Remote Desktop
-    Copyright (C) 2010  Michael Jumper
-
-    This program is free software: you can redistribute it and/or modify
-    it under the terms of the GNU Affero General Public License as published by
-    the Free Software Foundation, either version 3 of the License, or
-    (at your option) any later version.
-
-    This program is distributed in the hope that it will be useful,
-    but WITHOUT ANY WARRANTY; without even the implied warranty of
-    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-    GNU Affero General Public License for more details.
-
-    You should have received a copy of the GNU Affero General Public License
-    along with this program.  If not, see <http://www.gnu.org/licenses/>.
--->
-
-<keyboard lang="en_US" layout="qwerty" size="22">
-    <row>
-        <key>
-            <cap keysym="0xFF1B">Esc</cap>
-        </key>
-        <gap size="0.8"/>
-        <key>
-            <cap keysym="0xFFBE">F1</cap>
-        </key>
-        <gap size="0.1"/>
-        <key>
-            <cap keysym="0xFFBF">F2</cap>
-        </key>
-        <gap size="0.1"/>
-        <key>
-            <cap keysym="0xFFC0">F3</cap>
-        </key>
-        <gap size="0.1"/>
-        <key>
-            <cap keysym="0xFFC1">F4</cap>
-        </key>
-        <gap size="0.8"/>
-        <key>
-            <cap keysym="0xFFC2">F5</cap>
-        </key>
-        <gap size="0.1"/>
-        <key>
-            <cap keysym="0xFFC3">F6</cap>
-        </key>
-        <gap size="0.1"/>
-        <key>
-            <cap keysym="0xFFC4">F7</cap>
-        </key>
-        <gap size="0.1"/>
-        <key>
-            <cap keysym="0xFFC5">F8</cap>
-        </key>
-        <gap size="0.8"/>
-        <key>
-            <cap keysym="0xFFC6">F9</cap>
-        </key>
-        <gap size="0.1"/>
-        <key>
-            <cap keysym="0xFFC7">F10</cap>
-        </key>
-        <gap size="0.1"/>
-        <key>
-            <cap keysym="0xFFC8">F11</cap>
-        </key>
-        <gap size="0.1"/>
-        <key>
-            <cap keysym="0xFFC9">F12</cap>
-        </key>
-    </row>
-    <row>
-        <gap size="0.25"/>
-    </row>
-    <column>
-        <row>
-            <key>
-                <cap>`</cap>
-                <cap if="shift">~</cap>
-            </key>
-            <gap size="0.1"/>
-            <key>
-                <cap>1</cap>
-                <cap if="shift">!</cap>
-            </key>
-            <gap size="0.1"/>
-            <key>
-                <cap>2</cap>
-                <cap if="shift">@</cap>
-            </key>
-            <gap size="0.1"/>
-            <key>
-                <cap>3</cap>
-                <cap if="shift">#</cap>
-            </key>
-            <gap size="0.1"/>
-            <key>
-                <cap>4</cap>
-                <cap if="shift">$</cap>
-            </key>
-            <gap size="0.1"/>
-            <key>
-                <cap>5</cap>
-                <cap if="shift">%</cap>
-            </key>
-            <gap size="0.1"/>
-            <key>
-                <cap>6</cap>
-                <cap if="shift">^</cap>
-            </key>
-            <gap size="0.1"/>
-            <key>
-                <cap>7</cap>
-                <cap if="shift">&</cap>
-            </key>
-            <gap size="0.1"/>
-            <key>
-                <cap>8</cap>
-                <cap if="shift">*</cap>
-            </key>
-            <gap size="0.1"/>
-            <key>
-                <cap>9</cap>
-                <cap if="shift">(</cap>
-            </key>
-            <gap size="0.1"/>
-            <key>
-                <cap>0</cap>
-                <cap if="shift">)</cap>
-            </key>
-            <gap size="0.1"/>
-            <key>
-                <cap>-</cap>
-                <cap if="shift">_</cap>
-            </key>
-            <gap size="0.1"/>
-            <key>
-                <cap>=</cap>
-                <cap if="shift">+</cap>
-            </key>
-            <gap size="0.1"/>
-            <key size="2">
-                <cap keysym="0xFF08">Back</cap>
-            </key>
-        </row>
-
-        <row><gap size="0.1"/></row>
-
-        <row>
-            <key size="1.5">
-                <cap keysym="0xFF09">Tab</cap>
-            </key>
-            <gap size="0.1"/>
-            <key>
-                <cap>q</cap>
-                <cap if="caps">Q</cap>
-                <cap if="shift">Q</cap>
-                <cap if="caps,shift">q</cap>
-            </key>
-            <gap size="0.1"/>
-            <key>
-                <cap>w</cap>
-                <cap if="caps">W</cap>
-                <cap if="shift">W</cap>
-                <cap if="caps,shift">w</cap>
-            </key>
-            <gap size="0.1"/>
-            <key>
-                <cap>e</cap>
-                <cap if="caps">E</cap>
-                <cap if="shift">E</cap>
-                <cap if="caps,shift">e</cap>
-            </key>
-            <gap size="0.1"/>
-            <key>
-                <cap>r</cap>
-                <cap if="caps">R</cap>
-                <cap if="shift">R</cap>
-                <cap if="caps,shift">r</cap>
-            </key>
-            <gap size="0.1"/>
-            <key>
-                <cap>t</cap>
-                <cap if="caps">T</cap>
-                <cap if="shift">T</cap>
-                <cap if="caps,shift">t</cap>
-            </key>
-            <gap size="0.1"/>
-            <key>
-                <cap>y</cap>
-                <cap if="caps">Y</cap>
-                <cap if="shift">Y</cap>
-                <cap if="caps,shift">y</cap>
-            </key>
-            <gap size="0.1"/>
-            <key>
-                <cap>u</cap>
-                <cap if="caps">U</cap>
-                <cap if="shift">U</cap>
-                <cap if="caps,shift">u</cap>
-            </key>
-            <gap size="0.1"/>
-            <key>
-                <cap>i</cap>
-                <cap if="caps">I</cap>
-                <cap if="shift">I</cap>
-                <cap if="caps,shift">i</cap>
-            </key>
-            <gap size="0.1"/>
-            <key>
-                <cap>o</cap>
-                <cap if="caps">O</cap>
-                <cap if="shift">O</cap>
-                <cap if="caps,shift">o</cap>
-            </key>
-            <gap size="0.1"/>
-            <key>
-                <cap>p</cap>
-                <cap if="caps">P</cap>
-                <cap if="shift">P</cap>
-                <cap if="caps,shift">p</cap>
-            </key>
-            <gap size="0.1"/>
-            <key>
-                <cap>[</cap>
-                <cap if="shift">{</cap>
-            </key>
-            <gap size="0.1"/>
-            <key>
-                <cap>]</cap>
-                <cap if="shift">}</cap>
-            </key>
-            <gap size="0.1"/>
-            <key size="1.5">
-                <cap>\</cap>
-                <cap if="shift">|</cap>
-            </key>
-        </row>
-
-        <row><gap size="0.1"/></row>
-
-        <row>
-            <key size="1.85">
-                <cap modifier="caps" keysym="0xFFE5" sticky="true">Caps</cap>
-            </key>
-            <gap size="0.1"/>
-            <key>
-                <cap>a</cap>
-                <cap if="caps">A</cap>
-                <cap if="shift">A</cap>
-                <cap if="caps,shift">a</cap>
-            </key>
-            <gap size="0.1"/>
-            <key>
-                <cap>s</cap>
-                <cap if="caps">S</cap>
-                <cap if="shift">S</cap>
-                <cap if="caps,shift">s</cap>
-            </key>
-            <gap size="0.1"/>
-            <key>
-                <cap>d</cap>
-                <cap if="caps">D</cap>
-                <cap if="shift">D</cap>
-                <cap if="caps,shift">d</cap>
-            </key>
-            <gap size="0.1"/>
-            <key>
-                <cap>f</cap>
-                <cap if="caps">F</cap>
-                <cap if="shift">F</cap>
-                <cap if="caps,shift">f</cap>
-            </key>
-            <gap size="0.1"/>
-            <key>
-                <cap>g</cap>
-                <cap if="caps">G</cap>
-                <cap if="shift">G</cap>
-                <cap if="caps,shift">g</cap>
-            </key>
-            <gap size="0.1"/>
-            <key>
-                <cap>h</cap>
-                <cap if="caps">H</cap>
-                <cap if="shift">H</cap>
-                <cap if="caps,shift">h</cap>
-            </key>
-            <gap size="0.1"/>
-            <key>
-                <cap>j</cap>
-                <cap if="caps">J</cap>
-                <cap if="shift">J</cap>
-                <cap if="caps,shift">j</cap>
-            </key>
-            <gap size="0.1"/>
-            <key>
-                <cap>k</cap>
-                <cap if="caps">K</cap>
-                <cap if="shift">K</cap>
-                <cap if="caps,shift">k</cap>
-            </key>
-            <gap size="0.1"/>
-            <key>
-                <cap>l</cap>
-                <cap if="caps">L</cap>
-                <cap if="shift">L</cap>
-                <cap if="caps,shift">l</cap>
-            </key>
-            <gap size="0.1"/>
-            <key>
-                <cap>;</cap>
-                <cap if="shift">:</cap>
-            </key>
-            <gap size="0.1"/>
-            <key>
-                <cap>'</cap>
-                <cap if="shift">"</cap>
-            </key>
-            <gap size="0.1"/>
-            <key size="2.25">
-                <cap keysym="0xFF0D">Enter</cap>
-            </key>
-        </row>
-
-        <row><gap size="0.1"/></row>
-
-        <row>
-            <key size="2.1" class="shift">
-                <cap modifier="shift" keysym="0xFFE1">Shift</cap>
-            </key>
-            <gap size="0.1"/>
-            <key>
-                <cap>z</cap>
-                <cap if="caps">Z</cap>
-                <cap if="shift">Z</cap>
-                <cap if="caps,shift">z</cap>
-            </key>
-            <gap size="0.1"/>
-            <key>
-                <cap>x</cap>
-                <cap if="caps">X</cap>
-                <cap if="shift">X</cap>
-                <cap if="caps,shift">x</cap>
-            </key>
-            <gap size="0.1"/>
-            <key>
-                <cap>c</cap>
-                <cap if="caps">C</cap>
-                <cap if="shift">C</cap>
-                <cap if="caps,shift">c</cap>
-            </key>
-            <gap size="0.1"/>
-            <key>
-                <cap>v</cap>
-                <cap if="caps">V</cap>
-                <cap if="shift">V</cap>
-                <cap if="caps,shift">v</cap>
-            </key>
-            <gap size="0.1"/>
-            <key>
-                <cap>b</cap>
-                <cap if="caps">B</cap>
-                <cap if="shift">B</cap>
-                <cap if="caps,shift">b</cap>
-            </key>
-            <gap size="0.1"/>
-            <key>
-                <cap>n</cap>
-                <cap if="caps">N</cap>
-                <cap if="shift">N</cap>
-                <cap if="caps,shift">n</cap>
-            </key>
-            <gap size="0.1"/>
-            <key>
-                <cap>m</cap>
-                <cap if="caps">M</cap>
-                <cap if="shift">M</cap>
-                <cap if="caps,shift">m</cap>
-            </key>
-            <gap size="0.1"/>
-            <key>
-                <cap>,</cap>
-                <cap if="shift"><</cap>
-            </key>
-            <gap size="0.1"/>
-            <key>
-                <cap>.</cap>
-                <cap if="shift">></cap>
-            </key>
-            <gap size="0.1"/>
-            <key>
-                <cap>/</cap>
-                <cap if="shift">?</cap>
-            </key>
-            <gap size="0.1"/>
-            <key size="3.1" class="shift">
-                <cap modifier="shift" keysym="0xFFE2">Shift</cap>
-            </key>
-        </row>
-
-        <row><gap size="0.1"/></row>
-
-        <row>
-            <key size="1.6" class="control">
-                <cap modifier="control" keysym="0xFFE3">Ctrl</cap>
-            </key>
-            <gap size="0.1"/>
-            <key size="1.6" class="super">
-                <cap modifier="super" keysym="0xFFEB">Super</cap>
-            </key>
-            <gap size="0.1"/>
-            <key size="1.6" class="alt">
-                <cap modifier="alt" keysym="0xFFE9">Alt</cap>
-            </key>
-            <gap size="0.1"/>
-            <key size="6.1">
-                <cap> </cap>
-            </key>
-            <gap size="0.1"/>
-            <key size="1.6" class="alt">
-                <cap modifier="alt" keysym="0xFFE3">Alt</cap>
-            </key>
-            <gap size="0.1"/>
-            <key size="1.6" class="super">
-                <cap modifier="super" keysym="0xFF67">Menu</cap>
-            </key>
-            <gap size="0.1"/>
-            <key size="1.6" class="control">
-                <cap modifier="control" keysym="0xFFE4">Ctrl</cap>
-            </key>
-        </row>
-    </column>
-        <column>
-        <row>
-            <gap size="0.25"/>
-        </row>
-    </column>
-    <column align="center">
-        <row>
-            <key size="1.75">
-                <cap keysym="0xFF63">Ins</cap>
-            </key>
-            <gap size="0.1"/>
-            <key size="1.75">
-                <cap keysym="0xFF50">Home</cap>
-            </key>
-            <gap size="0.1"/>
-            <key size="1.75">
-                <cap keysym="0xFF55">PgUp</cap>
-            </key>
-        </row>
-        <row><gap size="0.1"/></row>
-        <row>
-            <key size="1.75">
-                <cap keysym="0xFFFF">Del</cap>
-            </key>
-            <gap size="0.1"/>
-            <key size="1.75">
-                <cap keysym="0xFF57">End</cap>
-            </key>
-            <gap size="0.1"/>
-            <key size="1.75">
-                <cap keysym="0xFF56">PgDn</cap>
-            </key>
-        </row>
-        <row>
-            <gap/>
-        </row>
-        <row>
-            <key>
-                <cap keysym="0xFF52">&#x2191;</cap>
-            </key>
-        </row>
-        <row><gap size="0.1"/></row>
-        <row>
-            <key>
-                <cap keysym="0xFF51">&#x2190;</cap>
-            </key>
-            <gap size="0.1"/>
-            <key>
-                <cap keysym="0xFF54">&#x2193;</cap>
-            </key>
-            <gap size="0.1"/>
-            <key>
-                <cap keysym="0xFF53">&#x2192;</cap>
-            </key>
-        </row>
-    </column>
-</keyboard>
diff --git a/guacamole/src/main/webapp/layouts/fr-fr-azerty.json b/guacamole/src/main/webapp/layouts/fr-fr-azerty.json
new file mode 100644
index 0000000..8b349fe
--- /dev/null
+++ b/guacamole/src/main/webapp/layouts/fr-fr-azerty.json
@@ -0,0 +1,399 @@
+{
+
+    "language" : "fr_FR",
+    "type"     : "azerty",
+    "width"    : 22,
+
+    "keys" : {
+
+        "Back"  : 65288,
+        "Tab"   : 65289,
+        "Enter" : 65293,
+        "Esc"   : 65307,
+        "Home"  : 65360,
+        "PgUp"  : 65365,
+        "PgDn"  : 65366,
+        "End"   : 65367,
+        "Ins"   : 65379,
+        "F1"    : 65470,
+        "F2"    : 65471,
+        "F3"    : 65472,
+        "F4"    : 65473,
+        "F5"    : 65474,
+        "F6"    : 65475,
+        "F7"    : 65476,
+        "F8"    : 65477,
+        "F9"    : 65478,
+        "F10"   : 65479,
+        "F11"   : 65480,
+        "F12"   : 65481,
+        "Del"   : 65535,
+
+        "Space" : " ",
+
+        "Left" : [{
+            "title"  : "←",
+            "keysym" : 65361
+        }],
+        "Up" : [{
+            "title"  : "↑",
+            "keysym" : 65362
+        }],
+        "Right" : [{
+            "title"  : "→",
+            "keysym" : 65363
+        }],
+        "Down" : [{
+            "title"  : "↓",
+            "keysym" : 65364
+        }],
+
+        "Menu" : [{
+            "title"    : "Menu",
+            "keysym"   : 65383
+        }],
+        "LShift" : [{
+            "title"    : "Shift",
+            "modifier" : "shift",
+            "keysym"   : 65505
+        }],
+        "RShift" : [{
+            "title"    : "Shift",
+            "modifier" : "shift",
+            "keysym"   : 65506
+        }],
+        "LCtrl" : [{
+            "title"    : "Ctrl",
+            "modifier" : "control",
+            "keysym"   : 65507
+        }],
+        "RCtrl" : [{
+            "title"    : "Ctrl",
+            "modifier" : "control",
+            "keysym"   : 65508
+        }],
+        "Caps" : [{
+            "title"    : "Caps",
+            "modifier" : "caps",
+            "keysym"   : 65509
+        }],
+        "LAlt" : [{
+            "title"    : "Alt",
+            "modifier" : "alt",
+            "keysym"   : 65513
+        }],
+        "RAlt" : [{
+            "title"    : "Alt",
+            "modifier" : "alt",
+            "keysym"   : 65514
+        }],
+        "Super" : [{
+            "title"    : "Super",
+            "modifier" : "super",
+            "keysym"   : 65515
+        }],
+
+        "`" : [
+            { "title" : "`", "requires" : [         ] },
+            { "title" : "~", "requires" : [ "shift" ] }
+        ],
+        "&" : [
+            { "title" : "&", "requires" : [         ] },
+            { "title" : "1", "requires" : [ "shift" ] }
+        ],
+        "é" : [
+            { "title" : "é", "requires" : [         ] },
+            { "title" : "2", "requires" : [ "shift" ] }
+        ],
+        "\"" : [
+            { "title" : "\"", "requires" : [         ] },
+            { "title" : "3", "requires" : [ "shift" ] }
+        ],
+        "'" : [
+            { "title" : "'", "requires" : [         ] },
+            { "title" : "4", "requires" : [ "shift" ] }
+        ],
+        "(" : [
+            { "title" : "(", "requires" : [         ] },
+            { "title" : "5", "requires" : [ "shift" ] }
+        ],
+        "-" : [
+            { "title" : "-", "requires" : [         ] },
+            { "title" : "6", "requires" : [ "shift" ] }
+        ],
+        "è" : [
+            { "title" : "è", "requires" : [         ] },
+            { "title" : "7", "requires" : [ "shift" ] }
+        ],
+        "_" : [
+            { "title" : "_", "requires" : [         ] },
+            { "title" : "8", "requires" : [ "shift" ] }
+        ],
+        "ç" : [
+            { "title" : "ç", "requires" : [         ] },
+            { "title" : "9", "requires" : [ "shift" ] }
+        ],
+        "à" : [
+            { "title" : "à", "requires" : [         ] },
+            { "title" : "0", "requires" : [ "shift" ] }
+        ],
+        ")" : [
+            { "title" : ")", "requires" : [         ] },
+            { "title" : "°", "requires" : [ "shift" ] }
+        ],
+        "=" : [
+            { "title" : "=", "requires" : [         ] },
+            { "title" : "+", "requires" : [ "shift" ] }
+        ],
+        "^" : [
+            { "title" : "^", "requires" : [         ] },
+            { "title" : "¨", "requires" : [ "shift" ] }
+        ],
+        "$" : [
+            { "title" : "$", "requires" : [         ] },
+            { "title" : "£", "requires" : [ "shift" ] }
+        ],
+        "/" : [
+            { "title" : "/", "requires" : [         ] },
+            { "title" : "?", "requires" : [ "shift" ] }
+        ],
+        "[" : [
+            { "title" : "[", "requires" : [         ] },
+            { "title" : "{", "requires" : [ "shift" ] }
+        ],
+        "]" : [
+            { "title" : "]", "requires" : [         ] },
+            { "title" : "}", "requires" : [ "shift" ] }
+        ],
+        "\\" : [
+            { "title" : "\\", "requires" : [         ] },
+            { "title" : "|",  "requires" : [ "shift" ] }
+        ],
+        ";" : [
+            { "title" : ";", "requires" : [         ] },
+            { "title" : ":", "requires" : [ "shift" ] }
+        ],
+        "'" : [
+            { "title" : "'",  "requires" : [         ] },
+            { "title" : "\"", "requires" : [ "shift" ] }
+        ],
+
+        "q" : [
+            { "title" : "q", "requires" : [                 ] },
+            { "title" : "Q", "requires" : [ "caps"          ] },
+            { "title" : "Q", "requires" : [ "shift"         ] },
+            { "title" : "q", "requires" : [ "caps", "shift" ] }
+        ],
+        "w" : [
+            { "title" : "w", "requires" : [                 ] },
+            { "title" : "W", "requires" : [ "caps"          ] },
+            { "title" : "W", "requires" : [ "shift"         ] },
+            { "title" : "w", "requires" : [ "caps", "shift" ] }
+        ],
+        "e" : [
+            { "title" : "e", "requires" : [                 ] },
+            { "title" : "E", "requires" : [ "caps"          ] },
+            { "title" : "E", "requires" : [ "shift"         ] },
+            { "title" : "€", "requires" : [ "caps", "shift" ] }
+        ],
+        "r" : [
+            { "title" : "r", "requires" : [                 ] },
+            { "title" : "R", "requires" : [ "caps"          ] },
+            { "title" : "R", "requires" : [ "shift"         ] },
+            { "title" : "r", "requires" : [ "caps", "shift" ] }
+        ],
+        "t" : [
+            { "title" : "t", "requires" : [                 ] },
+            { "title" : "T", "requires" : [ "caps"          ] },
+            { "title" : "T", "requires" : [ "shift"         ] },
+            { "title" : "t", "requires" : [ "caps", "shift" ] }
+        ],
+        "y" : [
+            { "title" : "y", "requires" : [                 ] },
+            { "title" : "Y", "requires" : [ "caps"          ] },
+            { "title" : "Y", "requires" : [ "shift"         ] },
+            { "title" : "y", "requires" : [ "caps", "shift" ] }
+        ],
+        "u" : [
+            { "title" : "u", "requires" : [                 ] },
+            { "title" : "U", "requires" : [ "caps"          ] },
+            { "title" : "U", "requires" : [ "shift"         ] },
+            { "title" : "u", "requires" : [ "caps", "shift" ] }
+        ],
+        "i" : [
+            { "title" : "i", "requires" : [                 ] },
+            { "title" : "I", "requires" : [ "caps"          ] },
+            { "title" : "I", "requires" : [ "shift"         ] },
+            { "title" : "i", "requires" : [ "caps", "shift" ] }
+        ],
+        "o" : [
+            { "title" : "o", "requires" : [                 ] },
+            { "title" : "O", "requires" : [ "caps"          ] },
+            { "title" : "O", "requires" : [ "shift"         ] },
+            { "title" : "o", "requires" : [ "caps", "shift" ] }
+        ],
+        "p" : [
+            { "title" : "p", "requires" : [                 ] },
+            { "title" : "P", "requires" : [ "caps"          ] },
+            { "title" : "P", "requires" : [ "shift"         ] },
+            { "title" : "p", "requires" : [ "caps", "shift" ] }
+        ],
+        "a" : [
+            { "title" : "a", "requires" : [                 ] },
+            { "title" : "A", "requires" : [ "caps"          ] },
+            { "title" : "A", "requires" : [ "shift"         ] },
+            { "title" : "a", "requires" : [ "caps", "shift" ] }
+        ],
+        "s" : [
+            { "title" : "s", "requires" : [                 ] },
+            { "title" : "S", "requires" : [ "caps"          ] },
+            { "title" : "S", "requires" : [ "shift"         ] },
+            { "title" : "s", "requires" : [ "caps", "shift" ] }
+        ],
+        "d" : [
+            { "title" : "d", "requires" : [                 ] },
+            { "title" : "D", "requires" : [ "caps"          ] },
+            { "title" : "D", "requires" : [ "shift"         ] },
+            { "title" : "d", "requires" : [ "caps", "shift" ] }
+        ],
+        "f" : [
+            { "title" : "f", "requires" : [                 ] },
+            { "title" : "F", "requires" : [ "caps"          ] },
+            { "title" : "F", "requires" : [ "shift"         ] },
+            { "title" : "f", "requires" : [ "caps", "shift" ] }
+        ],
+        "g" : [
+            { "title" : "g", "requires" : [                 ] },
+            { "title" : "G", "requires" : [ "caps"          ] },
+            { "title" : "G", "requires" : [ "shift"         ] },
+            { "title" : "g", "requires" : [ "caps", "shift" ] }
+        ],
+        "h" : [
+            { "title" : "h", "requires" : [                 ] },
+            { "title" : "H", "requires" : [ "caps"          ] },
+            { "title" : "H", "requires" : [ "shift"         ] },
+            { "title" : "h", "requires" : [ "caps", "shift" ] }
+        ],
+        "j" : [
+            { "title" : "j", "requires" : [                 ] },
+            { "title" : "J", "requires" : [ "caps"          ] },
+            { "title" : "J", "requires" : [ "shift"         ] },
+            { "title" : "j", "requires" : [ "caps", "shift" ] }
+        ],
+        "k" : [
+            { "title" : "k", "requires" : [                 ] },
+            { "title" : "K", "requires" : [ "caps"          ] },
+            { "title" : "K", "requires" : [ "shift"         ] },
+            { "title" : "k", "requires" : [ "caps", "shift" ] }
+        ],
+        "l" : [
+            { "title" : "l", "requires" : [                 ] },
+            { "title" : "L", "requires" : [ "caps"          ] },
+            { "title" : "L", "requires" : [ "shift"         ] },
+            { "title" : "l", "requires" : [ "caps", "shift" ] }
+        ],
+        "z" : [
+            { "title" : "z", "requires" : [                 ] },
+            { "title" : "Z", "requires" : [ "caps"          ] },
+            { "title" : "Z", "requires" : [ "shift"         ] },
+            { "title" : "z", "requires" : [ "caps", "shift" ] }
+        ],
+        "x" : [
+            { "title" : "x", "requires" : [                 ] },
+            { "title" : "X", "requires" : [ "caps"          ] },
+            { "title" : "X", "requires" : [ "shift"         ] },
+            { "title" : "x", "requires" : [ "caps", "shift" ] }
+        ],
+        "c" : [
+            { "title" : "c", "requires" : [                 ] },
+            { "title" : "C", "requires" : [ "caps"          ] },
+            { "title" : "C", "requires" : [ "shift"         ] },
+            { "title" : "c", "requires" : [ "caps", "shift" ] }
+        ],
+        "v" : [
+            { "title" : "v", "requires" : [                 ] },
+            { "title" : "V", "requires" : [ "caps"          ] },
+            { "title" : "V", "requires" : [ "shift"         ] },
+            { "title" : "v", "requires" : [ "caps", "shift" ] }
+        ],
+        "b" : [
+            { "title" : "b", "requires" : [                 ] },
+            { "title" : "B", "requires" : [ "caps"          ] },
+            { "title" : "B", "requires" : [ "shift"         ] },
+            { "title" : "b", "requires" : [ "caps", "shift" ] }
+        ],
+        "n" : [
+            { "title" : "n", "requires" : [                 ] },
+            { "title" : "N", "requires" : [ "caps"          ] },
+            { "title" : "N", "requires" : [ "shift"         ] },
+            { "title" : "n", "requires" : [ "caps", "shift" ] }
+        ],
+        "m" : [
+            { "title" : "m", "requires" : [                 ] },
+            { "title" : "M", "requires" : [ "caps"          ] },
+            { "title" : "M", "requires" : [ "shift"         ] },
+            { "title" : "m", "requires" : [ "caps", "shift" ] }
+        ]
+
+    },
+
+    "layout" : [
+
+        [ "Esc", 0.7, "F1", "F2",  "F3",  "F4",
+                 0.7, "F5", "F6",  "F7",  "F8",
+                 0.7, "F9", "F10", "F11", "F12" ],
+
+        [ 0.1 ],
+
+        {
+            "main" : {
+                "alpha" : [
+
+                    [ "`", "&", "é", "\"", "'", "(", "-", "è", "_", "ç", "à", ")", "=", "Back" ],
+                    [ "Tab", "a", "z", "e", "r", "t", "y", "u", "i", "o", "p", "^", "$", "\\" ],
+                    [ "Caps",  "q", "s", "d", "f", "g", "h", "j", "k", "l", ";", "'", "Enter" ],
+                    [ "LShift", "w", "x", "c", "v",  "b", "n",  "m", ",", ".", "/",  "RShift" ],
+                    [ "LCtrl", "Super", "LAlt",       "Space",        "RAlt", "Menu", "RCtrl" ]
+
+                ],
+
+                "movement" : [
+                    [ "Ins",  "Home", "PgUp"  ],
+                    [ "Del",  "End",  "PgDn"  ],
+                    [           1             ],
+                    [          "Up"           ],
+                    [ "Left", "Down", "Right" ]
+                ]
+            }
+        }
+
+    ],
+
+    "keyWidths" : {
+
+        "Back"   : 2,
+        "Tab"    : 1.5,
+        "\\"     : 1.5,
+        "Caps"   : 1.85,
+        "Enter"  : 2.25,
+        "LShift" : 2.1,
+        "RShift" : 3.1,
+
+        "LCtrl" : 1.6,
+        "Super" : 1.6,
+        "LAlt"  : 1.6,
+        "Space" : 6.1,
+        "RAlt"  : 1.6,
+        "Menu"  : 1.6,
+        "RCtrl" : 1.6,
+
+        "Ins"  : 1.6,
+        "Home" : 1.6,
+        "PgUp" : 1.6,
+        "Del"  : 1.6,
+        "End"  : 1.6,
+        "PgDn" : 1.6
+
+    }
+
+}
diff --git a/guacamole/src/main/webapp/layouts/it-it-qwerty.json b/guacamole/src/main/webapp/layouts/it-it-qwerty.json
new file mode 100644
index 0000000..17ae06d
--- /dev/null
+++ b/guacamole/src/main/webapp/layouts/it-it-qwerty.json
@@ -0,0 +1,453 @@
+{
+
+    "language" : "it_IT",
+    "type"     : "qwerty",
+    "width"    : 23,
+
+    "keys" : {
+
+        "Esc"   : 65307,
+        "F1"    : 65470,
+        "F2"    : 65471,
+        "F3"    : 65472,
+        "F4"    : 65473,
+        "F5"    : 65474,
+        "F6"    : 65475,
+        "F7"    : 65476,
+        "F8"    : 65477,
+        "F9"    : 65478,
+        "F10"   : 65479,
+        "F11"   : 65480,
+        "F12"   : 65481,
+
+        "Space" : " ",
+
+        "Back" : [{
+            "title"  : "⟵",
+            "keysym" : 65288
+        }],
+        "Tab" : [{
+            "title"  : "Tab ↹",
+            "keysym" : 65289
+        }],
+        "Enter" : [{
+            "title"  : "↵",
+            "keysym" : 65293
+        }],
+        "Home" : [{
+            "title"  : "Home",
+            "keysym" : 65360
+        }],
+        "PgUp" : [{
+            "title"  : "PgUp ↑",
+            "keysym" : 65365
+        }],
+        "PgDn" : [{
+            "title"  : "PgDn ↓",
+            "keysym" : 65366
+        }],
+        "End" : [{
+            "title"  : "End",
+            "keysym" : 65367
+        }],
+        "Ins" : [{
+            "title"  : "Ins",
+            "keysym" : 65379
+        }],
+        "Del" : [{
+            "title"  : "Del",
+            "keysym" : 65535
+        }],
+
+        "Left" : [{
+            "title"  : "←",
+            "keysym" : 65361
+        }],
+        "Up" : [{
+            "title"  : "↑",
+            "keysym" : 65362
+        }],
+        "Right" : [{
+            "title"  : "→",
+            "keysym" : 65363
+        }],
+        "Down" : [{
+            "title"  : "↓",
+            "keysym" : 65364
+        }],
+
+        "Menu" : [{
+            "title"    : "Menu",
+            "modifier" : "super",
+            "keysym"   : 65383
+        }],
+        "LShift" : [{
+            "title"    : "Shift",
+            "modifier" : "shift",
+            "keysym"   : 65505
+        }],
+        "RShift" : [{
+            "title"    : "Shift",
+            "modifier" : "shift",
+            "keysym"   : 65506
+        }],
+        "LCtrl" : [{
+            "title"    : "Ctrl",
+            "modifier" : "control",
+            "keysym"   : 65507
+        }],
+        "RCtrl" : [{
+            "title"    : "Ctrl",
+            "modifier" : "control",
+            "keysym"   : 65508
+        }],
+        "Caps" : [{
+            "title"    : "Caps",
+            "modifier" : "caps",
+            "keysym"   : 65509
+        }],
+        "LAlt" : [{
+            "title"    : "Alt",
+            "modifier" : "alt",
+            "keysym"   : 65513
+        }],
+        "AltGr" : [{
+            "title"    : "AltGr",
+            "modifier" : "alt-gr",
+            "keysym"   : 65027
+        }],
+        "Super" : [{
+            "title"    : "Super",
+            "modifier" : "super",
+            "keysym"   : 65515
+        }],
+
+        "\\" : [
+            { "title" : "\\", "requires" : [         ] },
+            { "title" : "|", "requires" : [ "shift" ] }
+        ],
+        "1" : [
+            { "title" : "1", "requires" : [         ] },
+            { "title" : "!", "requires" : [ "shift" ] }
+        ],
+        "2" : [
+            { "title" : "2",  "requires" : [          ] },
+            { "title" : "\"", "requires" : [ "shift"  ] }
+        ],
+        "3" : [
+            { "title" : "3", "requires" : [          ] },
+            { "title" : "£", "requires" : [ "shift"  ] }
+        ],
+        "4" : [
+            { "title" : "4", "requires" : [         ] },
+            { "title" : "$", "requires" : [ "shift" ] }
+        ],
+        "5" : [
+            { "title" : "5", "requires" : [         ] },
+            { "title" : "%", "requires" : [ "shift" ] },
+            { "title" : "€", "requires" : [ "alt-gr" ] }
+        ],
+        "6" : [
+            { "title" : "6", "requires" : [         ] },
+            { "title" : "&", "requires" : [ "shift" ] }
+        ],
+        "7" : [
+            { "title" : "7", "requires" : [          ] },
+            { "title" : "/", "requires" : [ "shift"  ] }
+        ],
+        "8" : [
+            { "title" : "8", "requires" : [          ] },
+            { "title" : "(", "requires" : [ "shift"  ] }
+        ],
+        "9" : [
+            { "title" : "9", "requires" : [          ] },
+            { "title" : ")", "requires" : [ "shift"  ] }
+        ],
+        "0" : [
+            { "title" : "0", "requires" : [          ] },
+            { "title" : "=", "requires" : [ "shift"  ] }
+        ],
+        "'" : [
+            { "title" : "'", "requires" : [          ] },
+            { "title" : "?", "requires" : [ "shift"  ] },
+            { "title" : "`", "requires" : [ "alt-gr", "shift" ] }
+        ],
+        "ì" : [
+            { "title" : "ì", "requires" : [         ] },
+            { "title" : "^", "requires" : [ "shift" ] },
+            { "title" : "~", "requires" : [ "alt-gr", "shift" ] }
+        ],
+
+        "q" : [
+            { "title" : "q", "requires" : [                 ] },
+            { "title" : "Q", "requires" : [ "caps"          ] },
+            { "title" : "Q", "requires" : [ "shift"         ] },
+            { "title" : "q", "requires" : [ "caps", "shift" ] }
+        ],
+        "w" : [
+            { "title" : "w", "requires" : [                 ] },
+            { "title" : "W", "requires" : [ "caps"          ] },
+            { "title" : "W", "requires" : [ "shift"         ] },
+            { "title" : "w", "requires" : [ "caps", "shift" ] }
+        ],
+        "e" : [
+            { "title" : "e", "requires" : [                 ] },
+            { "title" : "E", "requires" : [ "caps"          ] },
+            { "title" : "E", "requires" : [ "shift"         ] },
+            { "title" : "e", "requires" : [ "caps", "shift" ] },
+            { "title" : "€", "requires" : [ "alt-gr"        ] }
+        ],
+        "r" : [
+            { "title" : "r", "requires" : [                 ] },
+            { "title" : "R", "requires" : [ "caps"          ] },
+            { "title" : "R", "requires" : [ "shift"         ] },
+            { "title" : "r", "requires" : [ "caps", "shift" ] }
+        ],
+        "t" : [
+            { "title" : "t", "requires" : [                 ] },
+            { "title" : "T", "requires" : [ "caps"          ] },
+            { "title" : "T", "requires" : [ "shift"         ] },
+            { "title" : "t", "requires" : [ "caps", "shift" ] }
+        ],
+        "y" : [
+            { "title" : "y", "requires" : [                 ] },
+            { "title" : "Y", "requires" : [ "caps"          ] },
+            { "title" : "Y", "requires" : [ "shift"         ] },
+            { "title" : "y", "requires" : [ "caps", "shift" ] }
+        ],
+        "u" : [
+            { "title" : "u", "requires" : [                 ] },
+            { "title" : "U", "requires" : [ "caps"          ] },
+            { "title" : "U", "requires" : [ "shift"         ] },
+            { "title" : "u", "requires" : [ "caps", "shift" ] }
+        ],
+        "i" : [
+            { "title" : "i", "requires" : [                 ] },
+            { "title" : "I", "requires" : [ "caps"          ] },
+            { "title" : "I", "requires" : [ "shift"         ] },
+            { "title" : "i", "requires" : [ "caps", "shift" ] }
+        ],
+        "o" : [
+            { "title" : "o", "requires" : [                 ] },
+            { "title" : "O", "requires" : [ "caps"          ] },
+            { "title" : "O", "requires" : [ "shift"         ] },
+            { "title" : "o", "requires" : [ "caps", "shift" ] }
+        ],
+        "p" : [
+            { "title" : "p", "requires" : [                 ] },
+            { "title" : "P", "requires" : [ "caps"          ] },
+            { "title" : "P", "requires" : [ "shift"         ] },
+            { "title" : "p", "requires" : [ "caps", "shift" ] }
+        ],
+        "è" : [
+            { "title" : "è", "requires" : [          ] },
+            { "title" : "è", "requires" : [ "caps"   ] },
+            { "title" : "é", "requires" : [ "shift"  ] },
+            { "title" : "é", "requires" : [ "caps", "shift" ] },
+            { "title" : "[", "requires" : [ "alt-gr" ] },
+            { "title" : "{", "requires" : [ "alt-gr", "shift" ] }
+        ],
+        "+" : [
+            { "title" : "+", "requires" : [          ] },
+            { "title" : "+", "requires" : [ "caps"   ] },
+            { "title" : "*", "requires" : [ "shift"  ] },
+            { "title" : "*", "requires" : [ "caps", "shift" ] },
+            { "title" : "]", "requires" : [ "alt-gr" ] },
+            { "title" : "}", "requires" : [ "alt-gr", "shift" ] }
+        ],
+        "a" : [
+            { "title" : "a", "requires" : [                 ] },
+            { "title" : "A", "requires" : [ "caps"          ] },
+            { "title" : "A", "requires" : [ "shift"         ] },
+            { "title" : "a", "requires" : [ "caps", "shift" ] }
+        ],
+        "s" : [
+            { "title" : "s", "requires" : [                 ] },
+            { "title" : "S", "requires" : [ "caps"          ] },
+            { "title" : "S", "requires" : [ "shift"         ] },
+            { "title" : "s", "requires" : [ "caps", "shift" ] }
+        ],
+        "d" : [
+            { "title" : "d", "requires" : [                 ] },
+            { "title" : "D", "requires" : [ "caps"          ] },
+            { "title" : "D", "requires" : [ "shift"         ] },
+            { "title" : "d", "requires" : [ "caps", "shift" ] }
+        ],
+        "f" : [
+            { "title" : "f", "requires" : [                 ] },
+            { "title" : "F", "requires" : [ "caps"          ] },
+            { "title" : "F", "requires" : [ "shift"         ] },
+            { "title" : "f", "requires" : [ "caps", "shift" ] }
+        ],
+        "g" : [
+            { "title" : "g", "requires" : [                 ] },
+            { "title" : "G", "requires" : [ "caps"          ] },
+            { "title" : "G", "requires" : [ "shift"         ] },
+            { "title" : "g", "requires" : [ "caps", "shift" ] }
+        ],
+        "h" : [
+            { "title" : "h", "requires" : [                 ] },
+            { "title" : "H", "requires" : [ "caps"          ] },
+            { "title" : "H", "requires" : [ "shift"         ] },
+            { "title" : "h", "requires" : [ "caps", "shift" ] }
+        ],
+        "j" : [
+            { "title" : "j", "requires" : [                 ] },
+            { "title" : "J", "requires" : [ "caps"          ] },
+            { "title" : "J", "requires" : [ "shift"         ] },
+            { "title" : "j", "requires" : [ "caps", "shift" ] }
+        ],
+        "k" : [
+            { "title" : "k", "requires" : [                 ] },
+            { "title" : "K", "requires" : [ "caps"          ] },
+            { "title" : "K", "requires" : [ "shift"         ] },
+            { "title" : "k", "requires" : [ "caps", "shift" ] }
+        ],
+        "l" : [
+            { "title" : "l", "requires" : [                 ] },
+            { "title" : "L", "requires" : [ "caps"          ] },
+            { "title" : "L", "requires" : [ "shift"         ] },
+            { "title" : "l", "requires" : [ "caps", "shift" ] }
+        ],
+        "ò" : [
+            { "title" : "ò", "requires" : [                 ] },
+            { "title" : "ò", "requires" : [ "caps"          ] },
+            { "title" : "ç", "requires" : [ "shift"         ] },
+            { "title" : "ç", "requires" : [ "caps", "shift" ] },
+            { "title" : "@", "requires" : [ "alt-gr" ] }
+        ],
+        "à" : [
+            { "title" : "à", "requires" : [                 ] },
+            { "title" : "à", "requires" : [ "caps"          ] },
+            { "title" : "°", "requires" : [ "shift"         ] },
+            { "title" : "°", "requires" : [ "caps", "shift" ] },
+            { "title" : "#", "requires" : [ "alt-gr" ] }
+        ],
+        "ù" : [
+            { "title" : "ù", "requires" : [                 ] },
+            { "title" : "ù", "requires" : [ "caps"          ] },
+            { "title" : "§", "requires" : [ "shift"         ] },
+            { "title" : "§", "requires" : [ "caps", "shift" ] }
+        ],
+
+        "<" : [
+            { "title" : "<", "requires" : [         ] },
+            { "title" : ">", "requires" : [ "shift" ] }
+        ],
+        "z" : [
+            { "title" : "z", "requires" : [                 ] },
+            { "title" : "Z", "requires" : [ "caps"          ] },
+            { "title" : "Z", "requires" : [ "shift"         ] },
+            { "title" : "z", "requires" : [ "caps", "shift" ] }
+        ],
+        "x" : [
+            { "title" : "x", "requires" : [                 ] },
+            { "title" : "X", "requires" : [ "caps"          ] },
+            { "title" : "X", "requires" : [ "shift"         ] },
+            { "title" : "x", "requires" : [ "caps", "shift" ] }
+        ],
+        "c" : [
+            { "title" : "c", "requires" : [                 ] },
+            { "title" : "C", "requires" : [ "caps"          ] },
+            { "title" : "C", "requires" : [ "shift"         ] },
+            { "title" : "c", "requires" : [ "caps", "shift" ] }
+        ],
+        "v" : [
+            { "title" : "v", "requires" : [                 ] },
+            { "title" : "V", "requires" : [ "caps"          ] },
+            { "title" : "V", "requires" : [ "shift"         ] },
+            { "title" : "v", "requires" : [ "caps", "shift" ] }
+        ],
+        "b" : [
+            { "title" : "b", "requires" : [                 ] },
+            { "title" : "B", "requires" : [ "caps"          ] },
+            { "title" : "B", "requires" : [ "shift"         ] },
+            { "title" : "b", "requires" : [ "caps", "shift" ] }
+        ],
+        "n" : [
+            { "title" : "n", "requires" : [                 ] },
+            { "title" : "N", "requires" : [ "caps"          ] },
+            { "title" : "N", "requires" : [ "shift"         ] },
+            { "title" : "n", "requires" : [ "caps", "shift" ] }
+        ],
+        "m" : [
+            { "title" : "m", "requires" : [                 ] },
+            { "title" : "M", "requires" : [ "caps"          ] },
+            { "title" : "M", "requires" : [ "shift"         ] },
+            { "title" : "m", "requires" : [ "caps", "shift" ] },
+            { "title" : "µ", "requires" : [ "alt-gr"        ] }
+        ],
+        "," : [
+            { "title" : ",", "requires" : [         ] },
+            { "title" : ";", "requires" : [ "shift" ] }
+        ],
+        "." : [
+            { "title" : ".",  "requires" : [         ] },
+            { "title" : ":",  "requires" : [ "shift" ] }
+        ],
+        "-" : [
+            { "title" : "-", "requires" : [         ] },
+            { "title" : "_", "requires" : [ "shift" ] }
+        ]
+    },
+
+    "layout" : [
+
+        [ "Esc", 0.8, "F1", "F2",  "F3",  "F4",
+                 0.8, "F5", "F6",  "F7",  "F8",
+                 0.8, "F9", "F10", "F11", "F12" ],
+
+        [ 0.1 ],
+
+        {
+            "main" : {
+                "alpha" : [
+
+                    [ "\\", "1", "2", "3", "4", "5", "6", "7", "8", "9", "0", "'", "ì",     "Back" ],
+                    [ "Tab", "q", "w", "e", "r", "t", "y", "u", "i", "o", "p", "è", "+",   1,  0.6 ],
+                    [ "Caps",  "a", "s", "d", "f", "g", "h", "j", "k", "l", "ò", "à", "ù", "Enter" ],
+                    [ "LShift", "<", "z", "x", "c", "v",  "b", "n",  "m", ",", ".", "-",  "RShift" ],
+                    [ "LCtrl", "Super", "LAlt",         "Space",          "AltGr", "Menu", "RCtrl" ]
+
+                ],
+
+                "movement" : [
+                    [ "Ins",  "Home", "PgUp"  ],
+                    [ "Del",  "End",  "PgDn"  ],
+                    [           1             ],
+                    [          "Up"           ],
+                    [ "Left", "Down", "Right" ]
+                ]
+            }
+        }
+
+    ],
+
+    "keyWidths" : {
+
+        "Back"   : 2,
+        "Tab"    : 1.75,
+        "\\"     : 1.25,
+        "Caps"   : 1.75,
+        "Enter"  : 1.5,
+        "LShift" : 2.2,
+        "RShift" : 2.2,
+
+        "LCtrl" : 1.6,
+        "Super" : 1.6,
+        "LAlt"  : 1.6,
+        "Space" : 6.4,
+        "AltGr" : 1.6,
+        "Menu"  : 1.6,
+        "RCtrl" : 1.6,
+
+        "Ins"  : 1.6,
+        "Home" : 1.6,
+        "PgUp" : 1.6,
+        "Del"  : 1.6,
+        "End"  : 1.6,
+        "PgDn" : 1.6
+
+    }
+
+}
diff --git a/guacamole/src/main/webapp/layouts/ru-ru-qwerty.json b/guacamole/src/main/webapp/layouts/ru-ru-qwerty.json
new file mode 100644
index 0000000..7b5b57d
--- /dev/null
+++ b/guacamole/src/main/webapp/layouts/ru-ru-qwerty.json
@@ -0,0 +1,411 @@
+{
+
+    "language" : "ru_RU",
+    "type"     : "qwerty",
+    "width"    : 22,
+
+    "keys" : {
+
+        "Back"  : 65288,
+        "Tab"   : 65289,
+        "Enter" : 65293,
+        "Esc"   : 65307,
+        "Home"  : 65360,
+        "PgUp"  : 65365,
+        "PgDn"  : 65366,
+        "End"   : 65367,
+        "Ins"   : 65379,
+        "F1"    : 65470,
+        "F2"    : 65471,
+        "F3"    : 65472,
+        "F4"    : 65473,
+        "F5"    : 65474,
+        "F6"    : 65475,
+        "F7"    : 65476,
+        "F8"    : 65477,
+        "F9"    : 65478,
+        "F10"   : 65479,
+        "F11"   : 65480,
+        "F12"   : 65481,
+        "Del"   : 65535,
+
+        "Space" : " ",
+
+        "Left" : [{
+            "title"  : "←",
+            "keysym" : 65361
+        }],
+        "Up" : [{
+            "title"  : "↑",
+            "keysym" : 65362
+        }],
+        "Right" : [{
+            "title"  : "→",
+            "keysym" : 65363
+        }],
+        "Down" : [{
+            "title"  : "↓",
+            "keysym" : 65364
+        }],
+
+        "Menu" : [{
+            "title"    : "Menu",
+            "keysym"   : 65383
+        }],
+        "LShift" : [{
+            "title"    : "Shift",
+            "modifier" : "shift",
+            "keysym"   : 65505
+        }],
+        "RShift" : [{
+            "title"    : "Shift",
+            "modifier" : "shift",
+            "keysym"   : 65506
+        }],
+        "LCtrl" : [{
+            "title"    : "Ctrl",
+            "modifier" : "control",
+            "keysym"   : 65507
+        }],
+        "RCtrl" : [{
+            "title"    : "Ctrl",
+            "modifier" : "control",
+            "keysym"   : 65508
+        }],
+        "Caps" : [{
+            "title"    : "Caps",
+            "modifier" : "caps",
+            "keysym"   : 65509
+        }],
+        "LAlt" : [{
+            "title"    : "Alt",
+            "modifier" : "alt",
+            "keysym"   : 65513
+        }],
+        "RAlt" : [{
+            "title"    : "Alt",
+            "modifier" : "alt",
+            "keysym"   : 65514
+        }],
+        "Super" : [{
+            "title"    : "Super",
+            "modifier" : "super",
+            "keysym"   : 65515
+        }],
+
+        "`" : [
+            { "title" : "`", "requires" : [         ] },
+            { "title" : "~", "requires" : [ "shift" ] }
+        ],
+        "1" : [
+            { "title" : "1", "requires" : [         ] },
+            { "title" : "!", "requires" : [ "shift" ] }
+        ],
+        "2" : [
+            { "title" : "2", "requires" : [         ] },
+            { "title" : "@", "requires" : [ "shift" ] }
+        ],
+        "3" : [
+            { "title" : "3", "requires" : [         ] },
+            { "title" : "#", "requires" : [ "shift" ] }
+        ],
+        "4" : [
+            { "title" : "4", "requires" : [         ] },
+            { "title" : "$", "requires" : [ "shift" ] }
+        ],
+        "5" : [
+            { "title" : "5", "requires" : [         ] },
+            { "title" : "%", "requires" : [ "shift" ] }
+        ],
+        "6" : [
+            { "title" : "6", "requires" : [         ] },
+            { "title" : "^", "requires" : [ "shift" ] }
+        ],
+        "7" : [
+            { "title" : "7", "requires" : [         ] },
+            { "title" : "&", "requires" : [ "shift" ] }
+        ],
+        "8" : [
+            { "title" : "8", "requires" : [         ] },
+            { "title" : "*", "requires" : [ "shift" ] }
+        ],
+        "9" : [
+            { "title" : "9", "requires" : [         ] },
+            { "title" : "(", "requires" : [ "shift" ] }
+        ],
+        "0" : [
+            { "title" : "0", "requires" : [         ] },
+            { "title" : ")", "requires" : [ "shift" ] }
+        ],
+        "-" : [
+            { "title" : "-", "requires" : [         ] },
+            { "title" : "_", "requires" : [ "shift" ] }
+        ],
+        "=" : [
+            { "title" : "=", "requires" : [         ] },
+            { "title" : "+", "requires" : [ "shift" ] }
+        ],
+        "б" : [
+            { "title" : "б", "requires" : [                 ] },
+            { "title" : "Б", "requires" : [ "caps"          ] },
+            { "title" : "Б", "requires" : [ "shift"         ] },
+            { "title" : "б", "requires" : [ "caps", "shift" ] }
+        ],
+        "ю" : [
+            { "title" : "ю", "requires" : [                 ] },
+            { "title" : "Ю", "requires" : [ "caps"          ] },
+            { "title" : "Ю", "requires" : [ "shift"         ] },
+            { "title" : "ю", "requires" : [ "caps", "shift" ] }
+        ],
+        "/" : [
+            { "title" : "/", "requires" : [         ] },
+            { "title" : "?", "requires" : [ "shift" ] }
+        ],
+        "х" : [
+            { "title" : "х", "requires" : [                 ] },
+            { "title" : "Х", "requires" : [ "caps"          ] },
+            { "title" : "Х", "requires" : [ "shift"         ] },
+            { "title" : "х", "requires" : [ "caps", "shift" ] }
+        ],
+        "ъ" : [
+            { "title" : "ъ", "requires" : [                 ] },
+            { "title" : "Ъ", "requires" : [ "caps"          ] },
+            { "title" : "Ъ", "requires" : [ "shift"         ] },
+            { "title" : "ъ", "requires" : [ "caps", "shift" ] }
+        ],
+        "\\" : [
+            { "title" : "\\", "requires" : [         ] },
+            { "title" : "|",  "requires" : [ "shift" ] }
+        ],
+        "ж" : [
+            { "title" : "ж", "requires" : [                 ] },
+            { "title" : "Ж", "requires" : [ "caps"          ] },
+            { "title" : "Ж", "requires" : [ "shift"         ] },
+            { "title" : "ж", "requires" : [ "caps", "shift" ] }
+        ],
+        "э" : [
+            { "title" : "э", "requires" : [                 ] },
+            { "title" : "Э", "requires" : [ "caps"          ] },
+            { "title" : "Э", "requires" : [ "shift"         ] },
+            { "title" : "э", "requires" : [ "caps", "shift" ] }
+        ],
+
+        "й" : [
+            { "title" : "й", "requires" : [                 ] },
+            { "title" : "Й", "requires" : [ "caps"          ] },
+            { "title" : "Й", "requires" : [ "shift"         ] },
+            { "title" : "й", "requires" : [ "caps", "shift" ] }
+        ],
+        "ц" : [
+            { "title" : "ц", "requires" : [                 ] },
+            { "title" : "Ц", "requires" : [ "caps"          ] },
+            { "title" : "Ц", "requires" : [ "shift"         ] },
+            { "title" : "ц", "requires" : [ "caps", "shift" ] }
+        ],
+        "у" : [
+            { "title" : "у", "requires" : [                 ] },
+            { "title" : "У", "requires" : [ "caps"          ] },
+            { "title" : "У", "requires" : [ "shift"         ] },
+            { "title" : "у", "requires" : [ "caps", "shift" ] }
+        ],
+        "к" : [
+            { "title" : "к", "requires" : [                 ] },
+            { "title" : "К", "requires" : [ "caps"          ] },
+            { "title" : "К", "requires" : [ "shift"         ] },
+            { "title" : "к", "requires" : [ "caps", "shift" ] }
+        ],
+        "е" : [
+            { "title" : "е", "requires" : [                 ] },
+            { "title" : "Е", "requires" : [ "caps"          ] },
+            { "title" : "Е", "requires" : [ "shift"         ] },
+            { "title" : "е", "requires" : [ "caps", "shift" ] }
+        ],
+        "н" : [
+            { "title" : "н", "requires" : [                 ] },
+            { "title" : "Н", "requires" : [ "caps"          ] },
+            { "title" : "Н", "requires" : [ "shift"         ] },
+            { "title" : "н", "requires" : [ "caps", "shift" ] }
+        ],
+        "г" : [
+            { "title" : "г", "requires" : [                 ] },
+            { "title" : "Г", "requires" : [ "caps"          ] },
+            { "title" : "Г", "requires" : [ "shift"         ] },
+            { "title" : "г", "requires" : [ "caps", "shift" ] }
+        ],
+        "ш" : [
+            { "title" : "ш", "requires" : [                 ] },
+            { "title" : "Ш", "requires" : [ "caps"          ] },
+            { "title" : "Ш", "requires" : [ "shift"         ] },
+            { "title" : "ш", "requires" : [ "caps", "shift" ] }
+        ],
+        "щ" : [
+            { "title" : "щ", "requires" : [                 ] },
+            { "title" : "Щ", "requires" : [ "caps"          ] },
+            { "title" : "Щ", "requires" : [ "shift"         ] },
+            { "title" : "щ", "requires" : [ "caps", "shift" ] }
+        ],
+        "з" : [
+            { "title" : "з", "requires" : [                 ] },
+            { "title" : "З", "requires" : [ "caps"          ] },
+            { "title" : "З", "requires" : [ "shift"         ] },
+            { "title" : "з", "requires" : [ "caps", "shift" ] }
+        ],
+        "ф" : [
+            { "title" : "ф", "requires" : [                 ] },
+            { "title" : "Ф", "requires" : [ "caps"          ] },
+            { "title" : "Ф", "requires" : [ "shift"         ] },
+            { "title" : "ф", "requires" : [ "caps", "shift" ] }
+        ],
+        "ы" : [
+            { "title" : "ы", "requires" : [                 ] },
+            { "title" : "Ы", "requires" : [ "caps"          ] },
+            { "title" : "Ы", "requires" : [ "shift"         ] },
+            { "title" : "ы", "requires" : [ "caps", "shift" ] }
+        ],
+        "в" : [
+            { "title" : "в", "requires" : [                 ] },
+            { "title" : "В", "requires" : [ "caps"          ] },
+            { "title" : "В", "requires" : [ "shift"         ] },
+            { "title" : "в", "requires" : [ "caps", "shift" ] }
+        ],
+        "а" : [
+            { "title" : "а", "requires" : [                 ] },
+            { "title" : "А", "requires" : [ "caps"          ] },
+            { "title" : "А", "requires" : [ "shift"         ] },
+            { "title" : "а", "requires" : [ "caps", "shift" ] }
+        ],
+        "п" : [
+            { "title" : "п", "requires" : [                 ] },
+            { "title" : "П", "requires" : [ "caps"          ] },
+            { "title" : "П", "requires" : [ "shift"         ] },
+            { "title" : "п", "requires" : [ "caps", "shift" ] }
+        ],
+        "р" : [
+            { "title" : "р", "requires" : [                 ] },
+            { "title" : "Р", "requires" : [ "caps"          ] },
+            { "title" : "Р", "requires" : [ "shift"         ] },
+            { "title" : "р", "requires" : [ "caps", "shift" ] }
+        ],
+        "о" : [
+            { "title" : "о", "requires" : [                 ] },
+            { "title" : "О", "requires" : [ "caps"          ] },
+            { "title" : "О", "requires" : [ "shift"         ] },
+            { "title" : "о", "requires" : [ "caps", "shift" ] }
+        ],
+        "л" : [
+            { "title" : "л", "requires" : [                 ] },
+            { "title" : "Л", "requires" : [ "caps"          ] },
+            { "title" : "Л", "requires" : [ "shift"         ] },
+            { "title" : "л", "requires" : [ "caps", "shift" ] }
+        ],
+        "д" : [
+            { "title" : "д", "requires" : [                 ] },
+            { "title" : "Д", "requires" : [ "caps"          ] },
+            { "title" : "Д", "requires" : [ "shift"         ] },
+            { "title" : "д", "requires" : [ "caps", "shift" ] }
+        ],
+        "я" : [
+            { "title" : "я", "requires" : [                 ] },
+            { "title" : "Я", "requires" : [ "caps"          ] },
+            { "title" : "Я", "requires" : [ "shift"         ] },
+            { "title" : "я", "requires" : [ "caps", "shift" ] }
+        ],
+        "ч" : [
+            { "title" : "ч", "requires" : [                 ] },
+            { "title" : "Ч", "requires" : [ "caps"          ] },
+            { "title" : "Ч", "requires" : [ "shift"         ] },
+            { "title" : "ч", "requires" : [ "caps", "shift" ] }
+        ],
+        "с" : [
+            { "title" : "с", "requires" : [                 ] },
+            { "title" : "С", "requires" : [ "caps"          ] },
+            { "title" : "С", "requires" : [ "shift"         ] },
+            { "title" : "с", "requires" : [ "caps", "shift" ] }
+        ],
+        "м" : [
+            { "title" : "м", "requires" : [                 ] },
+            { "title" : "М", "requires" : [ "caps"          ] },
+            { "title" : "М", "requires" : [ "shift"         ] },
+            { "title" : "м", "requires" : [ "caps", "shift" ] }
+        ],
+        "и" : [
+            { "title" : "и", "requires" : [                 ] },
+            { "title" : "И", "requires" : [ "caps"          ] },
+            { "title" : "И", "requires" : [ "shift"         ] },
+            { "title" : "и", "requires" : [ "caps", "shift" ] }
+        ],
+        "т" : [
+            { "title" : "т", "requires" : [                 ] },
+            { "title" : "Т", "requires" : [ "caps"          ] },
+            { "title" : "Т", "requires" : [ "shift"         ] },
+            { "title" : "т", "requires" : [ "caps", "shift" ] }
+        ],
+        "ь" : [
+            { "title" : "ь", "requires" : [                 ] },
+            { "title" : "Ь", "requires" : [ "caps"          ] },
+            { "title" : "Ь", "requires" : [ "shift"         ] },
+            { "title" : "ь", "requires" : [ "caps", "shift" ] }
+        ]
+
+    },
+
+    "layout" : [
+
+        [ "Esc", 0.7, "F1", "F2",  "F3",  "F4",
+                 0.7, "F5", "F6",  "F7",  "F8",
+                 0.7, "F9", "F10", "F11", "F12" ],
+
+        [ 0.1 ],
+
+        {
+            "main" : {
+                "alpha" : [
+
+                    [ "`", "1", "2", "3", "4", "5", "6", "7", "8", "9", "0", "-", "=", "Back" ],
+                    [ "Tab", "й", "ц", "у", "к", "е", "н", "г", "ш", "щ", "з", "х", "ъ", "\\" ],
+                    [ "Caps",  "ф", "ы", "в", "а", "п", "р", "о", "л", "д", "ж", "э", "Enter" ],
+                    [ "LShift", "я", "ч", "с", "м",  "и", "т",  "ь", "б", "ю", "/",  "RShift" ],
+                    [ "LCtrl", "Super", "LAlt",       "Space",        "RAlt", "Menu", "RCtrl" ]
+
+                ],
+
+                "movement" : [
+                    [ "Ins",  "Home", "PgUp"  ],
+                    [ "Del",  "End",  "PgDn"  ],
+                    [           1             ],
+                    [          "Up"           ],
+                    [ "Left", "Down", "Right" ]
+                ]
+            }
+        }
+
+    ],
+
+    "keyWidths" : {
+
+        "Back"   : 2,
+        "Tab"    : 1.5,
+        "\\"     : 1.5,
+        "Caps"   : 1.85,
+        "Enter"  : 2.25,
+        "LShift" : 2.1,
+        "RShift" : 3.1,
+
+        "LCtrl" : 1.6,
+        "Super" : 1.6,
+        "LAlt"  : 1.6,
+        "Space" : 6.1,
+        "RAlt"  : 1.6,
+        "Menu"  : 1.6,
+        "RCtrl" : 1.6,
+
+        "Ins"  : 1.6,
+        "Home" : 1.6,
+        "PgUp" : 1.6,
+        "Del"  : 1.6,
+        "End"  : 1.6,
+        "PgDn" : 1.6
+
+    }
+
+}
diff --git a/guacamole/src/main/webapp/lib/angular-module-shim/LICENSE b/guacamole/src/main/webapp/lib/angular-module-shim/LICENSE
new file mode 100644
index 0000000..806ee6e
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/angular-module-shim/LICENSE
@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (c) 2014 Jed Richards
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
\ No newline at end of file
diff --git a/guacamole/src/main/webapp/lib/angular-module-shim/angular-module-shim.js b/guacamole/src/main/webapp/lib/angular-module-shim/angular-module-shim.js
new file mode 100644
index 0000000..fbd4a7d
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/angular-module-shim/angular-module-shim.js
@@ -0,0 +1,32 @@
+(function(angular) {
+
+    'use strict';
+
+    if ( !angular ) {
+        throw new Error('angular-module-shim: Missing Angular');
+    }
+
+    var origFn = angular.module;
+    var hash = {};
+
+    angular.module = function(name,requires,configFn) {
+
+        var requires = requires || [];
+        var registered = hash[name];
+        var module;
+
+        if ( registered ) {
+            module = origFn(name);
+            module.requires.push.apply(module.requires,requires);
+            // Register the config function if it exists.
+            if (configFn) {
+                module.config(configFn);
+            }
+        } else {
+            hash[name] = true;
+            module = origFn(name,requires,configFn);
+        }
+
+        return module;
+    };
+})(window.angular);
diff --git a/guacamole/src/main/webapp/lib/angular-translate/LICENSE b/guacamole/src/main/webapp/lib/angular-translate/LICENSE
new file mode 100644
index 0000000..f3e753f
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/angular-translate/LICENSE
@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (c) <2014> <pascal.precht at gmail.com>
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/guacamole/src/main/webapp/lib/angular-translate/angular-translate-interpolation-messageformat.js b/guacamole/src/main/webapp/lib/angular-translate/angular-translate-interpolation-messageformat.js
new file mode 100644
index 0000000..3c56e52
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/angular-translate/angular-translate-interpolation-messageformat.js
@@ -0,0 +1,157 @@
+/*!
+ * angular-translate - v2.7.2 - 2015-06-01
+ * http://github.com/angular-translate/angular-translate
+ * Copyright (c) 2015 ; Licensed MIT
+ */
+(function (root, factory) {
+  if (typeof define === 'function' && define.amd) {
+    // AMD. Register as an anonymous module unless amdModuleId is set
+    define([], function () {
+      return (factory());
+    });
+  } else if (typeof exports === 'object') {
+    // Node. Does not work with strict CommonJS, but
+    // only CommonJS-like environments that support module.exports,
+    // like Node.
+    module.exports = factory();
+  } else {
+    factory();
+  }
+}(this, function () {
+
+angular.module('pascalprecht.translate')
+
+/**
+ * @ngdoc property
+ * @name pascalprecht.translate.TRANSLATE_MF_INTERPOLATION_CACHE
+ * @requires TRANSLATE_MF_INTERPOLATION_CACHE
+ *
+ * @description
+ * Uses MessageFormat.js to interpolate strings against some values.
+ */
+.constant('TRANSLATE_MF_INTERPOLATION_CACHE', '$translateMessageFormatInterpolation')
+
+/**
+ * @ngdoc object
+ * @name pascalprecht.translate.$translateMessageFormatInterpolation
+ * @requires pascalprecht.translate.TRANSLATE_MF_INTERPOLATION_CACHE
+ *
+ * @description
+ * Uses MessageFormat.js to interpolate strings against some values.
+ *
+ * Be aware to configure a proper sanitization strategy.
+ *
+ * See also:
+ * * {@link pascalprecht.translate.$translateSanitization}
+ * * {@link https://github.com/SlexAxton/messageformat.js}
+ *
+ * @return {object} $translateMessageFormatInterpolation Interpolator service
+ */
+.factory('$translateMessageFormatInterpolation', $translateMessageFormatInterpolation);
+
+function $translateMessageFormatInterpolation($translateSanitization, $cacheFactory, TRANSLATE_MF_INTERPOLATION_CACHE) {
+
+  'use strict';
+
+  var $translateInterpolator = {},
+      $cache = $cacheFactory.get(TRANSLATE_MF_INTERPOLATION_CACHE),
+      // instantiate with default locale (which is 'en')
+      $mf = new MessageFormat('en'),
+      $identifier = 'messageformat';
+
+  if (!$cache) {
+    // create cache if it doesn't exist already
+    $cache = $cacheFactory(TRANSLATE_MF_INTERPOLATION_CACHE);
+  }
+
+  $cache.put('en', $mf);
+
+  /**
+   * @ngdoc function
+   * @name pascalprecht.translate.$translateMessageFormatInterpolation#setLocale
+   * @methodOf pascalprecht.translate.$translateMessageFormatInterpolation
+   *
+   * @description
+   * Sets current locale (this is currently not use in this interpolation).
+   *
+   * @param {string} locale Language key or locale.
+   */
+  $translateInterpolator.setLocale = function (locale) {
+    $mf = $cache.get(locale);
+    if (!$mf) {
+      $mf = new MessageFormat(locale);
+      $cache.put(locale, $mf);
+    }
+  };
+
+  /**
+   * @ngdoc function
+   * @name pascalprecht.translate.$translateMessageFormatInterpolation#getInterpolationIdentifier
+   * @methodOf pascalprecht.translate.$translateMessageFormatInterpolation
+   *
+   * @description
+   * Returns an identifier for this interpolation service.
+   *
+   * @returns {string} $identifier
+   */
+  $translateInterpolator.getInterpolationIdentifier = function () {
+    return $identifier;
+  };
+
+  /**
+   * @deprecated will be removed in 3.0
+   * @see {@link pascalprecht.translate.$translateSanitization}
+   */
+  $translateInterpolator.useSanitizeValueStrategy = function (value) {
+    $translateSanitization.useStrategy(value);
+    return this;
+  };
+
+  /**
+   * @ngdoc function
+   * @name pascalprecht.translate.$translateMessageFormatInterpolation#interpolate
+   * @methodOf pascalprecht.translate.$translateMessageFormatInterpolation
+   *
+   * @description
+   * Interpolates given string agains given interpolate params using MessageFormat.js.
+   *
+   * @returns {string} interpolated string.
+   */
+  $translateInterpolator.interpolate = function (string, interpolationParams) {
+    interpolationParams = interpolationParams || {};
+    interpolationParams = $translateSanitization.sanitize(interpolationParams, 'params');
+
+    var interpolatedText = $cache.get(string + angular.toJson(interpolationParams));
+
+    // if given string wasn't interpolated yet, we do so now and never have to do it again
+    if (!interpolatedText) {
+
+      // Ensure explicit type if possible
+      // MessageFormat checks the actual type (i.e. for amount based conditions)
+      for (var key in interpolationParams) {
+        if (interpolationParams.hasOwnProperty(key)) {
+          // ensure number
+          var number = parseInt(interpolationParams[key], 10);
+          if (angular.isNumber(number) && ('' + number) === interpolationParams[key]) {
+            interpolationParams[key] = number;
+          }
+        }
+      }
+
+      interpolatedText = $mf.compile(string)(interpolationParams);
+      interpolatedText = $translateSanitization.sanitize(interpolatedText, 'text');
+
+      $cache.put(string + angular.toJson(interpolationParams), interpolatedText);
+    }
+
+    return interpolatedText;
+  };
+
+  return $translateInterpolator;
+}
+$translateMessageFormatInterpolation.$inject = ['$translateSanitization', '$cacheFactory', 'TRANSLATE_MF_INTERPOLATION_CACHE'];
+
+$translateMessageFormatInterpolation.displayName = '$translateMessageFormatInterpolation';
+return 'pascalprecht.translate';
+
+}));
diff --git a/guacamole/src/main/webapp/lib/angular-translate/angular-translate-loader-static-files.js b/guacamole/src/main/webapp/lib/angular-translate/angular-translate-loader-static-files.js
new file mode 100644
index 0000000..fa12f69
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/angular-translate/angular-translate-loader-static-files.js
@@ -0,0 +1,114 @@
+/*!
+ * angular-translate - v2.7.2 - 2015-06-01
+ * http://github.com/angular-translate/angular-translate
+ * Copyright (c) 2015 ; Licensed MIT
+ */
+(function (root, factory) {
+  if (typeof define === 'function' && define.amd) {
+    // AMD. Register as an anonymous module unless amdModuleId is set
+    define([], function () {
+      return (factory());
+    });
+  } else if (typeof exports === 'object') {
+    // Node. Does not work with strict CommonJS, but
+    // only CommonJS-like environments that support module.exports,
+    // like Node.
+    module.exports = factory();
+  } else {
+    factory();
+  }
+}(this, function () {
+
+angular.module('pascalprecht.translate')
+/**
+ * @ngdoc object
+ * @name pascalprecht.translate.$translateStaticFilesLoader
+ * @requires $q
+ * @requires $http
+ *
+ * @description
+ * Creates a loading function for a typical static file url pattern:
+ * "lang-en_US.json", "lang-de_DE.json", etc. Using this builder,
+ * the response of these urls must be an object of key-value pairs.
+ *
+ * @param {object} options Options object, which gets prefix, suffix and key.
+ */
+.factory('$translateStaticFilesLoader', $translateStaticFilesLoader);
+
+function $translateStaticFilesLoader($q, $http) {
+
+  'use strict';
+
+  return function (options) {
+
+    if (!options || (!angular.isArray(options.files) && (!angular.isString(options.prefix) || !angular.isString(options.suffix)))) {
+      throw new Error('Couldn\'t load static files, no files and prefix or suffix specified!');
+    }
+
+    if (!options.files) {
+      options.files = [{
+        prefix: options.prefix,
+        suffix: options.suffix
+      }];
+    }
+
+    var load = function (file) {
+      if (!file || (!angular.isString(file.prefix) || !angular.isString(file.suffix))) {
+        throw new Error('Couldn\'t load static file, no prefix or suffix specified!');
+      }
+
+      var deferred = $q.defer();
+
+      $http(angular.extend({
+        url: [
+          file.prefix,
+          options.key,
+          file.suffix
+        ].join(''),
+        method: 'GET',
+        params: ''
+      }, options.$http)).success(function (data) {
+        deferred.resolve(data);
+      }).error(function () {
+        deferred.reject(options.key);
+      });
+
+      return deferred.promise;
+    };
+
+    var deferred = $q.defer(),
+        promises = [],
+        length = options.files.length;
+
+    for (var i = 0; i < length; i++) {
+      promises.push(load({
+        prefix: options.files[i].prefix,
+        key: options.key,
+        suffix: options.files[i].suffix
+      }));
+    }
+
+    $q.all(promises).then(function (data) {
+      var length = data.length,
+          mergedData = {};
+
+      for (var i = 0; i < length; i++) {
+        for (var key in data[i]) {
+          mergedData[key] = data[i][key];
+        }
+      }
+
+      deferred.resolve(mergedData);
+    }, function (data) {
+      deferred.reject(data);
+    });
+
+    return deferred.promise;
+  };
+}
+$translateStaticFilesLoader.$inject = ['$q', '$http'];
+
+$translateStaticFilesLoader.displayName = '$translateStaticFilesLoader';
+return 'pascalprecht.translate';
+
+}));
diff --git a/guacamole/src/main/webapp/lib/angular-translate/angular-translate.js b/guacamole/src/main/webapp/lib/angular-translate/angular-translate.js
new file mode 100644
index 0000000..e7183a0
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/angular-translate/angular-translate.js
@@ -0,0 +1,2904 @@
+/*!
+ * angular-translate - v2.7.2 - 2015-06-01
+ * http://github.com/angular-translate/angular-translate
+ * Copyright (c) 2015 ; Licensed MIT
+ */
+(function (root, factory) {
+  if (typeof define === 'function' && define.amd) {
+    // AMD. Register as an anonymous module unless amdModuleId is set
+    define([], function () {
+      return (factory());
+    });
+  } else if (typeof exports === 'object') {
+    // Node. Does not work with strict CommonJS, but
+    // only CommonJS-like environments that support module.exports,
+    // like Node.
+    module.exports = factory();
+  } else {
+    factory();
+  }
+}(this, function () {
+
+/**
+ * @ngdoc overview
+ * @name pascalprecht.translate
+ *
+ * @description
+ * The main module which holds everything together.
+ */
+angular.module('pascalprecht.translate', ['ng'])
+  .run(runTranslate);
+
+function runTranslate($translate) {
+
+  'use strict';
+
+  var key = $translate.storageKey(),
+    storage = $translate.storage();
+
+  var fallbackFromIncorrectStorageValue = function () {
+    var preferred = $translate.preferredLanguage();
+    if (angular.isString(preferred)) {
+      $translate.use(preferred);
+      // $translate.use() will also remember the language.
+      // So, we don't need to call storage.put() here.
+    } else {
+      storage.put(key, $translate.use());
+    }
+  };
+
+  fallbackFromIncorrectStorageValue.displayName = 'fallbackFromIncorrectStorageValue';
+
+  if (storage) {
+    if (!storage.get(key)) {
+      fallbackFromIncorrectStorageValue();
+    } else {
+      $translate.use(storage.get(key))['catch'](fallbackFromIncorrectStorageValue);
+    }
+  } else if (angular.isString($translate.preferredLanguage())) {
+    $translate.use($translate.preferredLanguage());
+  }
+}
+runTranslate.$inject = ['$translate'];
+
+runTranslate.displayName = 'runTranslate';
+
+/**
+ * @ngdoc object
+ * @name pascalprecht.translate.$translateSanitizationProvider
+ *
+ * @description
+ *
+ * Configurations for $translateSanitization
+ */
+angular.module('pascalprecht.translate').provider('$translateSanitization', $translateSanitizationProvider);
+
+function $translateSanitizationProvider () {
+
+  'use strict';
+
+  var $sanitize,
+      currentStrategy = null, // TODO change to either 'sanitize', 'escape' or ['sanitize', 'escapeParameters'] in 3.0.
+      hasConfiguredStrategy = false,
+      hasShownNoStrategyConfiguredWarning = false,
+      strategies;
+
+  /**
+   * Definition of a sanitization strategy function
+   * @callback StrategyFunction
+   * @param {string|object} value - value to be sanitized (either a string or an interpolated value map)
+   * @param {string} mode - either 'text' for a string (translation) or 'params' for the interpolated params
+   * @return {string|object}
+   */
+
+  /**
+   * @ngdoc property
+   * @name strategies
+   * @propertyOf pascalprecht.translate.$translateSanitizationProvider
+   *
+   * @description
+   * Following strategies are built-in:
+   * <dl>
+   *   <dt>sanitize</dt>
+   *   <dd>Sanitizes HTML in the translation text using $sanitize</dd>
+   *   <dt>escape</dt>
+   *   <dd>Escapes HTML in the translation</dd>
+   *   <dt>sanitizeParameters</dt>
+   *   <dd>Sanitizes HTML in the values of the interpolation parameters using $sanitize</dd>
+   *   <dt>escapeParameters</dt>
+   *   <dd>Escapes HTML in the values of the interpolation parameters</dd>
+   *   <dt>escaped</dt>
+   *   <dd>Support legacy strategy name 'escaped' for backwards compatibility (will be removed in 3.0)</dd>
+   * </dl>
+   *
+   */
+
+  strategies = {
+    sanitize: function (value, mode) {
+      if (mode === 'text') {
+        value = htmlSanitizeValue(value);
+      }
+      return value;
+    },
+    escape: function (value, mode) {
+      if (mode === 'text') {
+        value = htmlEscapeValue(value);
+      }
+      return value;
+    },
+    sanitizeParameters: function (value, mode) {
+      if (mode === 'params') {
+        value = mapInterpolationParameters(value, htmlSanitizeValue);
+      }
+      return value;
+    },
+    escapeParameters: function (value, mode) {
+      if (mode === 'params') {
+        value = mapInterpolationParameters(value, htmlEscapeValue);
+      }
+      return value;
+    }
+  };
+  // Support legacy strategy name 'escaped' for backwards compatibility.
+  // TODO should be removed in 3.0
+  strategies.escaped = strategies.escapeParameters;
+
+  /**
+   * @ngdoc function
+   * @name pascalprecht.translate.$translateSanitizationProvider#addStrategy
+   * @methodOf pascalprecht.translate.$translateSanitizationProvider
+   *
+   * @description
+   * Adds a sanitization strategy to the list of known strategies.
+   *
+   * @param {string} strategyName - unique key for a strategy
+   * @param {StrategyFunction} strategyFunction - strategy function
+   * @returns {object} this
+   */
+  this.addStrategy = function (strategyName, strategyFunction) {
+    strategies[strategyName] = strategyFunction;
+    return this;
+  };
+
+  /**
+   * @ngdoc function
+   * @name pascalprecht.translate.$translateSanitizationProvider#removeStrategy
+   * @methodOf pascalprecht.translate.$translateSanitizationProvider
+   *
+   * @description
+   * Removes a sanitization strategy from the list of known strategies.
+   *
+   * @param {string} strategyName - unique key for a strategy
+   * @returns {object} this
+   */
+  this.removeStrategy = function (strategyName) {
+    delete strategies[strategyName];
+    return this;
+  };
+
+  /**
+   * @ngdoc function
+   * @name pascalprecht.translate.$translateSanitizationProvider#useStrategy
+   * @methodOf pascalprecht.translate.$translateSanitizationProvider
+   *
+   * @description
+   * Selects a sanitization strategy. When an array is provided the strategies will be executed in order.
+   *
+   * @param {string|StrategyFunction|array} strategy The sanitization strategy / strategies which should be used. Either a name of an existing strategy, a custom strategy function, or an array consisting of multiple names and / or custom functions.
+   * @returns {object} this
+   */
+  this.useStrategy = function (strategy) {
+    hasConfiguredStrategy = true;
+    currentStrategy = strategy;
+    return this;
+  };
+
+  /**
+   * @ngdoc object
+   * @name pascalprecht.translate.$translateSanitization
+   * @requires $injector
+   * @requires $log
+   *
+   * @description
+   * Sanitizes interpolation parameters and translated texts.
+   *
+   */
+  this.$get = ['$injector', '$log', function ($injector, $log) {
+
+    var applyStrategies = function (value, mode, selectedStrategies) {
+      angular.forEach(selectedStrategies, function (selectedStrategy) {
+        if (angular.isFunction(selectedStrategy)) {
+          value = selectedStrategy(value, mode);
+        } else if (angular.isFunction(strategies[selectedStrategy])) {
+          value = strategies[selectedStrategy](value, mode);
+        } else {
+          throw new Error('pascalprecht.translate.$translateSanitization: Unknown sanitization strategy: \'' + selectedStrategy + '\'');
+        }
+      });
+      return value;
+    };
+
+    // TODO: should be removed in 3.0
+    var showNoStrategyConfiguredWarning = function () {
+      if (!hasConfiguredStrategy && !hasShownNoStrategyConfiguredWarning) {
+        $log.warn('pascalprecht.translate.$translateSanitization: No sanitization strategy has been configured. This can have serious security implications. See http://angular-translate.github.io/docs/#/guide/19_security for details.');
+        hasShownNoStrategyConfiguredWarning = true;
+      }
+    };
+
+    if ($injector.has('$sanitize')) {
+      $sanitize = $injector.get('$sanitize');
+    }
+
+    return {
+      /**
+       * @ngdoc function
+       * @name pascalprecht.translate.$translateSanitization#useStrategy
+       * @methodOf pascalprecht.translate.$translateSanitization
+       *
+       * @description
+       * Selects a sanitization strategy. When an array is provided the strategies will be executed in order.
+       *
+       * @param {string|StrategyFunction|array} strategy The sanitization strategy / strategies which should be used. Either a name of an existing strategy, a custom strategy function, or an array consisting of multiple names and / or custom functions.
+       */
+      useStrategy: (function (self) {
+        return function (strategy) {
+          self.useStrategy(strategy);
+        };
+      })(this),
+
+      /**
+       * @ngdoc function
+       * @name pascalprecht.translate.$translateSanitization#sanitize
+       * @methodOf pascalprecht.translate.$translateSanitization
+       *
+       * @description
+       * Sanitizes a value.
+       *
+       * @param {string|object} value The value which should be sanitized.
+       * @param {string} mode The current sanitization mode, either 'params' or 'text'.
+       * @param {string|StrategyFunction|array} [strategy] Optional custom strategy which should be used instead of the currently selected strategy.
+       * @returns {string|object} sanitized value
+       */
+      sanitize: function (value, mode, strategy) {
+        if (!currentStrategy) {
+          showNoStrategyConfiguredWarning();
+        }
+
+        if (arguments.length < 3) {
+          strategy = currentStrategy;
+        }
+
+        if (!strategy) {
+          return value;
+        }
+
+        var selectedStrategies = angular.isArray(strategy) ? strategy : [strategy];
+        return applyStrategies(value, mode, selectedStrategies);
+      }
+    };
+  }];
+
+  var htmlEscapeValue = function (value) {
+    var element = angular.element('<div></div>');
+    element.text(value); // not chainable, see #1044
+    return element.html();
+  };
+
+  var htmlSanitizeValue = function (value) {
+    if (!$sanitize) {
+      throw new Error('pascalprecht.translate.$translateSanitization: Error cannot find $sanitize service. Either include the ngSanitize module (https://docs.angularjs.org/api/ngSanitize) or use a sanitization strategy which does not depend on $sanitize, such as \'escape\'.');
+    }
+    return $sanitize(value);
+  };
+
+  var mapInterpolationParameters = function (value, iteratee) {
+    if (angular.isObject(value)) {
+      var result = angular.isArray(value) ? [] : {};
+
+      angular.forEach(value, function (propertyValue, propertyKey) {
+        result[propertyKey] = mapInterpolationParameters(propertyValue, iteratee);
+      });
+
+      return result;
+    } else if (angular.isNumber(value)) {
+      return value;
+    } else {
+      return iteratee(value);
+    }
+  };
+}
+
+/**
+ * @ngdoc object
+ * @name pascalprecht.translate.$translateProvider
+ * @description
+ *
+ * $translateProvider allows developers to register translation-tables, asynchronous loaders
+ * and similar to configure translation behavior directly inside of a module.
+ *
+ */
+angular.module('pascalprecht.translate')
+.constant('pascalprechtTranslateOverrider', {})
+.provider('$translate', $translate);
+
+function $translate($STORAGE_KEY, $windowProvider, $translateSanitizationProvider, pascalprechtTranslateOverrider) {
+
+  'use strict';
+
+  var $translationTable = {},
+      $preferredLanguage,
+      $availableLanguageKeys = [],
+      $languageKeyAliases,
+      $fallbackLanguage,
+      $fallbackWasString,
+      $uses,
+      $nextLang,
+      $storageFactory,
+      $storageKey = $STORAGE_KEY,
+      $storagePrefix,
+      $missingTranslationHandlerFactory,
+      $interpolationFactory,
+      $interpolatorFactories = [],
+      $loaderFactory,
+      $cloakClassName = 'translate-cloak',
+      $loaderOptions,
+      $notFoundIndicatorLeft,
+      $notFoundIndicatorRight,
+      $postCompilingEnabled = false,
+      $forceAsyncReloadEnabled = false,
+      NESTED_OBJECT_DELIMITER = '.',
+      loaderCache,
+      directivePriority = 0,
+      statefulFilter = true,
+      uniformLanguageTagResolver = 'default',
+      languageTagResolver = {
+        'default': function (tag) {
+          return (tag || '').split('-').join('_');
+        },
+        java: function (tag) {
+          var temp = (tag || '').split('-').join('_');
+          var parts = temp.split('_');
+          return parts.length > 1 ? (parts[0].toLowerCase() + '_' + parts[1].toUpperCase()) : temp;
+        },
+        bcp47: function (tag) {
+          var temp = (tag || '').split('_').join('-');
+          var parts = temp.split('-');
+          return parts.length > 1 ? (parts[0].toLowerCase() + '-' + parts[1].toUpperCase()) : temp;
+        }
+      };
+
+  var version = '2.7.2';
+
+  // tries to determine the browsers language
+  var getFirstBrowserLanguage = function () {
+
+    // internal purpose only
+    if (angular.isFunction(pascalprechtTranslateOverrider.getLocale)) {
+      return pascalprechtTranslateOverrider.getLocale();
+    }
+
+    var nav = $windowProvider.$get().navigator,
+        browserLanguagePropertyKeys = ['language', 'browserLanguage', 'systemLanguage', 'userLanguage'],
+        i,
+        language;
+
+    // support for HTML 5.1 "navigator.languages"
+    if (angular.isArray(nav.languages)) {
+      for (i = 0; i < nav.languages.length; i++) {
+        language = nav.languages[i];
+        if (language && language.length) {
+          return language;
+        }
+      }
+    }
+
+    // support for other well known properties in browsers
+    for (i = 0; i < browserLanguagePropertyKeys.length; i++) {
+      language = nav[browserLanguagePropertyKeys[i]];
+      if (language && language.length) {
+        return language;
+      }
+    }
+
+    return null;
+  };
+  getFirstBrowserLanguage.displayName = 'angular-translate/service: getFirstBrowserLanguage';
+
+  // tries to determine the browsers locale
+  var getLocale = function () {
+    var locale = getFirstBrowserLanguage() || '';
+    if (languageTagResolver[uniformLanguageTagResolver]) {
+      locale = languageTagResolver[uniformLanguageTagResolver](locale);
+    }
+    return locale;
+  };
+  getLocale.displayName = 'angular-translate/service: getLocale';
+
+  /**
+   * @name indexOf
+   * @private
+   *
+   * @description
+   * indexOf polyfill. Kinda sorta.
+   *
+   * @param {array} array Array to search in.
+   * @param {string} searchElement Element to search for.
+   *
+   * @returns {int} Index of search element.
+   */
+  var indexOf = function(array, searchElement) {
+    for (var i = 0, len = array.length; i < len; i++) {
+      if (array[i] === searchElement) {
+        return i;
+      }
+    }
+    return -1;
+  };
+
+  /**
+   * @name trim
+   * @private
+   *
+   * @description
+   * trim polyfill
+   *
+   * @returns {string} The string stripped of whitespace from both ends
+   */
+  var trim = function() {
+    return this.toString().replace(/^\s+|\s+$/g, '');
+  };
+
+  var negotiateLocale = function (preferred) {
+
+    var avail = [],
+        locale = angular.lowercase(preferred),
+        i = 0,
+        n = $availableLanguageKeys.length;
+
+    for (; i < n; i++) {
+      avail.push(angular.lowercase($availableLanguageKeys[i]));
+    }
+
+    if (indexOf(avail, locale) > -1) {
+      return preferred;
+    }
+
+    if ($languageKeyAliases) {
+      var alias;
+      for (var langKeyAlias in $languageKeyAliases) {
+        var hasWildcardKey = false;
+        var hasExactKey = Object.prototype.hasOwnProperty.call($languageKeyAliases, langKeyAlias) &&
+          angular.lowercase(langKeyAlias) === angular.lowercase(preferred);
+
+        if (langKeyAlias.slice(-1) === '*') {
+          hasWildcardKey = langKeyAlias.slice(0, -1) === preferred.slice(0, langKeyAlias.length-1);
+        }
+        if (hasExactKey || hasWildcardKey) {
+          alias = $languageKeyAliases[langKeyAlias];
+          if (indexOf(avail, angular.lowercase(alias)) > -1) {
+            return alias;
+          }
+        }
+      }
+    }
+
+    if (preferred) {
+      var parts = preferred.split('_');
+
+      if (parts.length > 1 && indexOf(avail, angular.lowercase(parts[0])) > -1) {
+        return parts[0];
+      }
+    }
+
+    // If everything fails, just return the preferred, unchanged.
+    return preferred;
+  };
+
+  /**
+   * @ngdoc function
+   * @name pascalprecht.translate.$translateProvider#translations
+   * @methodOf pascalprecht.translate.$translateProvider
+   *
+   * @description
+   * Registers a new translation table for specific language key.
+   *
+   * To register a translation table for specific language, pass a defined language
+   * key as first parameter.
+   *
+   * <pre>
+   *  // register translation table for language: 'de_DE'
+   *  $translateProvider.translations('de_DE', {
+   *    'GREETING': 'Hallo Welt!'
+   *  });
+   *
+   *  // register another one
+   *  $translateProvider.translations('en_US', {
+   *    'GREETING': 'Hello world!'
+   *  });
+   * </pre>
+   *
+   * When registering multiple translation tables for for the same language key,
+   * the actual translation table gets extended. This allows you to define module
+   * specific translation which only get added, once a specific module is loaded in
+   * your app.
+   *
+   * Invoking this method with no arguments returns the translation table which was
+   * registered with no language key. Invoking it with a language key returns the
+   * related translation table.
+   *
+   * @param {string} key A language key.
+   * @param {object} translationTable A plain old JavaScript object that represents a translation table.
+   *
+   */
+  var translations = function (langKey, translationTable) {
+
+    if (!langKey && !translationTable) {
+      return $translationTable;
+    }
+
+    if (langKey && !translationTable) {
+      if (angular.isString(langKey)) {
+        return $translationTable[langKey];
+      }
+    } else {
+      if (!angular.isObject($translationTable[langKey])) {
+        $translationTable[langKey] = {};
+      }
+      angular.extend($translationTable[langKey], flatObject(translationTable));
+    }
+    return this;
+  };
+
+  this.translations = translations;
+
+  /**
+   * @ngdoc function
+   * @name pascalprecht.translate.$translateProvider#cloakClassName
+   * @methodOf pascalprecht.translate.$translateProvider
+   *
+   * @description
+   *
+   * Let's you change the class name for `translate-cloak` directive.
+   * Default class name is `translate-cloak`.
+   *
+   * @param {string} name translate-cloak class name
+   */
+  this.cloakClassName = function (name) {
+    if (!name) {
+      return $cloakClassName;
+    }
+    $cloakClassName = name;
+    return this;
+  };
+
+  /**
+   * @name flatObject
+   * @private
+   *
+   * @description
+   * Flats an object. This function is used to flatten given translation data with
+   * namespaces, so they are later accessible via dot notation.
+   */
+  var flatObject = function (data, path, result, prevKey) {
+    var key, keyWithPath, keyWithShortPath, val;
+
+    if (!path) {
+      path = [];
+    }
+    if (!result) {
+      result = {};
+    }
+    for (key in data) {
+      if (!Object.prototype.hasOwnProperty.call(data, key)) {
+        continue;
+      }
+      val = data[key];
+      if (angular.isObject(val)) {
+        flatObject(val, path.concat(key), result, key);
+      } else {
+        keyWithPath = path.length ? ('' + path.join(NESTED_OBJECT_DELIMITER) + NESTED_OBJECT_DELIMITER + key) : key;
+        if(path.length && key === prevKey){
+          // Create shortcut path (foo.bar == foo.bar.bar)
+          keyWithShortPath = '' + path.join(NESTED_OBJECT_DELIMITER);
+          // Link it to original path
+          result[keyWithShortPath] = '@:' + keyWithPath;
+        }
+        result[keyWithPath] = val;
+      }
+    }
+    return result;
+  };
+  flatObject.displayName = 'flatObject';
+
+  /**
+   * @ngdoc function
+   * @name pascalprecht.translate.$translateProvider#addInterpolation
+   * @methodOf pascalprecht.translate.$translateProvider
+   *
+   * @description
+   * Adds interpolation services to angular-translate, so it can manage them.
+   *
+   * @param {object} factory Interpolation service factory
+   */
+  this.addInterpolation = function (factory) {
+    $interpolatorFactories.push(factory);
+    return this;
+  };
+
+  /**
+   * @ngdoc function
+   * @name pascalprecht.translate.$translateProvider#useMessageFormatInterpolation
+   * @methodOf pascalprecht.translate.$translateProvider
+   *
+   * @description
+   * Tells angular-translate to use interpolation functionality of messageformat.js.
+   * This is useful when having high level pluralization and gender selection.
+   */
+  this.useMessageFormatInterpolation = function () {
+    return this.useInterpolation('$translateMessageFormatInterpolation');
+  };
+
+  /**
+   * @ngdoc function
+   * @name pascalprecht.translate.$translateProvider#useInterpolation
+   * @methodOf pascalprecht.translate.$translateProvider
+   *
+   * @description
+   * Tells angular-translate which interpolation style to use as default, application-wide.
+   * Simply pass a factory/service name. The interpolation service has to implement
+   * the correct interface.
+   *
+   * @param {string} factory Interpolation service name.
+   */
+  this.useInterpolation = function (factory) {
+    $interpolationFactory = factory;
+    return this;
+  };
+
+  /**
+   * @ngdoc function
+   * @name pascalprecht.translate.$translateProvider#useSanitizeStrategy
+   * @methodOf pascalprecht.translate.$translateProvider
+   *
+   * @description
+   * Simply sets a sanitation strategy type.
+   *
+   * @param {string} value Strategy type.
+   */
+  this.useSanitizeValueStrategy = function (value) {
+    $translateSanitizationProvider.useStrategy(value);
+    return this;
+  };
+
+ /**
+   * @ngdoc function
+   * @name pascalprecht.translate.$translateProvider#preferredLanguage
+   * @methodOf pascalprecht.translate.$translateProvider
+   *
+   * @description
+   * Tells the module which of the registered translation tables to use for translation
+   * at initial startup by passing a language key. Similar to `$translateProvider#use`
+   * only that it says which language to **prefer**.
+   *
+   * @param {string} langKey A language key.
+   *
+   */
+  this.preferredLanguage = function(langKey) {
+    setupPreferredLanguage(langKey);
+    return this;
+
+  };
+  var setupPreferredLanguage = function (langKey) {
+    if (langKey) {
+      $preferredLanguage = langKey;
+    }
+    return $preferredLanguage;
+  };
+  /**
+   * @ngdoc function
+   * @name pascalprecht.translate.$translateProvider#translationNotFoundIndicator
+   * @methodOf pascalprecht.translate.$translateProvider
+   *
+   * @description
+   * Sets an indicator which is used when a translation isn't found. E.g. when
+   * setting the indicator as 'X' and one tries to translate a translation id
+   * called `NOT_FOUND`, this will result in `X NOT_FOUND X`.
+   *
+   * Internally this methods sets a left indicator and a right indicator using
+   * `$translateProvider.translationNotFoundIndicatorLeft()` and
+   * `$translateProvider.translationNotFoundIndicatorRight()`.
+   *
+   * **Note**: These methods automatically add a whitespace between the indicators
+   * and the translation id.
+   *
+   * @param {string} indicator An indicator, could be any string.
+   */
+  this.translationNotFoundIndicator = function (indicator) {
+    this.translationNotFoundIndicatorLeft(indicator);
+    this.translationNotFoundIndicatorRight(indicator);
+    return this;
+  };
+
+  /**
+   * ngdoc function
+   * @name pascalprecht.translate.$translateProvider#translationNotFoundIndicatorLeft
+   * @methodOf pascalprecht.translate.$translateProvider
+   *
+   * @description
+   * Sets an indicator which is used when a translation isn't found left to the
+   * translation id.
+   *
+   * @param {string} indicator An indicator.
+   */
+  this.translationNotFoundIndicatorLeft = function (indicator) {
+    if (!indicator) {
+      return $notFoundIndicatorLeft;
+    }
+    $notFoundIndicatorLeft = indicator;
+    return this;
+  };
+
+  /**
+   * ngdoc function
+   * @name pascalprecht.translate.$translateProvider#translationNotFoundIndicatorLeft
+   * @methodOf pascalprecht.translate.$translateProvider
+   *
+   * @description
+   * Sets an indicator which is used when a translation isn't found right to the
+   * translation id.
+   *
+   * @param {string} indicator An indicator.
+   */
+  this.translationNotFoundIndicatorRight = function (indicator) {
+    if (!indicator) {
+      return $notFoundIndicatorRight;
+    }
+    $notFoundIndicatorRight = indicator;
+    return this;
+  };
+
+  /**
+   * @ngdoc function
+   * @name pascalprecht.translate.$translateProvider#fallbackLanguage
+   * @methodOf pascalprecht.translate.$translateProvider
+   *
+   * @description
+   * Tells the module which of the registered translation tables to use when missing translations
+   * at initial startup by passing a language key. Similar to `$translateProvider#use`
+   * only that it says which language to **fallback**.
+   *
+   * @param {string||array} langKey A language key.
+   *
+   */
+  this.fallbackLanguage = function (langKey) {
+    fallbackStack(langKey);
+    return this;
+  };
+
+  var fallbackStack = function (langKey) {
+    if (langKey) {
+      if (angular.isString(langKey)) {
+        $fallbackWasString = true;
+        $fallbackLanguage = [ langKey ];
+      } else if (angular.isArray(langKey)) {
+        $fallbackWasString = false;
+        $fallbackLanguage = langKey;
+      }
+      if (angular.isString($preferredLanguage)  && indexOf($fallbackLanguage, $preferredLanguage) < 0) {
+        $fallbackLanguage.push($preferredLanguage);
+      }
+
+      return this;
+    } else {
+      if ($fallbackWasString) {
+        return $fallbackLanguage[0];
+      } else {
+        return $fallbackLanguage;
+      }
+    }
+  };
+
+  /**
+   * @ngdoc function
+   * @name pascalprecht.translate.$translateProvider#use
+   * @methodOf pascalprecht.translate.$translateProvider
+   *
+   * @description
+   * Set which translation table to use for translation by given language key. When
+   * trying to 'use' a language which isn't provided, it'll throw an error.
+   *
+   * You actually don't have to use this method since `$translateProvider#preferredLanguage`
+   * does the job too.
+   *
+   * @param {string} langKey A language key.
+   */
+  this.use = function (langKey) {
+    if (langKey) {
+      if (!$translationTable[langKey] && (!$loaderFactory)) {
+        // only throw an error, when not loading translation data asynchronously
+        throw new Error('$translateProvider couldn\'t find translationTable for langKey: \'' + langKey + '\'');
+      }
+      $uses = langKey;
+      return this;
+    }
+    return $uses;
+  };
+
+ /**
+   * @ngdoc function
+   * @name pascalprecht.translate.$translateProvider#storageKey
+   * @methodOf pascalprecht.translate.$translateProvider
+   *
+   * @description
+   * Tells the module which key must represent the choosed language by a user in the storage.
+   *
+   * @param {string} key A key for the storage.
+   */
+  var storageKey = function(key) {
+    if (!key) {
+      if ($storagePrefix) {
+        return $storagePrefix + $storageKey;
+      }
+      return $storageKey;
+    }
+    $storageKey = key;
+    return this;
+  };
+
+  this.storageKey = storageKey;
+
+  /**
+   * @ngdoc function
+   * @name pascalprecht.translate.$translateProvider#useUrlLoader
+   * @methodOf pascalprecht.translate.$translateProvider
+   *
+   * @description
+   * Tells angular-translate to use `$translateUrlLoader` extension service as loader.
+   *
+   * @param {string} url Url
+   * @param {Object=} options Optional configuration object
+   */
+  this.useUrlLoader = function (url, options) {
+    return this.useLoader('$translateUrlLoader', angular.extend({ url: url }, options));
+  };
+
+  /**
+   * @ngdoc function
+   * @name pascalprecht.translate.$translateProvider#useStaticFilesLoader
+   * @methodOf pascalprecht.translate.$translateProvider
+   *
+   * @description
+   * Tells angular-translate to use `$translateStaticFilesLoader` extension service as loader.
+   *
+   * @param {Object=} options Optional configuration object
+   */
+  this.useStaticFilesLoader = function (options) {
+    return this.useLoader('$translateStaticFilesLoader', options);
+  };
+
+  /**
+   * @ngdoc function
+   * @name pascalprecht.translate.$translateProvider#useLoader
+   * @methodOf pascalprecht.translate.$translateProvider
+   *
+   * @description
+   * Tells angular-translate to use any other service as loader.
+   *
+   * @param {string} loaderFactory Factory name to use
+   * @param {Object=} options Optional configuration object
+   */
+  this.useLoader = function (loaderFactory, options) {
+    $loaderFactory = loaderFactory;
+    $loaderOptions = options || {};
+    return this;
+  };
+
+  /**
+   * @ngdoc function
+   * @name pascalprecht.translate.$translateProvider#useLocalStorage
+   * @methodOf pascalprecht.translate.$translateProvider
+   *
+   * @description
+   * Tells angular-translate to use `$translateLocalStorage` service as storage layer.
+   *
+   */
+  this.useLocalStorage = function () {
+    return this.useStorage('$translateLocalStorage');
+  };
+
+  /**
+   * @ngdoc function
+   * @name pascalprecht.translate.$translateProvider#useCookieStorage
+   * @methodOf pascalprecht.translate.$translateProvider
+   *
+   * @description
+   * Tells angular-translate to use `$translateCookieStorage` service as storage layer.
+   */
+  this.useCookieStorage = function () {
+    return this.useStorage('$translateCookieStorage');
+  };
+
+  /**
+   * @ngdoc function
+   * @name pascalprecht.translate.$translateProvider#useStorage
+   * @methodOf pascalprecht.translate.$translateProvider
+   *
+   * @description
+   * Tells angular-translate to use custom service as storage layer.
+   */
+  this.useStorage = function (storageFactory) {
+    $storageFactory = storageFactory;
+    return this;
+  };
+
+  /**
+   * @ngdoc function
+   * @name pascalprecht.translate.$translateProvider#storagePrefix
+   * @methodOf pascalprecht.translate.$translateProvider
+   *
+   * @description
+   * Sets prefix for storage key.
+   *
+   * @param {string} prefix Storage key prefix
+   */
+  this.storagePrefix = function (prefix) {
+    if (!prefix) {
+      return prefix;
+    }
+    $storagePrefix = prefix;
+    return this;
+  };
+
+  /**
+   * @ngdoc function
+   * @name pascalprecht.translate.$translateProvider#useMissingTranslationHandlerLog
+   * @methodOf pascalprecht.translate.$translateProvider
+   *
+   * @description
+   * Tells angular-translate to use built-in log handler when trying to translate
+   * a translation Id which doesn't exist.
+   *
+   * This is actually a shortcut method for `useMissingTranslationHandler()`.
+   *
+   */
+  this.useMissingTranslationHandlerLog = function () {
+    return this.useMissingTranslationHandler('$translateMissingTranslationHandlerLog');
+  };
+
+  /**
+   * @ngdoc function
+   * @name pascalprecht.translate.$translateProvider#useMissingTranslationHandler
+   * @methodOf pascalprecht.translate.$translateProvider
+   *
+   * @description
+   * Expects a factory name which later gets instantiated with `$injector`.
+   * This method can be used to tell angular-translate to use a custom
+   * missingTranslationHandler. Just build a factory which returns a function
+   * and expects a translation id as argument.
+   *
+   * Example:
+   * <pre>
+   *  app.config(function ($translateProvider) {
+   *    $translateProvider.useMissingTranslationHandler('customHandler');
+   *  });
+   *
+   *  app.factory('customHandler', function (dep1, dep2) {
+   *    return function (translationId) {
+   *      // something with translationId and dep1 and dep2
+   *    };
+   *  });
+   * </pre>
+   *
+   * @param {string} factory Factory name
+   */
+  this.useMissingTranslationHandler = function (factory) {
+    $missingTranslationHandlerFactory = factory;
+    return this;
+  };
+
+  /**
+   * @ngdoc function
+   * @name pascalprecht.translate.$translateProvider#usePostCompiling
+   * @methodOf pascalprecht.translate.$translateProvider
+   *
+   * @description
+   * If post compiling is enabled, all translated values will be processed
+   * again with AngularJS' $compile.
+   *
+   * Example:
+   * <pre>
+   *  app.config(function ($translateProvider) {
+   *    $translateProvider.usePostCompiling(true);
+   *  });
+   * </pre>
+   *
+   * @param {string} factory Factory name
+   */
+  this.usePostCompiling = function (value) {
+    $postCompilingEnabled = !(!value);
+    return this;
+  };
+
+  /**
+   * @ngdoc function
+   * @name pascalprecht.translate.$translateProvider#forceAsyncReload
+   * @methodOf pascalprecht.translate.$translateProvider
+   *
+   * @description
+   * If force async reload is enabled, async loader will always be called
+   * even if $translationTable already contains the language key, adding
+   * possible new entries to the $translationTable.
+   *
+   * Example:
+   * <pre>
+   *  app.config(function ($translateProvider) {
+   *    $translateProvider.forceAsyncReload(true);
+   *  });
+   * </pre>
+   *
+   * @param {boolean} value - valid values are true or false
+   */
+  this.forceAsyncReload = function (value) {
+    $forceAsyncReloadEnabled = !(!value);
+    return this;
+  };
+
+  /**
+   * @ngdoc function
+   * @name pascalprecht.translate.$translateProvider#uniformLanguageTag
+   * @methodOf pascalprecht.translate.$translateProvider
+   *
+   * @description
+   * Tells angular-translate which language tag should be used as a result when determining
+   * the current browser language.
+   *
+   * This setting must be set before invoking {@link pascalprecht.translate.$translateProvider#methods_determinePreferredLanguage determinePreferredLanguage()}.
+   *
+   * <pre>
+   * $translateProvider
+   *   .uniformLanguageTag('bcp47')
+   *   .determinePreferredLanguage()
+   * </pre>
+   *
+   * The resolver currently supports:
+   * * default
+   *     (traditionally: hyphens will be converted into underscores, i.e. en-US => en_US)
+   *     en-US => en_US
+   *     en_US => en_US
+   *     en-us => en_us
+   * * java
+   *     like default, but the second part will be always in uppercase
+   *     en-US => en_US
+   *     en_US => en_US
+   *     en-us => en_US
+   * * BCP 47 (RFC 4646 & 4647)
+   *     en-US => en-US
+   *     en_US => en-US
+   *     en-us => en-US
+   *
+   * See also:
+   * * http://en.wikipedia.org/wiki/IETF_language_tag
+   * * http://www.w3.org/International/core/langtags/
+   * * http://tools.ietf.org/html/bcp47
+   *
+   * @param {string|object} options - options (or standard)
+   * @param {string} options.standard - valid values are 'default', 'bcp47', 'java'
+   */
+  this.uniformLanguageTag = function (options) {
+
+    if (!options) {
+      options = {};
+    } else if (angular.isString(options)) {
+      options = {
+        standard: options
+      };
+    }
+
+    uniformLanguageTagResolver = options.standard;
+
+    return this;
+  };
+
+  /**
+   * @ngdoc function
+   * @name pascalprecht.translate.$translateProvider#determinePreferredLanguage
+   * @methodOf pascalprecht.translate.$translateProvider
+   *
+   * @description
+   * Tells angular-translate to try to determine on its own which language key
+   * to set as preferred language. When `fn` is given, angular-translate uses it
+   * to determine a language key, otherwise it uses the built-in `getLocale()`
+   * method.
+   *
+   * The `getLocale()` returns a language key in the format `[lang]_[country]` or
+   * `[lang]` depending on what the browser provides.
+   *
+   * Use this method at your own risk, since not all browsers return a valid
+   * locale (see {@link pascalprecht.translate.$translateProvider#methods_uniformLanguageTag uniformLanguageTag()}).
+   *
+   * @param {Function=} fn Function to determine a browser's locale
+   */
+  this.determinePreferredLanguage = function (fn) {
+
+    var locale = (fn && angular.isFunction(fn)) ? fn() : getLocale();
+
+    if (!$availableLanguageKeys.length) {
+      $preferredLanguage = locale;
+    } else {
+      $preferredLanguage = negotiateLocale(locale);
+    }
+
+    return this;
+  };
+
+  /**
+   * @ngdoc function
+   * @name pascalprecht.translate.$translateProvider#registerAvailableLanguageKeys
+   * @methodOf pascalprecht.translate.$translateProvider
+   *
+   * @description
+   * Registers a set of language keys the app will work with. Use this method in
+   * combination with
+   * {@link pascalprecht.translate.$translateProvider#determinePreferredLanguage determinePreferredLanguage}.
+   * When available languages keys are registered, angular-translate
+   * tries to find the best fitting language key depending on the browsers locale,
+   * considering your language key convention.
+   *
+   * @param {object} languageKeys Array of language keys the your app will use
+   * @param {object=} aliases Alias map.
+   */
+  this.registerAvailableLanguageKeys = function (languageKeys, aliases) {
+    if (languageKeys) {
+      $availableLanguageKeys = languageKeys;
+      if (aliases) {
+        $languageKeyAliases = aliases;
+      }
+      return this;
+    }
+    return $availableLanguageKeys;
+  };
+
+  /**
+   * @ngdoc function
+   * @name pascalprecht.translate.$translateProvider#useLoaderCache
+   * @methodOf pascalprecht.translate.$translateProvider
+   *
+   * @description
+   * Registers a cache for internal $http based loaders.
+   * {@link pascalprecht.translate.$translateProvider#determinePreferredLanguage determinePreferredLanguage}.
+   * When false the cache will be disabled (default). When true or undefined
+   * the cache will be a default (see $cacheFactory). When an object it will
+   * be treat as a cache object itself: the usage is $http({cache: cache})
+   *
+   * @param {object} cache boolean, string or cache-object
+   */
+  this.useLoaderCache = function (cache) {
+    if (cache === false) {
+      // disable cache
+      loaderCache = undefined;
+    } else if (cache === true) {
+      // enable cache using AJS defaults
+      loaderCache = true;
+    } else if (typeof(cache) === 'undefined') {
+      // enable cache using default
+      loaderCache = '$translationCache';
+    } else if (cache) {
+      // enable cache using given one (see $cacheFactory)
+      loaderCache = cache;
+    }
+    return this;
+  };
+
+  /**
+   * @ngdoc function
+   * @name pascalprecht.translate.$translateProvider#directivePriority
+   * @methodOf pascalprecht.translate.$translateProvider
+   *
+   * @description
+   * Sets the default priority of the translate directive. The standard value is `0`.
+   * Calling this function without an argument will return the current value.
+   *
+   * @param {number} priority for the translate-directive
+   */
+  this.directivePriority = function (priority) {
+    if (priority === undefined) {
+      // getter
+      return directivePriority;
+    } else {
+      // setter with chaining
+      directivePriority = priority;
+      return this;
+    }
+  };
+
+  /**
+   * @ngdoc function
+   * @name pascalprecht.translate.$translateProvider#statefulFilter
+   * @methodOf pascalprecht.translate.$translateProvider
+   *
+   * @description
+   * Since AngularJS 1.3, filters which are not stateless (depending at the scope)
+   * have to explicit define this behavior.
+   * Sets whether the translate filter should be stateful or stateless. The standard value is `true`
+   * meaning being stateful.
+   * Calling this function without an argument will return the current value.
+   *
+   * @param {boolean} state - defines the state of the filter
+   */
+  this.statefulFilter = function (state) {
+    if (state === undefined) {
+      // getter
+      return statefulFilter;
+    } else {
+      // setter with chaining
+      statefulFilter = state;
+      return this;
+    }
+  };
+
+  /**
+   * @ngdoc object
+   * @name pascalprecht.translate.$translate
+   * @requires $interpolate
+   * @requires $log
+   * @requires $rootScope
+   * @requires $q
+   *
+   * @description
+   * The `$translate` service is the actual core of angular-translate. It expects a translation id
+   * and optional interpolate parameters to translate contents.
+   *
+   * <pre>
+   *  $translate('HEADLINE_TEXT').then(function (translation) {
+   *    $scope.translatedText = translation;
+   *  });
+   * </pre>
+   *
+   * @param {string|array} translationId A token which represents a translation id
+   *                                     This can be optionally an array of translation ids which
+   *                                     results that the function returns an object where each key
+   *                                     is the translation id and the value the translation.
+   * @param {object=} interpolateParams An object hash for dynamic values
+   * @param {string} interpolationId The id of the interpolation to use
+   * @returns {object} promise
+   */
+  this.$get = [
+    '$log',
+    '$injector',
+    '$rootScope',
+    '$q',
+    function ($log, $injector, $rootScope, $q) {
+
+      var Storage,
+          defaultInterpolator = $injector.get($interpolationFactory || '$translateDefaultInterpolation'),
+          pendingLoader = false,
+          interpolatorHashMap = {},
+          langPromises = {},
+          fallbackIndex,
+          startFallbackIteration;
+
+      var $translate = function (translationId, interpolateParams, interpolationId, defaultTranslationText) {
+
+        // Duck detection: If the first argument is an array, a bunch of translations was requested.
+        // The result is an object.
+        if (angular.isArray(translationId)) {
+          // Inspired by Q.allSettled by Kris Kowal
+          // https://github.com/kriskowal/q/blob/b0fa72980717dc202ffc3cbf03b936e10ebbb9d7/q.js#L1553-1563
+          // This transforms all promises regardless resolved or rejected
+          var translateAll = function (translationIds) {
+            var results = {}; // storing the actual results
+            var promises = []; // promises to wait for
+            // Wraps the promise a) being always resolved and b) storing the link id->value
+            var translate = function (translationId) {
+              var deferred = $q.defer();
+              var regardless = function (value) {
+                results[translationId] = value;
+                deferred.resolve([translationId, value]);
+              };
+              // we don't care whether the promise was resolved or rejected; just store the values
+              $translate(translationId, interpolateParams, interpolationId, defaultTranslationText).then(regardless, regardless);
+              return deferred.promise;
+            };
+            for (var i = 0, c = translationIds.length; i < c; i++) {
+              promises.push(translate(translationIds[i]));
+            }
+            // wait for all (including storing to results)
+            return $q.all(promises).then(function () {
+              // return the results
+              return results;
+            });
+          };
+          return translateAll(translationId);
+        }
+
+        var deferred = $q.defer();
+
+        // trim off any whitespace
+        if (translationId) {
+          translationId = trim.apply(translationId);
+        }
+
+        var promiseToWaitFor = (function () {
+          var promise = $preferredLanguage ?
+            langPromises[$preferredLanguage] :
+            langPromises[$uses];
+
+          fallbackIndex = 0;
+
+          if ($storageFactory && !promise) {
+            // looks like there's no pending promise for $preferredLanguage or
+            // $uses. Maybe there's one pending for a language that comes from
+            // storage.
+            var langKey = Storage.get($storageKey);
+            promise = langPromises[langKey];
+
+            if ($fallbackLanguage && $fallbackLanguage.length) {
+                var index = indexOf($fallbackLanguage, langKey);
+                // maybe the language from storage is also defined as fallback language
+                // we increase the fallback language index to not search in that language
+                // as fallback, since it's probably the first used language
+                // in that case the index starts after the first element
+                fallbackIndex = (index === 0) ? 1 : 0;
+
+                // but we can make sure to ALWAYS fallback to preferred language at least
+                if (indexOf($fallbackLanguage, $preferredLanguage) < 0) {
+                  $fallbackLanguage.push($preferredLanguage);
+                }
+            }
+          }
+          return promise;
+        }());
+
+        if (!promiseToWaitFor) {
+          // no promise to wait for? okay. Then there's no loader registered
+          // nor is a one pending for language that comes from storage.
+          // We can just translate.
+          determineTranslation(translationId, interpolateParams, interpolationId, defaultTranslationText).then(deferred.resolve, deferred.reject);
+        } else {
+          var promiseResolved = function () {
+            determineTranslation(translationId, interpolateParams, interpolationId, defaultTranslationText).then(deferred.resolve, deferred.reject);
+          };
+          promiseResolved.displayName = 'promiseResolved';
+
+          promiseToWaitFor['finally'](promiseResolved, deferred.reject);
+        }
+        return deferred.promise;
+      };
+
+      /**
+       * @name applyNotFoundIndicators
+       * @private
+       *
+       * @description
+       * Applies not fount indicators to given translation id, if needed.
+       * This function gets only executed, if a translation id doesn't exist,
+       * which is why a translation id is expected as argument.
+       *
+       * @param {string} translationId Translation id.
+       * @returns {string} Same as given translation id but applied with not found
+       * indicators.
+       */
+      var applyNotFoundIndicators = function (translationId) {
+        // applying notFoundIndicators
+        if ($notFoundIndicatorLeft) {
+          translationId = [$notFoundIndicatorLeft, translationId].join(' ');
+        }
+        if ($notFoundIndicatorRight) {
+          translationId = [translationId, $notFoundIndicatorRight].join(' ');
+        }
+        return translationId;
+      };
+
+      /**
+       * @name useLanguage
+       * @private
+       *
+       * @description
+       * Makes actual use of a language by setting a given language key as used
+       * language and informs registered interpolators to also use the given
+       * key as locale.
+       *
+       * @param {key} Locale key.
+       */
+      var useLanguage = function (key) {
+        $uses = key;
+        $rootScope.$emit('$translateChangeSuccess', {language: key});
+
+        if ($storageFactory) {
+          Storage.put($translate.storageKey(), $uses);
+        }
+        // inform default interpolator
+        defaultInterpolator.setLocale($uses);
+
+        var eachInterpolator = function (interpolator, id) {
+          interpolatorHashMap[id].setLocale($uses);
+        };
+        eachInterpolator.displayName = 'eachInterpolatorLocaleSetter';
+
+        // inform all others too!
+        angular.forEach(interpolatorHashMap, eachInterpolator);
+        $rootScope.$emit('$translateChangeEnd', {language: key});
+      };
+
+      /**
+       * @name loadAsync
+       * @private
+       *
+       * @description
+       * Kicks of registered async loader using `$injector` and applies existing
+       * loader options. When resolved, it updates translation tables accordingly
+       * or rejects with given language key.
+       *
+       * @param {string} key Language key.
+       * @return {Promise} A promise.
+       */
+      var loadAsync = function (key) {
+        if (!key) {
+          throw 'No language key specified for loading.';
+        }
+
+        var deferred = $q.defer();
+
+        $rootScope.$emit('$translateLoadingStart', {language: key});
+        pendingLoader = true;
+
+        var cache = loaderCache;
+        if (typeof(cache) === 'string') {
+          // getting on-demand instance of loader
+          cache = $injector.get(cache);
+        }
+
+        var loaderOptions = angular.extend({}, $loaderOptions, {
+          key: key,
+          $http: angular.extend({}, {
+            cache: cache
+          }, $loaderOptions.$http)
+        });
+
+        var onLoaderSuccess = function (data) {
+          var translationTable = {};
+          $rootScope.$emit('$translateLoadingSuccess', {language: key});
+
+          if (angular.isArray(data)) {
+            angular.forEach(data, function (table) {
+              angular.extend(translationTable, flatObject(table));
+            });
+          } else {
+            angular.extend(translationTable, flatObject(data));
+          }
+          pendingLoader = false;
+          deferred.resolve({
+            key: key,
+            table: translationTable
+          });
+          $rootScope.$emit('$translateLoadingEnd', {language: key});
+        };
+        onLoaderSuccess.displayName = 'onLoaderSuccess';
+
+        var onLoaderError = function (key) {
+          $rootScope.$emit('$translateLoadingError', {language: key});
+          deferred.reject(key);
+          $rootScope.$emit('$translateLoadingEnd', {language: key});
+        };
+        onLoaderError.displayName = 'onLoaderError';
+
+        $injector.get($loaderFactory)(loaderOptions)
+          .then(onLoaderSuccess, onLoaderError);
+
+        return deferred.promise;
+      };
+
+      if ($storageFactory) {
+        Storage = $injector.get($storageFactory);
+
+        if (!Storage.get || !Storage.put) {
+          throw new Error('Couldn\'t use storage \'' + $storageFactory + '\', missing get() or put() method!');
+        }
+      }
+
+      // if we have additional interpolations that were added via
+      // $translateProvider.addInterpolation(), we have to map'em
+      if ($interpolatorFactories.length) {
+        var eachInterpolationFactory = function (interpolatorFactory) {
+          var interpolator = $injector.get(interpolatorFactory);
+          // setting initial locale for each interpolation service
+          interpolator.setLocale($preferredLanguage || $uses);
+          // make'em recognizable through id
+          interpolatorHashMap[interpolator.getInterpolationIdentifier()] = interpolator;
+        };
+        eachInterpolationFactory.displayName = 'interpolationFactoryAdder';
+
+        angular.forEach($interpolatorFactories, eachInterpolationFactory);
+      }
+
+      /**
+       * @name getTranslationTable
+       * @private
+       *
+       * @description
+       * Returns a promise that resolves to the translation table
+       * or is rejected if an error occurred.
+       *
+       * @param langKey
+       * @returns {Q.promise}
+       */
+      var getTranslationTable = function (langKey) {
+        var deferred = $q.defer();
+        if (Object.prototype.hasOwnProperty.call($translationTable, langKey)) {
+          deferred.resolve($translationTable[langKey]);
+        } else if (langPromises[langKey]) {
+          var onResolve = function (data) {
+            translations(data.key, data.table);
+            deferred.resolve(data.table);
+          };
+          onResolve.displayName = 'translationTableResolver';
+          langPromises[langKey].then(onResolve, deferred.reject);
+        } else {
+          deferred.reject();
+        }
+        return deferred.promise;
+      };
+
+      /**
+       * @name getFallbackTranslation
+       * @private
+       *
+       * @description
+       * Returns a promise that will resolve to the translation
+       * or be rejected if no translation was found for the language.
+       * This function is currently only used for fallback language translation.
+       *
+       * @param langKey The language to translate to.
+       * @param translationId
+       * @param interpolateParams
+       * @param Interpolator
+       * @returns {Q.promise}
+       */
+      var getFallbackTranslation = function (langKey, translationId, interpolateParams, Interpolator) {
+        var deferred = $q.defer();
+
+        var onResolve = function (translationTable) {
+          if (Object.prototype.hasOwnProperty.call(translationTable, translationId)) {
+            Interpolator.setLocale(langKey);
+            var translation = translationTable[translationId];
+            if (translation.substr(0, 2) === '@:') {
+              getFallbackTranslation(langKey, translation.substr(2), interpolateParams, Interpolator)
+                .then(deferred.resolve, deferred.reject);
+            } else {
+              deferred.resolve(Interpolator.interpolate(translationTable[translationId], interpolateParams));
+            }
+            Interpolator.setLocale($uses);
+          } else {
+            deferred.reject();
+          }
+        };
+        onResolve.displayName = 'fallbackTranslationResolver';
+
+        getTranslationTable(langKey).then(onResolve, deferred.reject);
+
+        return deferred.promise;
+      };
+
+      /**
+       * @name getFallbackTranslationInstant
+       * @private
+       *
+       * @description
+       * Returns a translation
+       * This function is currently only used for fallback language translation.
+       *
+       * @param langKey The language to translate to.
+       * @param translationId
+       * @param interpolateParams
+       * @param Interpolator
+       * @returns {string} translation
+       */
+      var getFallbackTranslationInstant = function (langKey, translationId, interpolateParams, Interpolator) {
+        var result, translationTable = $translationTable[langKey];
+
+        if (translationTable && Object.prototype.hasOwnProperty.call(translationTable, translationId)) {
+          Interpolator.setLocale(langKey);
+          result = Interpolator.interpolate(translationTable[translationId], interpolateParams);
+          if (result.substr(0, 2) === '@:') {
+            return getFallbackTranslationInstant(langKey, result.substr(2), interpolateParams, Interpolator);
+          }
+          Interpolator.setLocale($uses);
+        }
+
+        return result;
+      };
+
+
+      /**
+       * @name translateByHandler
+       * @private
+       *
+       * Translate by missing translation handler.
+       *
+       * @param translationId
+       * @returns translation created by $missingTranslationHandler or translationId is $missingTranslationHandler is
+       * absent
+       */
+      var translateByHandler = function (translationId, interpolateParams) {
+        // If we have a handler factory - we might also call it here to determine if it provides
+        // a default text for a translationid that can't be found anywhere in our tables
+        if ($missingTranslationHandlerFactory) {
+          var resultString = $injector.get($missingTranslationHandlerFactory)(translationId, $uses, interpolateParams);
+          if (resultString !== undefined) {
+            return resultString;
+          } else {
+            return translationId;
+          }
+        } else {
+          return translationId;
+        }
+      };
+
+      /**
+       * @name resolveForFallbackLanguage
+       * @private
+       *
+       * Recursive helper function for fallbackTranslation that will sequentially look
+       * for a translation in the fallbackLanguages starting with fallbackLanguageIndex.
+       *
+       * @param fallbackLanguageIndex
+       * @param translationId
+       * @param interpolateParams
+       * @param Interpolator
+       * @returns {Q.promise} Promise that will resolve to the translation.
+       */
+      var resolveForFallbackLanguage = function (fallbackLanguageIndex, translationId, interpolateParams, Interpolator, defaultTranslationText) {
+        var deferred = $q.defer();
+
+        if (fallbackLanguageIndex < $fallbackLanguage.length) {
+          var langKey = $fallbackLanguage[fallbackLanguageIndex];
+          getFallbackTranslation(langKey, translationId, interpolateParams, Interpolator).then(
+            deferred.resolve,
+            function () {
+              // Look in the next fallback language for a translation.
+              // It delays the resolving by passing another promise to resolve.
+              resolveForFallbackLanguage(fallbackLanguageIndex + 1, translationId, interpolateParams, Interpolator, defaultTranslationText).then(deferred.resolve);
+            }
+          );
+        } else {
+          // No translation found in any fallback language
+          // if a default translation text is set in the directive, then return this as a result
+          if (defaultTranslationText) {
+            deferred.resolve(defaultTranslationText);
+          } else {
+            // if no default translation is set and an error handler is defined, send it to the handler
+            // and then return the result
+            deferred.resolve(translateByHandler(translationId, interpolateParams));
+          }
+        }
+        return deferred.promise;
+      };
+
+      /**
+       * @name resolveForFallbackLanguageInstant
+       * @private
+       *
+       * Recursive helper function for fallbackTranslation that will sequentially look
+       * for a translation in the fallbackLanguages starting with fallbackLanguageIndex.
+       *
+       * @param fallbackLanguageIndex
+       * @param translationId
+       * @param interpolateParams
+       * @param Interpolator
+       * @returns {string} translation
+       */
+      var resolveForFallbackLanguageInstant = function (fallbackLanguageIndex, translationId, interpolateParams, Interpolator) {
+        var result;
+
+        if (fallbackLanguageIndex < $fallbackLanguage.length) {
+          var langKey = $fallbackLanguage[fallbackLanguageIndex];
+          result = getFallbackTranslationInstant(langKey, translationId, interpolateParams, Interpolator);
+          if (!result) {
+            result = resolveForFallbackLanguageInstant(fallbackLanguageIndex + 1, translationId, interpolateParams, Interpolator);
+          }
+        }
+        return result;
+      };
+
+      /**
+       * Translates with the usage of the fallback languages.
+       *
+       * @param translationId
+       * @param interpolateParams
+       * @param Interpolator
+       * @returns {Q.promise} Promise, that resolves to the translation.
+       */
+      var fallbackTranslation = function (translationId, interpolateParams, Interpolator, defaultTranslationText) {
+        // Start with the fallbackLanguage with index 0
+        return resolveForFallbackLanguage((startFallbackIteration>0 ? startFallbackIteration : fallbackIndex), translationId, interpolateParams, Interpolator, defaultTranslationText);
+      };
+
+      /**
+       * Translates with the usage of the fallback languages.
+       *
+       * @param translationId
+       * @param interpolateParams
+       * @param Interpolator
+       * @returns {String} translation
+       */
+      var fallbackTranslationInstant = function (translationId, interpolateParams, Interpolator) {
+        // Start with the fallbackLanguage with index 0
+        return resolveForFallbackLanguageInstant((startFallbackIteration>0 ? startFallbackIteration : fallbackIndex), translationId, interpolateParams, Interpolator);
+      };
+
+      var determineTranslation = function (translationId, interpolateParams, interpolationId, defaultTranslationText) {
+
+        var deferred = $q.defer();
+
+        var table = $uses ? $translationTable[$uses] : $translationTable,
+            Interpolator = (interpolationId) ? interpolatorHashMap[interpolationId] : defaultInterpolator;
+
+        // if the translation id exists, we can just interpolate it
+        if (table && Object.prototype.hasOwnProperty.call(table, translationId)) {
+          var translation = table[translationId];
+
+          // If using link, rerun $translate with linked translationId and return it
+          if (translation.substr(0, 2) === '@:') {
+
+            $translate(translation.substr(2), interpolateParams, interpolationId, defaultTranslationText)
+              .then(deferred.resolve, deferred.reject);
+          } else {
+            deferred.resolve(Interpolator.interpolate(translation, interpolateParams));
+          }
+        } else {
+          var missingTranslationHandlerTranslation;
+          // for logging purposes only (as in $translateMissingTranslationHandlerLog), value is not returned to promise
+          if ($missingTranslationHandlerFactory && !pendingLoader) {
+            missingTranslationHandlerTranslation = translateByHandler(translationId, interpolateParams);
+          }
+
+          // since we couldn't translate the inital requested translation id,
+          // we try it now with one or more fallback languages, if fallback language(s) is
+          // configured.
+          if ($uses && $fallbackLanguage && $fallbackLanguage.length) {
+            fallbackTranslation(translationId, interpolateParams, Interpolator, defaultTranslationText)
+                .then(function (translation) {
+                  deferred.resolve(translation);
+                }, function (_translationId) {
+                  deferred.reject(applyNotFoundIndicators(_translationId));
+                });
+          } else if ($missingTranslationHandlerFactory && !pendingLoader && missingTranslationHandlerTranslation) {
+            // looks like the requested translation id doesn't exists.
+            // Now, if there is a registered handler for missing translations and no
+            // asyncLoader is pending, we execute the handler
+            if (defaultTranslationText) {
+              deferred.resolve(defaultTranslationText);
+              } else {
+                deferred.resolve(missingTranslationHandlerTranslation);
+              }
+          } else {
+            if (defaultTranslationText) {
+              deferred.resolve(defaultTranslationText);
+            } else {
+              deferred.reject(applyNotFoundIndicators(translationId));
+            }
+          }
+        }
+        return deferred.promise;
+      };
+
+      var determineTranslationInstant = function (translationId, interpolateParams, interpolationId) {
+
+        var result, table = $uses ? $translationTable[$uses] : $translationTable,
+            Interpolator = defaultInterpolator;
+
+        // if the interpolation id exists use custom interpolator
+        if (interpolatorHashMap && Object.prototype.hasOwnProperty.call(interpolatorHashMap, interpolationId)) {
+          Interpolator = interpolatorHashMap[interpolationId];
+        }
+
+        // if the translation id exists, we can just interpolate it
+        if (table && Object.prototype.hasOwnProperty.call(table, translationId)) {
+          var translation = table[translationId];
+
+          // If using link, rerun $translate with linked translationId and return it
+          if (translation.substr(0, 2) === '@:') {
+            result = determineTranslationInstant(translation.substr(2), interpolateParams, interpolationId);
+          } else {
+            result = Interpolator.interpolate(translation, interpolateParams);
+          }
+        } else {
+          var missingTranslationHandlerTranslation;
+          // for logging purposes only (as in $translateMissingTranslationHandlerLog), value is not returned to promise
+          if ($missingTranslationHandlerFactory && !pendingLoader) {
+            missingTranslationHandlerTranslation = translateByHandler(translationId, interpolateParams);
+          }
+
+          // since we couldn't translate the inital requested translation id,
+          // we try it now with one or more fallback languages, if fallback language(s) is
+          // configured.
+          if ($uses && $fallbackLanguage && $fallbackLanguage.length) {
+            fallbackIndex = 0;
+            result = fallbackTranslationInstant(translationId, interpolateParams, Interpolator);
+          } else if ($missingTranslationHandlerFactory && !pendingLoader && missingTranslationHandlerTranslation) {
+            // looks like the requested translation id doesn't exists.
+            // Now, if there is a registered handler for missing translations and no
+            // asyncLoader is pending, we execute the handler
+            result = missingTranslationHandlerTranslation;
+          } else {
+            result = applyNotFoundIndicators(translationId);
+          }
+        }
+
+        return result;
+      };
+
+      var clearNextLangAndPromise = function(key) {
+        if ($nextLang === key) {
+          $nextLang = undefined;
+        }
+        langPromises[key] = undefined;
+      };
+
+      /**
+       * @ngdoc function
+       * @name pascalprecht.translate.$translate#preferredLanguage
+       * @methodOf pascalprecht.translate.$translate
+       *
+       * @description
+       * Returns the language key for the preferred language.
+       *
+       * @param {string} langKey language String or Array to be used as preferredLanguage (changing at runtime)
+       *
+       * @return {string} preferred language key
+       */
+      $translate.preferredLanguage = function (langKey) {
+        if(langKey) {
+          setupPreferredLanguage(langKey);
+        }
+        return $preferredLanguage;
+      };
+
+      /**
+       * @ngdoc function
+       * @name pascalprecht.translate.$translate#cloakClassName
+       * @methodOf pascalprecht.translate.$translate
+       *
+       * @description
+       * Returns the configured class name for `translate-cloak` directive.
+       *
+       * @return {string} cloakClassName
+       */
+      $translate.cloakClassName = function () {
+        return $cloakClassName;
+      };
+
+      /**
+       * @ngdoc function
+       * @name pascalprecht.translate.$translate#fallbackLanguage
+       * @methodOf pascalprecht.translate.$translate
+       *
+       * @description
+       * Returns the language key for the fallback languages or sets a new fallback stack.
+       *
+       * @param {string=} langKey language String or Array of fallback languages to be used (to change stack at runtime)
+       *
+       * @return {string||array} fallback language key
+       */
+      $translate.fallbackLanguage = function (langKey) {
+        if (langKey !== undefined && langKey !== null) {
+          fallbackStack(langKey);
+
+          // as we might have an async loader initiated and a new translation language might have been defined
+          // we need to add the promise to the stack also. So - iterate.
+          if ($loaderFactory) {
+            if ($fallbackLanguage && $fallbackLanguage.length) {
+              for (var i = 0, len = $fallbackLanguage.length; i < len; i++) {
+                if (!langPromises[$fallbackLanguage[i]]) {
+                  langPromises[$fallbackLanguage[i]] = loadAsync($fallbackLanguage[i]);
+                }
+              }
+            }
+          }
+          $translate.use($translate.use());
+        }
+        if ($fallbackWasString) {
+          return $fallbackLanguage[0];
+        } else {
+          return $fallbackLanguage;
+        }
+
+      };
+
+      /**
+       * @ngdoc function
+       * @name pascalprecht.translate.$translate#useFallbackLanguage
+       * @methodOf pascalprecht.translate.$translate
+       *
+       * @description
+       * Sets the first key of the fallback language stack to be used for translation.
+       * Therefore all languages in the fallback array BEFORE this key will be skipped!
+       *
+       * @param {string=} langKey Contains the langKey the iteration shall start with. Set to false if you want to
+       * get back to the whole stack
+       */
+      $translate.useFallbackLanguage = function (langKey) {
+        if (langKey !== undefined && langKey !== null) {
+          if (!langKey) {
+            startFallbackIteration = 0;
+          } else {
+            var langKeyPosition = indexOf($fallbackLanguage, langKey);
+            if (langKeyPosition > -1) {
+              startFallbackIteration = langKeyPosition;
+            }
+          }
+
+        }
+
+      };
+
+      /**
+       * @ngdoc function
+       * @name pascalprecht.translate.$translate#proposedLanguage
+       * @methodOf pascalprecht.translate.$translate
+       *
+       * @description
+       * Returns the language key of language that is currently loaded asynchronously.
+       *
+       * @return {string} language key
+       */
+      $translate.proposedLanguage = function () {
+        return $nextLang;
+      };
+
+      /**
+       * @ngdoc function
+       * @name pascalprecht.translate.$translate#storage
+       * @methodOf pascalprecht.translate.$translate
+       *
+       * @description
+       * Returns registered storage.
+       *
+       * @return {object} Storage
+       */
+      $translate.storage = function () {
+        return Storage;
+      };
+
+      /**
+       * @ngdoc function
+       * @name pascalprecht.translate.$translate#use
+       * @methodOf pascalprecht.translate.$translate
+       *
+       * @description
+       * Tells angular-translate which language to use by given language key. This method is
+       * used to change language at runtime. It also takes care of storing the language
+       * key in a configured store to let your app remember the choosed language.
+       *
+       * When trying to 'use' a language which isn't available it tries to load it
+       * asynchronously with registered loaders.
+       *
+       * Returns promise object with loaded language file data
+       * @example
+       * $translate.use("en_US").then(function(data){
+       *   $scope.text = $translate("HELLO");
+       * });
+       *
+       * @param {string} key Language key
+       * @return {string} Language key
+       */
+      $translate.use = function (key) {
+        if (!key) {
+          return $uses;
+        }
+
+        var deferred = $q.defer();
+
+        $rootScope.$emit('$translateChangeStart', {language: key});
+
+        // Try to get the aliased language key
+        var aliasedKey = negotiateLocale(key);
+        if (aliasedKey) {
+          key = aliasedKey;
+        }
+
+        // if there isn't a translation table for the language we've requested,
+        // we load it asynchronously
+        if (($forceAsyncReloadEnabled || !$translationTable[key]) && $loaderFactory && !langPromises[key]) {
+          $nextLang = key;
+          langPromises[key] = loadAsync(key).then(function (translation) {
+            translations(translation.key, translation.table);
+            deferred.resolve(translation.key);
+            useLanguage(translation.key);
+            return translation;
+          }, function (key) {
+            $rootScope.$emit('$translateChangeError', {language: key});
+            deferred.reject(key);
+            $rootScope.$emit('$translateChangeEnd', {language: key});
+            return $q.reject(key);
+          });
+          langPromises[key]['finally'](function () {
+            clearNextLangAndPromise(key);
+          });
+        } else if ($nextLang === key && langPromises[key]) {
+          // we are already loading this asynchronously
+          // resolve our new deferred when the old langPromise is resolved
+          langPromises[key].then(function (translation) {
+            deferred.resolve(translation.key);
+            return translation;
+          }, function (key) {
+            deferred.reject(key);
+            return $q.reject(key);
+          });
+        } else {
+          deferred.resolve(key);
+          useLanguage(key);
+        }
+
+        return deferred.promise;
+      };
+
+      /**
+       * @ngdoc function
+       * @name pascalprecht.translate.$translate#storageKey
+       * @methodOf pascalprecht.translate.$translate
+       *
+       * @description
+       * Returns the key for the storage.
+       *
+       * @return {string} storage key
+       */
+      $translate.storageKey = function () {
+        return storageKey();
+      };
+
+      /**
+       * @ngdoc function
+       * @name pascalprecht.translate.$translate#isPostCompilingEnabled
+       * @methodOf pascalprecht.translate.$translate
+       *
+       * @description
+       * Returns whether post compiling is enabled or not
+       *
+       * @return {bool} storage key
+       */
+      $translate.isPostCompilingEnabled = function () {
+        return $postCompilingEnabled;
+      };
+
+      /**
+       * @ngdoc function
+       * @name pascalprecht.translate.$translate#isForceAsyncReloadEnabled
+       * @methodOf pascalprecht.translate.$translate
+       *
+       * @description
+       * Returns whether force async reload is enabled or not
+       *
+       * @return {boolean} forceAsyncReload value
+       */
+      $translate.isForceAsyncReloadEnabled = function () {
+        return $forceAsyncReloadEnabled;
+      };
+
+      /**
+       * @ngdoc function
+       * @name pascalprecht.translate.$translate#refresh
+       * @methodOf pascalprecht.translate.$translate
+       *
+       * @description
+       * Refreshes a translation table pointed by the given langKey. If langKey is not specified,
+       * the module will drop all existent translation tables and load new version of those which
+       * are currently in use.
+       *
+       * Refresh means that the module will drop target translation table and try to load it again.
+       *
+       * In case there are no loaders registered the refresh() method will throw an Error.
+       *
+       * If the module is able to refresh translation tables refresh() method will broadcast
+       * $translateRefreshStart and $translateRefreshEnd events.
+       *
+       * @example
+       * // this will drop all currently existent translation tables and reload those which are
+       * // currently in use
+       * $translate.refresh();
+       * // this will refresh a translation table for the en_US language
+       * $translate.refresh('en_US');
+       *
+       * @param {string} langKey A language key of the table, which has to be refreshed
+       *
+       * @return {promise} Promise, which will be resolved in case a translation tables refreshing
+       * process is finished successfully, and reject if not.
+       */
+      $translate.refresh = function (langKey) {
+        if (!$loaderFactory) {
+          throw new Error('Couldn\'t refresh translation table, no loader registered!');
+        }
+
+        var deferred = $q.defer();
+
+        function resolve() {
+          deferred.resolve();
+          $rootScope.$emit('$translateRefreshEnd', {language: langKey});
+        }
+
+        function reject() {
+          deferred.reject();
+          $rootScope.$emit('$translateRefreshEnd', {language: langKey});
+        }
+
+        $rootScope.$emit('$translateRefreshStart', {language: langKey});
+
+        if (!langKey) {
+          // if there's no language key specified we refresh ALL THE THINGS!
+          var tables = [], loadingKeys = {};
+
+          // reload registered fallback languages
+          if ($fallbackLanguage && $fallbackLanguage.length) {
+            for (var i = 0, len = $fallbackLanguage.length; i < len; i++) {
+              tables.push(loadAsync($fallbackLanguage[i]));
+              loadingKeys[$fallbackLanguage[i]] = true;
+            }
+          }
+
+          // reload currently used language
+          if ($uses && !loadingKeys[$uses]) {
+            tables.push(loadAsync($uses));
+          }
+
+          var allTranslationsLoaded = function (tableData) {
+            $translationTable = {};
+            angular.forEach(tableData, function (data) {
+              translations(data.key, data.table);
+            });
+            if ($uses) {
+              useLanguage($uses);
+            }
+            resolve();
+          };
+          allTranslationsLoaded.displayName = 'refreshPostProcessor';
+
+          $q.all(tables).then(allTranslationsLoaded, reject);
+
+        } else if ($translationTable[langKey]) {
+
+          var oneTranslationsLoaded = function (data) {
+            translations(data.key, data.table);
+            if (langKey === $uses) {
+              useLanguage($uses);
+            }
+            resolve();
+          };
+          oneTranslationsLoaded.displayName = 'refreshPostProcessor';
+
+          loadAsync(langKey).then(oneTranslationsLoaded, reject);
+
+        } else {
+          reject();
+        }
+        return deferred.promise;
+      };
+
+      /**
+       * @ngdoc function
+       * @name pascalprecht.translate.$translate#instant
+       * @methodOf pascalprecht.translate.$translate
+       *
+       * @description
+       * Returns a translation instantly from the internal state of loaded translation. All rules
+       * regarding the current language, the preferred language of even fallback languages will be
+       * used except any promise handling. If a language was not found, an asynchronous loading
+       * will be invoked in the background.
+       *
+       * @param {string|array} translationId A token which represents a translation id
+       *                                     This can be optionally an array of translation ids which
+       *                                     results that the function's promise returns an object where
+       *                                     each key is the translation id and the value the translation.
+       * @param {object} interpolateParams Params
+       * @param {string} interpolationId The id of the interpolation to use
+       *
+       * @return {string|object} translation
+       */
+      $translate.instant = function (translationId, interpolateParams, interpolationId) {
+
+        // Detect undefined and null values to shorten the execution and prevent exceptions
+        if (translationId === null || angular.isUndefined(translationId)) {
+          return translationId;
+        }
+
+        // Duck detection: If the first argument is an array, a bunch of translations was requested.
+        // The result is an object.
+        if (angular.isArray(translationId)) {
+          var results = {};
+          for (var i = 0, c = translationId.length; i < c; i++) {
+            results[translationId[i]] = $translate.instant(translationId[i], interpolateParams, interpolationId);
+          }
+          return results;
+        }
+
+        // We discarded unacceptable values. So we just need to verify if translationId is empty String
+        if (angular.isString(translationId) && translationId.length < 1) {
+          return translationId;
+        }
+
+        // trim off any whitespace
+        if (translationId) {
+          translationId = trim.apply(translationId);
+        }
+
+        var result, possibleLangKeys = [];
+        if ($preferredLanguage) {
+          possibleLangKeys.push($preferredLanguage);
+        }
+        if ($uses) {
+          possibleLangKeys.push($uses);
+        }
+        if ($fallbackLanguage && $fallbackLanguage.length) {
+          possibleLangKeys = possibleLangKeys.concat($fallbackLanguage);
+        }
+        for (var j = 0, d = possibleLangKeys.length; j < d; j++) {
+          var possibleLangKey = possibleLangKeys[j];
+          if ($translationTable[possibleLangKey]) {
+            if (typeof $translationTable[possibleLangKey][translationId] !== 'undefined') {
+              result = determineTranslationInstant(translationId, interpolateParams, interpolationId);
+            } else if ($notFoundIndicatorLeft || $notFoundIndicatorRight) {
+              result = applyNotFoundIndicators(translationId);
+            }
+          }
+          if (typeof result !== 'undefined') {
+            break;
+          }
+        }
+
+        if (!result && result !== '') {
+          // Return translation of default interpolator if not found anything.
+          result = defaultInterpolator.interpolate(translationId, interpolateParams);
+          if ($missingTranslationHandlerFactory && !pendingLoader) {
+            result = translateByHandler(translationId, interpolateParams);
+          }
+        }
+
+        return result;
+      };
+
+      /**
+       * @ngdoc function
+       * @name pascalprecht.translate.$translate#versionInfo
+       * @methodOf pascalprecht.translate.$translate
+       *
+       * @description
+       * Returns the current version information for the angular-translate library
+       *
+       * @return {string} angular-translate version
+       */
+      $translate.versionInfo = function () {
+        return version;
+      };
+
+      /**
+       * @ngdoc function
+       * @name pascalprecht.translate.$translate#loaderCache
+       * @methodOf pascalprecht.translate.$translate
+       *
+       * @description
+       * Returns the defined loaderCache.
+       *
+       * @return {boolean|string|object} current value of loaderCache
+       */
+      $translate.loaderCache = function () {
+        return loaderCache;
+      };
+
+      // internal purpose only
+      $translate.directivePriority = function () {
+        return directivePriority;
+      };
+
+      // internal purpose only
+      $translate.statefulFilter = function () {
+        return statefulFilter;
+      };
+
+      if ($loaderFactory) {
+
+        // If at least one async loader is defined and there are no
+        // (default) translations available we should try to load them.
+        if (angular.equals($translationTable, {})) {
+          $translate.use($translate.use());
+        }
+
+        // Also, if there are any fallback language registered, we start
+        // loading them asynchronously as soon as we can.
+        if ($fallbackLanguage && $fallbackLanguage.length) {
+          var processAsyncResult = function (translation) {
+            translations(translation.key, translation.table);
+            $rootScope.$emit('$translateChangeEnd', { language: translation.key });
+            return translation;
+          };
+          for (var i = 0, len = $fallbackLanguage.length; i < len; i++) {
+            var fallbackLanguageId = $fallbackLanguage[i];
+            if ($forceAsyncReloadEnabled || !$translationTable[fallbackLanguageId]) {
+              langPromises[fallbackLanguageId] = loadAsync(fallbackLanguageId).then(processAsyncResult);
+            }
+          }
+        }
+      }
+
+      return $translate;
+    }
+  ];
+}
+$translate.$inject = ['$STORAGE_KEY', '$windowProvider', '$translateSanitizationProvider', 'pascalprechtTranslateOverrider'];
+
+$translate.displayName = 'displayName';
+
+/**
+ * @ngdoc object
+ * @name pascalprecht.translate.$translateDefaultInterpolation
+ * @requires $interpolate
+ *
+ * @description
+ * Uses angular's `$interpolate` services to interpolate strings against some values.
+ *
+ * Be aware to configure a proper sanitization strategy.
+ *
+ * See also:
+ * * {@link pascalprecht.translate.$translateSanitization}
+ *
+ * @return {object} $translateDefaultInterpolation Interpolator service
+ */
+angular.module('pascalprecht.translate').factory('$translateDefaultInterpolation', $translateDefaultInterpolation);
+
+function $translateDefaultInterpolation ($interpolate, $translateSanitization) {
+
+  'use strict';
+
+  var $translateInterpolator = {},
+      $locale,
+      $identifier = 'default';
+
+  /**
+   * @ngdoc function
+   * @name pascalprecht.translate.$translateDefaultInterpolation#setLocale
+   * @methodOf pascalprecht.translate.$translateDefaultInterpolation
+   *
+   * @description
+   * Sets current locale (this is currently not use in this interpolation).
+   *
+   * @param {string} locale Language key or locale.
+   */
+  $translateInterpolator.setLocale = function (locale) {
+    $locale = locale;
+  };
+
+  /**
+   * @ngdoc function
+   * @name pascalprecht.translate.$translateDefaultInterpolation#getInterpolationIdentifier
+   * @methodOf pascalprecht.translate.$translateDefaultInterpolation
+   *
+   * @description
+   * Returns an identifier for this interpolation service.
+   *
+   * @returns {string} $identifier
+   */
+  $translateInterpolator.getInterpolationIdentifier = function () {
+    return $identifier;
+  };
+
+  /**
+   * @deprecated will be removed in 3.0
+   * @see {@link pascalprecht.translate.$translateSanitization}
+   */
+  $translateInterpolator.useSanitizeValueStrategy = function (value) {
+    $translateSanitization.useStrategy(value);
+    return this;
+  };
+
+  /**
+   * @ngdoc function
+   * @name pascalprecht.translate.$translateDefaultInterpolation#interpolate
+   * @methodOf pascalprecht.translate.$translateDefaultInterpolation
+   *
+   * @description
+   * Interpolates given string agains given interpolate params using angulars
+   * `$interpolate` service.
+   *
+   * @returns {string} interpolated string.
+   */
+  $translateInterpolator.interpolate = function (string, interpolationParams) {
+    interpolationParams = interpolationParams || {};
+    interpolationParams = $translateSanitization.sanitize(interpolationParams, 'params');
+
+    var interpolatedText = $interpolate(string)(interpolationParams);
+    interpolatedText = $translateSanitization.sanitize(interpolatedText, 'text');
+
+    return interpolatedText;
+  };
+
+  return $translateInterpolator;
+}
+$translateDefaultInterpolation.$inject = ['$interpolate', '$translateSanitization'];
+
+$translateDefaultInterpolation.displayName = '$translateDefaultInterpolation';
+
+angular.module('pascalprecht.translate').constant('$STORAGE_KEY', 'NG_TRANSLATE_LANG_KEY');
+
+angular.module('pascalprecht.translate')
+/**
+ * @ngdoc directive
+ * @name pascalprecht.translate.directive:translate
+ * @requires $compile
+ * @requires $filter
+ * @requires $interpolate
+ * @restrict A
+ *
+ * @description
+ * Translates given translation id either through attribute or DOM content.
+ * Internally it uses `translate` filter to translate translation id. It possible to
+ * pass an optional `translate-values` object literal as string into translation id.
+ *
+ * @param {string=} translate Translation id which could be either string or interpolated string.
+ * @param {string=} translate-values Values to pass into translation id. Can be passed as object literal string or interpolated object.
+ * @param {string=} translate-attr-ATTR translate Translation id and put it into ATTR attribute.
+ * @param {string=} translate-default will be used unless translation was successful
+ * @param {boolean=} translate-compile (default true if present) defines locally activation of {@link pascalprecht.translate.$translateProvider#methods_usePostCompiling}
+ *
+ * @example
+   <example module="ngView">
+    <file name="index.html">
+      <div ng-controller="TranslateCtrl">
+
+        <pre translate="TRANSLATION_ID"></pre>
+        <pre translate>TRANSLATION_ID</pre>
+        <pre translate translate-attr-title="TRANSLATION_ID"></pre>
+        <pre translate="{{translationId}}"></pre>
+        <pre translate>{{translationId}}</pre>
+        <pre translate="WITH_VALUES" translate-values="{value: 5}"></pre>
+        <pre translate translate-values="{value: 5}">WITH_VALUES</pre>
+        <pre translate="WITH_VALUES" translate-values="{{values}}"></pre>
+        <pre translate translate-values="{{values}}">WITH_VALUES</pre>
+        <pre translate translate-attr-title="WITH_VALUES" translate-values="{{values}}"></pre>
+
+      </div>
+    </file>
+    <file name="script.js">
+      angular.module('ngView', ['pascalprecht.translate'])
+
+      .config(function ($translateProvider) {
+
+        $translateProvider.translations('en',{
+          'TRANSLATION_ID': 'Hello there!',
+          'WITH_VALUES': 'The following value is dynamic: {{value}}'
+        }).preferredLanguage('en');
+
+      });
+
+      angular.module('ngView').controller('TranslateCtrl', function ($scope) {
+        $scope.translationId = 'TRANSLATION_ID';
+
+        $scope.values = {
+          value: 78
+        };
+      });
+    </file>
+    <file name="scenario.js">
+      it('should translate', function () {
+        inject(function ($rootScope, $compile) {
+          $rootScope.translationId = 'TRANSLATION_ID';
+
+          element = $compile('<p translate="TRANSLATION_ID"></p>')($rootScope);
+          $rootScope.$digest();
+          expect(element.text()).toBe('Hello there!');
+
+          element = $compile('<p translate="{{translationId}}"></p>')($rootScope);
+          $rootScope.$digest();
+          expect(element.text()).toBe('Hello there!');
+
+          element = $compile('<p translate>TRANSLATION_ID</p>')($rootScope);
+          $rootScope.$digest();
+          expect(element.text()).toBe('Hello there!');
+
+          element = $compile('<p translate>{{translationId}}</p>')($rootScope);
+          $rootScope.$digest();
+          expect(element.text()).toBe('Hello there!');
+
+          element = $compile('<p translate translate-attr-title="TRANSLATION_ID"></p>')($rootScope);
+          $rootScope.$digest();
+          expect(element.attr('title')).toBe('Hello there!');
+        });
+      });
+    </file>
+   </example>
+ */
+.directive('translate', translateDirective);
+function translateDirective($translate, $q, $interpolate, $compile, $parse, $rootScope) {
+
+  'use strict';
+
+  /**
+   * @name trim
+   * @private
+   *
+   * @description
+   * trim polyfill
+   *
+   * @returns {string} The string stripped of whitespace from both ends
+   */
+  var trim = function() {
+    return this.toString().replace(/^\s+|\s+$/g, '');
+  };
+
+  return {
+    restrict: 'AE',
+    scope: true,
+    priority: $translate.directivePriority(),
+    compile: function (tElement, tAttr) {
+
+      var translateValuesExist = (tAttr.translateValues) ?
+        tAttr.translateValues : undefined;
+
+      var translateInterpolation = (tAttr.translateInterpolation) ?
+        tAttr.translateInterpolation : undefined;
+
+      var translateValueExist = tElement[0].outerHTML.match(/translate-value-+/i);
+
+      var interpolateRegExp = '^(.*)(' + $interpolate.startSymbol() + '.*' + $interpolate.endSymbol() + ')(.*)',
+          watcherRegExp = '^(.*)' + $interpolate.startSymbol() + '(.*)' + $interpolate.endSymbol() + '(.*)';
+
+      return function linkFn(scope, iElement, iAttr) {
+
+        scope.interpolateParams = {};
+        scope.preText = '';
+        scope.postText = '';
+        var translationIds = {};
+
+        var initInterpolationParams = function (interpolateParams, iAttr, tAttr) {
+          // initial setup
+          if (iAttr.translateValues) {
+            angular.extend(interpolateParams, $parse(iAttr.translateValues)(scope.$parent));
+          }
+          // initially fetch all attributes if existing and fill the params
+          if (translateValueExist) {
+            for (var attr in tAttr) {
+              if (Object.prototype.hasOwnProperty.call(iAttr, attr) && attr.substr(0, 14) === 'translateValue' && attr !== 'translateValues') {
+                var attributeName = angular.lowercase(attr.substr(14, 1)) + attr.substr(15);
+                interpolateParams[attributeName] = tAttr[attr];
+              }
+            }
+          }
+        };
+
+        // Ensures any change of the attribute "translate" containing the id will
+        // be re-stored to the scope's "translationId".
+        // If the attribute has no content, the element's text value (white spaces trimmed off) will be used.
+        var observeElementTranslation = function (translationId) {
+
+          // Remove any old watcher
+          if (angular.isFunction(observeElementTranslation._unwatchOld)) {
+            observeElementTranslation._unwatchOld();
+            observeElementTranslation._unwatchOld = undefined;
+          }
+
+          if (angular.equals(translationId , '') || !angular.isDefined(translationId)) {
+            // Resolve translation id by inner html if required
+            var interpolateMatches = trim.apply(iElement.text()).match(interpolateRegExp);
+            // Interpolate translation id if required
+            if (angular.isArray(interpolateMatches)) {
+              scope.preText = interpolateMatches[1];
+              scope.postText = interpolateMatches[3];
+              translationIds.translate = $interpolate(interpolateMatches[2])(scope.$parent);
+              var watcherMatches = iElement.text().match(watcherRegExp);
+              if (angular.isArray(watcherMatches) && watcherMatches[2] && watcherMatches[2].length) {
+                observeElementTranslation._unwatchOld = scope.$watch(watcherMatches[2], function (newValue) {
+                  translationIds.translate = newValue;
+                  updateTranslations();
+                });
+              }
+            } else {
+              translationIds.translate = iElement.text().replace(/^\s+|\s+$/g,'');
+            }
+          } else {
+            translationIds.translate = translationId;
+          }
+          updateTranslations();
+        };
+
+        var observeAttributeTranslation = function (translateAttr) {
+          iAttr.$observe(translateAttr, function (translationId) {
+            translationIds[translateAttr] = translationId;
+            updateTranslations();
+          });
+        };
+
+        // initial setup with values
+        initInterpolationParams(scope.interpolateParams, iAttr, tAttr);
+
+        var firstAttributeChangedEvent = true;
+        iAttr.$observe('translate', function (translationId) {
+          if (typeof translationId === 'undefined') {
+            // case of element "<translate>xyz</translate>"
+            observeElementTranslation('');
+          } else {
+            // case of regular attribute
+            if (translationId !== '' || !firstAttributeChangedEvent) {
+              translationIds.translate = translationId;
+              updateTranslations();
+            }
+          }
+          firstAttributeChangedEvent = false;
+        });
+
+        for (var translateAttr in iAttr) {
+          if (iAttr.hasOwnProperty(translateAttr) && translateAttr.substr(0, 13) === 'translateAttr') {
+            observeAttributeTranslation(translateAttr);
+          }
+        }
+
+        iAttr.$observe('translateDefault', function (value) {
+          scope.defaultText = value;
+        });
+
+        if (translateValuesExist) {
+          iAttr.$observe('translateValues', function (interpolateParams) {
+            if (interpolateParams) {
+              scope.$parent.$watch(function () {
+                angular.extend(scope.interpolateParams, $parse(interpolateParams)(scope.$parent));
+              });
+            }
+          });
+        }
+
+        if (translateValueExist) {
+          var observeValueAttribute = function (attrName) {
+            iAttr.$observe(attrName, function (value) {
+              var attributeName = angular.lowercase(attrName.substr(14, 1)) + attrName.substr(15);
+              scope.interpolateParams[attributeName] = value;
+            });
+          };
+          for (var attr in iAttr) {
+            if (Object.prototype.hasOwnProperty.call(iAttr, attr) && attr.substr(0, 14) === 'translateValue' && attr !== 'translateValues') {
+              observeValueAttribute(attr);
+            }
+          }
+        }
+
+        // Master update function
+        var updateTranslations = function () {
+          for (var key in translationIds) {
+
+            if (translationIds.hasOwnProperty(key) && translationIds[key] !== undefined) {
+              updateTranslation(key, translationIds[key], scope, scope.interpolateParams, scope.defaultText);
+            }
+          }
+        };
+
+        // Put translation processing function outside loop
+        var updateTranslation = function(translateAttr, translationId, scope, interpolateParams, defaultTranslationText) {
+          if (translationId) {
+            $translate(translationId, interpolateParams, translateInterpolation, defaultTranslationText)
+              .then(function (translation) {
+                applyTranslation(translation, scope, true, translateAttr);
+              }, function (translationId) {
+                applyTranslation(translationId, scope, false, translateAttr);
+              });
+          } else {
+            // as an empty string cannot be translated, we can solve this using successful=false
+            applyTranslation(translationId, scope, false, translateAttr);
+          }
+        };
+
+        var applyTranslation = function (value, scope, successful, translateAttr) {
+          if (translateAttr === 'translate') {
+            // default translate into innerHTML
+            if (!successful && typeof scope.defaultText !== 'undefined') {
+              value = scope.defaultText;
+            }
+            iElement.html(scope.preText + value + scope.postText);
+            var globallyEnabled = $translate.isPostCompilingEnabled();
+            var locallyDefined = typeof tAttr.translateCompile !== 'undefined';
+            var locallyEnabled = locallyDefined && tAttr.translateCompile !== 'false';
+            if ((globallyEnabled && !locallyDefined) || locallyEnabled) {
+              $compile(iElement.contents())(scope);
+            }
+          } else {
+            // translate attribute
+            if (!successful && typeof scope.defaultText !== 'undefined') {
+              value = scope.defaultText;
+            }
+            var attributeName = iAttr.$attr[translateAttr];
+            if (attributeName.substr(0, 5) === 'data-') {
+              // ensure html5 data prefix is stripped
+              attributeName = attributeName.substr(5);
+            }
+            attributeName = attributeName.substr(15);
+            iElement.attr(attributeName, value);
+          }
+        };
+
+        if (translateValuesExist || translateValueExist || iAttr.translateDefault) {
+          scope.$watch('interpolateParams', updateTranslations, true);
+        }
+
+        // Ensures the text will be refreshed after the current language was changed
+        // w/ $translate.use(...)
+        var unbind = $rootScope.$on('$translateChangeSuccess', updateTranslations);
+
+        // ensure translation will be looked up at least one
+        if (iElement.text().length) {
+          if (iAttr.translate) {
+            observeElementTranslation(iAttr.translate);
+          } else {
+            observeElementTranslation('');
+          }
+        } else if (iAttr.translate) {
+          // ensure attribute will be not skipped
+          observeElementTranslation(iAttr.translate);
+        }
+        updateTranslations();
+        scope.$on('$destroy', unbind);
+      };
+    }
+  };
+}
+translateDirective.$inject = ['$translate', '$q', '$interpolate', '$compile', '$parse', '$rootScope'];
+
+translateDirective.displayName = 'translateDirective';
+
+angular.module('pascalprecht.translate')
+/**
+ * @ngdoc directive
+ * @name pascalprecht.translate.directive:translateCloak
+ * @requires $rootScope
+ * @requires $translate
+ * @restrict A
+ *
+ * $description
+ * Adds a `translate-cloak` class name to the given element where this directive
+ * is applied initially and removes it, once a loader has finished loading.
+ *
+ * This directive can be used to prevent initial flickering when loading translation
+ * data asynchronously.
+ *
+ * The class name is defined in
+ * {@link pascalprecht.translate.$translateProvider#cloakClassName $translate.cloakClassName()}.
+ *
+ * @param {string=} translate-cloak If a translationId is provided, it will be used for showing
+ *                                  or hiding the cloak. Basically it relies on the translation
+ *                                  resolve.
+ */
+.directive('translateCloak', translateCloakDirective);
+
+function translateCloakDirective($rootScope, $translate) {
+
+  'use strict';
+
+  return {
+    compile: function (tElement) {
+      var applyCloak = function () {
+        tElement.addClass($translate.cloakClassName());
+      },
+      removeCloak = function () {
+        tElement.removeClass($translate.cloakClassName());
+      },
+      removeListener = $rootScope.$on('$translateChangeEnd', function () {
+        removeCloak();
+        removeListener();
+        removeListener = null;
+      });
+      applyCloak();
+
+      return function linkFn(scope, iElement, iAttr) {
+        // Register a watcher for the defined translation allowing a fine tuned cloak
+        if (iAttr.translateCloak && iAttr.translateCloak.length) {
+          iAttr.$observe('translateCloak', function (translationId) {
+            $translate(translationId).then(removeCloak, applyCloak);
+          });
+        }
+      };
+    }
+  };
+}
+translateCloakDirective.$inject = ['$rootScope', '$translate'];
+
+translateCloakDirective.displayName = 'translateCloakDirective';
+
+angular.module('pascalprecht.translate')
+/**
+ * @ngdoc filter
+ * @name pascalprecht.translate.filter:translate
+ * @requires $parse
+ * @requires pascalprecht.translate.$translate
+ * @function
+ *
+ * @description
+ * Uses `$translate` service to translate contents. Accepts interpolate parameters
+ * to pass dynamized values though translation.
+ *
+ * @param {string} translationId A translation id to be translated.
+ * @param {*=} interpolateParams Optional object literal (as hash or string) to pass values into translation.
+ *
+ * @returns {string} Translated text.
+ *
+ * @example
+   <example module="ngView">
+    <file name="index.html">
+      <div ng-controller="TranslateCtrl">
+
+        <pre>{{ 'TRANSLATION_ID' | translate }}</pre>
+        <pre>{{ translationId | translate }}</pre>
+        <pre>{{ 'WITH_VALUES' | translate:'{value: 5}' }}</pre>
+        <pre>{{ 'WITH_VALUES' | translate:values }}</pre>
+
+      </div>
+    </file>
+    <file name="script.js">
+      angular.module('ngView', ['pascalprecht.translate'])
+
+      .config(function ($translateProvider) {
+
+        $translateProvider.translations('en', {
+          'TRANSLATION_ID': 'Hello there!',
+          'WITH_VALUES': 'The following value is dynamic: {{value}}'
+        });
+        $translateProvider.preferredLanguage('en');
+
+      });
+
+      angular.module('ngView').controller('TranslateCtrl', function ($scope) {
+        $scope.translationId = 'TRANSLATION_ID';
+
+        $scope.values = {
+          value: 78
+        };
+      });
+    </file>
+   </example>
+ */
+.filter('translate', translateFilterFactory);
+
+function translateFilterFactory($parse, $translate) {
+
+  'use strict';
+
+  var translateFilter = function (translationId, interpolateParams, interpolation) {
+
+    if (!angular.isObject(interpolateParams)) {
+      interpolateParams = $parse(interpolateParams)(this);
+    }
+
+    return $translate.instant(translationId, interpolateParams, interpolation);
+  };
+
+  if ($translate.statefulFilter()) {
+    translateFilter.$stateful = true;
+  }
+
+  return translateFilter;
+}
+translateFilterFactory.$inject = ['$parse', '$translate'];
+
+translateFilterFactory.displayName = 'translateFilterFactory';
+
+angular.module('pascalprecht.translate')
+
+/**
+ * @ngdoc object
+ * @name pascalprecht.translate.$translationCache
+ * @requires $cacheFactory
+ *
+ * @description
+ * The first time a translation table is used, it is loaded in the translation cache for quick retrieval. You
+ * can load translation tables directly into the cache by consuming the
+ * `$translationCache` service directly.
+ *
+ * @return {object} $cacheFactory object.
+ */
+  .factory('$translationCache', $translationCache);
+
+function $translationCache($cacheFactory) {
+
+  'use strict';
+
+  return $cacheFactory('translations');
+}
+$translationCache.$inject = ['$cacheFactory'];
+
+$translationCache.displayName = '$translationCache';
+return 'pascalprecht.translate';
+
+}));
diff --git a/guacamole/src/main/webapp/lib/angular/LICENSE b/guacamole/src/main/webapp/lib/angular/LICENSE
new file mode 100644
index 0000000..9ced331
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/angular/LICENSE
@@ -0,0 +1,22 @@
+The MIT License
+
+Copyright (c) 2010-2014 Google, Inc. http://angularjs.org
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
diff --git a/guacamole/src/main/webapp/lib/angular/angular-cookies.js b/guacamole/src/main/webapp/lib/angular/angular-cookies.js
new file mode 100644
index 0000000..e0d7d66
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/angular/angular-cookies.js
@@ -0,0 +1,207 @@
+/**
+ * @license AngularJS v1.3.16
+ * (c) 2010-2014 Google, Inc. http://angularjs.org
+ * License: MIT
+ */
+(function(window, angular, undefined) {'use strict';
+
+/**
+ * @ngdoc module
+ * @name ngCookies
+ * @description
+ *
+ * # ngCookies
+ *
+ * The `ngCookies` module provides a convenient wrapper for reading and writing browser cookies.
+ *
+ *
+ * <div doc-module-components="ngCookies"></div>
+ *
+ * See {@link ngCookies.$cookies `$cookies`} and
+ * {@link ngCookies.$cookieStore `$cookieStore`} for usage.
+ */
+
+
+angular.module('ngCookies', ['ng']).
+  /**
+   * @ngdoc service
+   * @name $cookies
+   *
+   * @description
+   * Provides read/write access to browser's cookies.
+   *
+   * Only a simple Object is exposed and by adding or removing properties to/from this object, new
+   * cookies are created/deleted at the end of current $eval.
+   * The object's properties can only be strings.
+   *
+   * Requires the {@link ngCookies `ngCookies`} module to be installed.
+   *
+   * @example
+   *
+   * ```js
+   * angular.module('cookiesExample', ['ngCookies'])
+   *   .controller('ExampleController', ['$cookies', function($cookies) {
+   *     // Retrieving a cookie
+   *     var favoriteCookie = $cookies.myFavorite;
+   *     // Setting a cookie
+   *     $cookies.myFavorite = 'oatmeal';
+   *   }]);
+   * ```
+   */
+   factory('$cookies', ['$rootScope', '$browser', function($rootScope, $browser) {
+      var cookies = {},
+          lastCookies = {},
+          lastBrowserCookies,
+          runEval = false,
+          copy = angular.copy,
+          isUndefined = angular.isUndefined;
+
+      //creates a poller fn that copies all cookies from the $browser to service & inits the service
+      $browser.addPollFn(function() {
+        var currentCookies = $browser.cookies();
+        if (lastBrowserCookies != currentCookies) { //relies on browser.cookies() impl
+          lastBrowserCookies = currentCookies;
+          copy(currentCookies, lastCookies);
+          copy(currentCookies, cookies);
+          if (runEval) $rootScope.$apply();
+        }
+      })();
+
+      runEval = true;
+
+      //at the end of each eval, push cookies
+      //TODO: this should happen before the "delayed" watches fire, because if some cookies are not
+      //      strings or browser refuses to store some cookies, we update the model in the push fn.
+      $rootScope.$watch(push);
+
+      return cookies;
+
+
+      /**
+       * Pushes all the cookies from the service to the browser and verifies if all cookies were
+       * stored.
+       */
+      function push() {
+        var name,
+            value,
+            browserCookies,
+            updated;
+
+        //delete any cookies deleted in $cookies
+        for (name in lastCookies) {
+          if (isUndefined(cookies[name])) {
+            $browser.cookies(name, undefined);
+            delete lastCookies[name];
+          }
+        }
+
+        //update all cookies updated in $cookies
+        for (name in cookies) {
+          value = cookies[name];
+          if (!angular.isString(value)) {
+            value = '' + value;
+            cookies[name] = value;
+          }
+          if (value !== lastCookies[name]) {
+            $browser.cookies(name, value);
+            lastCookies[name] = value;
+            updated = true;
+          }
+        }
+
+        //verify what was actually stored
+        if (updated) {
+          browserCookies = $browser.cookies();
+
+          for (name in cookies) {
+            if (cookies[name] !== browserCookies[name]) {
+              //delete or reset all cookies that the browser dropped from $cookies
+              if (isUndefined(browserCookies[name])) {
+                delete cookies[name];
+                delete lastCookies[name];
+              } else {
+                cookies[name] = lastCookies[name] = browserCookies[name];
+              }
+            }
+          }
+        }
+      }
+    }]).
+
+
+  /**
+   * @ngdoc service
+   * @name $cookieStore
+   * @requires $cookies
+   *
+   * @description
+   * Provides a key-value (string-object) storage, that is backed by session cookies.
+   * Objects put or retrieved from this storage are automatically serialized or
+   * deserialized by angular's toJson/fromJson.
+   *
+   * Requires the {@link ngCookies `ngCookies`} module to be installed.
+   *
+   * @example
+   *
+   * ```js
+   * angular.module('cookieStoreExample', ['ngCookies'])
+   *   .controller('ExampleController', ['$cookieStore', function($cookieStore) {
+   *     // Put cookie
+   *     $cookieStore.put('myFavorite','oatmeal');
+   *     // Get cookie
+   *     var favoriteCookie = $cookieStore.get('myFavorite');
+   *     // Removing a cookie
+   *     $cookieStore.remove('myFavorite');
+   *   }]);
+   * ```
+   */
+   factory('$cookieStore', ['$cookies', function($cookies) {
+
+      return {
+        /**
+         * @ngdoc method
+         * @name $cookieStore#get
+         *
+         * @description
+         * Returns the value of given cookie key
+         *
+         * @param {string} key Id to use for lookup.
+         * @returns {Object} Deserialized cookie value.
+         */
+        get: function(key) {
+          var value = $cookies[key];
+          return value ? angular.fromJson(value) : value;
+        },
+
+        /**
+         * @ngdoc method
+         * @name $cookieStore#put
+         *
+         * @description
+         * Sets a value for given cookie key
+         *
+         * @param {string} key Id for the `value`.
+         * @param {Object} value Value to be stored.
+         */
+        put: function(key, value) {
+          $cookies[key] = angular.toJson(value);
+        },
+
+        /**
+         * @ngdoc method
+         * @name $cookieStore#remove
+         *
+         * @description
+         * Remove given cookie
+         *
+         * @param {string} key Id of the key-value pair to delete.
+         */
+        remove: function(key) {
+          delete $cookies[key];
+        }
+      };
+
+    }]);
+
+
+})(window, window.angular);
diff --git a/guacamole/src/main/webapp/lib/angular/angular-route.js b/guacamole/src/main/webapp/lib/angular/angular-route.js
new file mode 100644
index 0000000..fba0d9b
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/angular/angular-route.js
@@ -0,0 +1,991 @@
+/**
+ * @license AngularJS v1.3.16
+ * (c) 2010-2014 Google, Inc. http://angularjs.org
+ * License: MIT
+ */
+(function(window, angular, undefined) {'use strict';
+
+/**
+ * @ngdoc module
+ * @name ngRoute
+ * @description
+ *
+ * # ngRoute
+ *
+ * The `ngRoute` module provides routing and deeplinking services and directives for angular apps.
+ *
+ * ## Example
+ * See {@link ngRoute.$route#example $route} for an example of configuring and using `ngRoute`.
+ *
+ *
+ * <div doc-module-components="ngRoute"></div>
+ */
+ /* global -ngRouteModule */
+var ngRouteModule = angular.module('ngRoute', ['ng']).
+                        provider('$route', $RouteProvider),
+    $routeMinErr = angular.$$minErr('ngRoute');
+
+/**
+ * @ngdoc provider
+ * @name $routeProvider
+ *
+ * @description
+ *
+ * Used for configuring routes.
+ *
+ * ## Example
+ * See {@link ngRoute.$route#example $route} for an example of configuring and using `ngRoute`.
+ *
+ * ## Dependencies
+ * Requires the {@link ngRoute `ngRoute`} module to be installed.
+ */
+function $RouteProvider() {
+  function inherit(parent, extra) {
+    return angular.extend(Object.create(parent), extra);
+  }
+
+  var routes = {};
+
+  /**
+   * @ngdoc method
+   * @name $routeProvider#when
+   *
+   * @param {string} path Route path (matched against `$location.path`). If `$location.path`
+   *    contains redundant trailing slash or is missing one, the route will still match and the
+   *    `$location.path` will be updated to add or drop the trailing slash to exactly match the
+   *    route definition.
+   *
+   *    * `path` can contain named groups starting with a colon: e.g. `:name`. All characters up
+   *        to the next slash are matched and stored in `$routeParams` under the given `name`
+   *        when the route matches.
+   *    * `path` can contain named groups starting with a colon and ending with a star:
+   *        e.g.`:name*`. All characters are eagerly stored in `$routeParams` under the given `name`
+   *        when the route matches.
+   *    * `path` can contain optional named groups with a question mark: e.g.`:name?`.
+   *
+   *    For example, routes like `/color/:color/largecode/:largecode*\/edit` will match
+   *    `/color/brown/largecode/code/with/slashes/edit` and extract:
+   *
+   *    * `color: brown`
+   *    * `largecode: code/with/slashes`.
+   *
+   *
+   * @param {Object} route Mapping information to be assigned to `$route.current` on route
+   *    match.
+   *
+   *    Object properties:
+   *
+   *    - `controller` – `{(string|function()=}` – Controller fn that should be associated with
+   *      newly created scope or the name of a {@link angular.Module#controller registered
+   *      controller} if passed as a string.
+   *    - `controllerAs` – `{string=}` – A controller alias name. If present the controller will be
+   *      published to scope under the `controllerAs` name.
+   *    - `template` – `{string=|function()=}` – html template as a string or a function that
+   *      returns an html template as a string which should be used by {@link
+   *      ngRoute.directive:ngView ngView} or {@link ng.directive:ngInclude ngInclude} directives.
+   *      This property takes precedence over `templateUrl`.
+   *
+   *      If `template` is a function, it will be called with the following parameters:
+   *
+   *      - `{Array.<Object>}` - route parameters extracted from the current
+   *        `$location.path()` by applying the current route
+   *
+   *    - `templateUrl` – `{string=|function()=}` – path or function that returns a path to an html
+   *      template that should be used by {@link ngRoute.directive:ngView ngView}.
+   *
+   *      If `templateUrl` is a function, it will be called with the following parameters:
+   *
+   *      - `{Array.<Object>}` - route parameters extracted from the current
+   *        `$location.path()` by applying the current route
+   *
+   *    - `resolve` - `{Object.<string, function>=}` - An optional map of dependencies which should
+   *      be injected into the controller. If any of these dependencies are promises, the router
+   *      will wait for them all to be resolved or one to be rejected before the controller is
+   *      instantiated.
+   *      If all the promises are resolved successfully, the values of the resolved promises are
+   *      injected and {@link ngRoute.$route#$routeChangeSuccess $routeChangeSuccess} event is
+   *      fired. If any of the promises are rejected the
+   *      {@link ngRoute.$route#$routeChangeError $routeChangeError} event is fired. The map object
+   *      is:
+   *
+   *      - `key` – `{string}`: a name of a dependency to be injected into the controller.
+   *      - `factory` - `{string|function}`: If `string` then it is an alias for a service.
+   *        Otherwise if function, then it is {@link auto.$injector#invoke injected}
+   *        and the return value is treated as the dependency. If the result is a promise, it is
+   *        resolved before its value is injected into the controller. Be aware that
+   *        `ngRoute.$routeParams` will still refer to the previous route within these resolve
+   *        functions.  Use `$route.current.params` to access the new route parameters, instead.
+   *
+   *    - `redirectTo` – {(string|function())=} – value to update
+   *      {@link ng.$location $location} path with and trigger route redirection.
+   *
+   *      If `redirectTo` is a function, it will be called with the following parameters:
+   *
+   *      - `{Object.<string>}` - route parameters extracted from the current
+   *        `$location.path()` by applying the current route templateUrl.
+   *      - `{string}` - current `$location.path()`
+   *      - `{Object}` - current `$location.search()`
+   *
+   *      The custom `redirectTo` function is expected to return a string which will be used
+   *      to update `$location.path()` and `$location.search()`.
+   *
+   *    - `[reloadOnSearch=true]` - {boolean=} - reload route when only `$location.search()`
+   *      or `$location.hash()` changes.
+   *
+   *      If the option is set to `false` and url in the browser changes, then
+   *      `$routeUpdate` event is broadcasted on the root scope.
+   *
+   *    - `[caseInsensitiveMatch=false]` - {boolean=} - match routes without being case sensitive
+   *
+   *      If the option is set to `true`, then the particular route can be matched without being
+   *      case sensitive
+   *
+   * @returns {Object} self
+   *
+   * @description
+   * Adds a new route definition to the `$route` service.
+   */
+  this.when = function(path, route) {
+    //copy original route object to preserve params inherited from proto chain
+    var routeCopy = angular.copy(route);
+    if (angular.isUndefined(routeCopy.reloadOnSearch)) {
+      routeCopy.reloadOnSearch = true;
+    }
+    if (angular.isUndefined(routeCopy.caseInsensitiveMatch)) {
+      routeCopy.caseInsensitiveMatch = this.caseInsensitiveMatch;
+    }
+    routes[path] = angular.extend(
+      routeCopy,
+      path && pathRegExp(path, routeCopy)
+    );
+
+    // create redirection for trailing slashes
+    if (path) {
+      var redirectPath = (path[path.length - 1] == '/')
+            ? path.substr(0, path.length - 1)
+            : path + '/';
+
+      routes[redirectPath] = angular.extend(
+        {redirectTo: path},
+        pathRegExp(redirectPath, routeCopy)
+      );
+    }
+
+    return this;
+  };
+
+  /**
+   * @ngdoc property
+   * @name $routeProvider#caseInsensitiveMatch
+   * @description
+   *
+   * A boolean property indicating if routes defined
+   * using this provider should be matched using a case insensitive
+   * algorithm. Defaults to `false`.
+   */
+  this.caseInsensitiveMatch = false;
+
+   /**
+    * @param path {string} path
+    * @param opts {Object} options
+    * @return {?Object}
+    *
+    * @description
+    * Normalizes the given path, returning a regular expression
+    * and the original path.
+    *
+    * Inspired by pathRexp in visionmedia/express/lib/utils.js.
+    */
+  function pathRegExp(path, opts) {
+    var insensitive = opts.caseInsensitiveMatch,
+        ret = {
+          originalPath: path,
+          regexp: path
+        },
+        keys = ret.keys = [];
+
+    path = path
+      .replace(/([().])/g, '\\$1')
+      .replace(/(\/)?:(\w+)([\?\*])?/g, function(_, slash, key, option) {
+        var optional = option === '?' ? option : null;
+        var star = option === '*' ? option : null;
+        keys.push({ name: key, optional: !!optional });
+        slash = slash || '';
+        return ''
+          + (optional ? '' : slash)
+          + '(?:'
+          + (optional ? slash : '')
+          + (star && '(.+?)' || '([^/]+)')
+          + (optional || '')
+          + ')'
+          + (optional || '');
+      })
+      .replace(/([\/$\*])/g, '\\$1');
+
+    ret.regexp = new RegExp('^' + path + '$', insensitive ? 'i' : '');
+    return ret;
+  }
+
+  /**
+   * @ngdoc method
+   * @name $routeProvider#otherwise
+   *
+   * @description
+   * Sets route definition that will be used on route change when no other route definition
+   * is matched.
+   *
+   * @param {Object|string} params Mapping information to be assigned to `$route.current`.
+   * If called with a string, the value maps to `redirectTo`.
+   * @returns {Object} self
+   */
+  this.otherwise = function(params) {
+    if (typeof params === 'string') {
+      params = {redirectTo: params};
+    }
+    this.when(null, params);
+    return this;
+  };
+
+
+  this.$get = ['$rootScope',
+               '$location',
+               '$routeParams',
+               '$q',
+               '$injector',
+               '$templateRequest',
+               '$sce',
+      function($rootScope, $location, $routeParams, $q, $injector, $templateRequest, $sce) {
+
+    /**
+     * @ngdoc service
+     * @name $route
+     * @requires $location
+     * @requires $routeParams
+     *
+     * @property {Object} current Reference to the current route definition.
+     * The route definition contains:
+     *
+     *   - `controller`: The controller constructor as define in route definition.
+     *   - `locals`: A map of locals which is used by {@link ng.$controller $controller} service for
+     *     controller instantiation. The `locals` contain
+     *     the resolved values of the `resolve` map. Additionally the `locals` also contain:
+     *
+     *     - `$scope` - The current route scope.
+     *     - `$template` - The current route template HTML.
+     *
+     * @property {Object} routes Object with all route configuration Objects as its properties.
+     *
+     * @description
+     * `$route` is used for deep-linking URLs to controllers and views (HTML partials).
+     * It watches `$location.url()` and tries to map the path to an existing route definition.
+     *
+     * Requires the {@link ngRoute `ngRoute`} module to be installed.
+     *
+     * You can define routes through {@link ngRoute.$routeProvider $routeProvider}'s API.
+     *
+     * The `$route` service is typically used in conjunction with the
+     * {@link ngRoute.directive:ngView `ngView`} directive and the
+     * {@link ngRoute.$routeParams `$routeParams`} service.
+     *
+     * @example
+     * This example shows how changing the URL hash causes the `$route` to match a route against the
+     * URL, and the `ngView` pulls in the partial.
+     *
+     * <example name="$route-service" module="ngRouteExample"
+     *          deps="angular-route.js" fixBase="true">
+     *   <file name="index.html">
+     *     <div ng-controller="MainController">
+     *       Choose:
+     *       <a href="Book/Moby">Moby</a> |
+     *       <a href="Book/Moby/ch/1">Moby: Ch1</a> |
+     *       <a href="Book/Gatsby">Gatsby</a> |
+     *       <a href="Book/Gatsby/ch/4?key=value">Gatsby: Ch4</a> |
+     *       <a href="Book/Scarlet">Scarlet Letter</a><br/>
+     *
+     *       <div ng-view></div>
+     *
+     *       <hr />
+     *
+     *       <pre>$location.path() = {{$location.path()}}</pre>
+     *       <pre>$route.current.templateUrl = {{$route.current.templateUrl}}</pre>
+     *       <pre>$route.current.params = {{$route.current.params}}</pre>
+     *       <pre>$route.current.scope.name = {{$route.current.scope.name}}</pre>
+     *       <pre>$routeParams = {{$routeParams}}</pre>
+     *     </div>
+     *   </file>
+     *
+     *   <file name="book.html">
+     *     controller: {{name}}<br />
+     *     Book Id: {{params.bookId}}<br />
+     *   </file>
+     *
+     *   <file name="chapter.html">
+     *     controller: {{name}}<br />
+     *     Book Id: {{params.bookId}}<br />
+     *     Chapter Id: {{params.chapterId}}
+     *   </file>
+     *
+     *   <file name="script.js">
+     *     angular.module('ngRouteExample', ['ngRoute'])
+     *
+     *      .controller('MainController', function($scope, $route, $routeParams, $location) {
+     *          $scope.$route = $route;
+     *          $scope.$location = $location;
+     *          $scope.$routeParams = $routeParams;
+     *      })
+     *
+     *      .controller('BookController', function($scope, $routeParams) {
+     *          $scope.name = "BookController";
+     *          $scope.params = $routeParams;
+     *      })
+     *
+     *      .controller('ChapterController', function($scope, $routeParams) {
+     *          $scope.name = "ChapterController";
+     *          $scope.params = $routeParams;
+     *      })
+     *
+     *     .config(function($routeProvider, $locationProvider) {
+     *       $routeProvider
+     *        .when('/Book/:bookId', {
+     *         templateUrl: 'book.html',
+     *         controller: 'BookController',
+     *         resolve: {
+     *           // I will cause a 1 second delay
+     *           delay: function($q, $timeout) {
+     *             var delay = $q.defer();
+     *             $timeout(delay.resolve, 1000);
+     *             return delay.promise;
+     *           }
+     *         }
+     *       })
+     *       .when('/Book/:bookId/ch/:chapterId', {
+     *         templateUrl: 'chapter.html',
+     *         controller: 'ChapterController'
+     *       });
+     *
+     *       // configure html5 to get links working on jsfiddle
+     *       $locationProvider.html5Mode(true);
+     *     });
+     *
+     *   </file>
+     *
+     *   <file name="protractor.js" type="protractor">
+     *     it('should load and compile correct template', function() {
+     *       element(by.linkText('Moby: Ch1')).click();
+     *       var content = element(by.css('[ng-view]')).getText();
+     *       expect(content).toMatch(/controller\: ChapterController/);
+     *       expect(content).toMatch(/Book Id\: Moby/);
+     *       expect(content).toMatch(/Chapter Id\: 1/);
+     *
+     *       element(by.partialLinkText('Scarlet')).click();
+     *
+     *       content = element(by.css('[ng-view]')).getText();
+     *       expect(content).toMatch(/controller\: BookController/);
+     *       expect(content).toMatch(/Book Id\: Scarlet/);
+     *     });
+     *   </file>
+     * </example>
+     */
+
+    /**
+     * @ngdoc event
+     * @name $route#$routeChangeStart
+     * @eventType broadcast on root scope
+     * @description
+     * Broadcasted before a route change. At this  point the route services starts
+     * resolving all of the dependencies needed for the route change to occur.
+     * Typically this involves fetching the view template as well as any dependencies
+     * defined in `resolve` route property. Once  all of the dependencies are resolved
+     * `$routeChangeSuccess` is fired.
+     *
+     * The route change (and the `$location` change that triggered it) can be prevented
+     * by calling `preventDefault` method of the event. See {@link ng.$rootScope.Scope#$on}
+     * for more details about event object.
+     *
+     * @param {Object} angularEvent Synthetic event object.
+     * @param {Route} next Future route information.
+     * @param {Route} current Current route information.
+     */
+
+    /**
+     * @ngdoc event
+     * @name $route#$routeChangeSuccess
+     * @eventType broadcast on root scope
+     * @description
+     * Broadcasted after a route dependencies are resolved.
+     * {@link ngRoute.directive:ngView ngView} listens for the directive
+     * to instantiate the controller and render the view.
+     *
+     * @param {Object} angularEvent Synthetic event object.
+     * @param {Route} current Current route information.
+     * @param {Route|Undefined} previous Previous route information, or undefined if current is
+     * first route entered.
+     */
+
+    /**
+     * @ngdoc event
+     * @name $route#$routeChangeError
+     * @eventType broadcast on root scope
+     * @description
+     * Broadcasted if any of the resolve promises are rejected.
+     *
+     * @param {Object} angularEvent Synthetic event object
+     * @param {Route} current Current route information.
+     * @param {Route} previous Previous route information.
+     * @param {Route} rejection Rejection of the promise. Usually the error of the failed promise.
+     */
+
+    /**
+     * @ngdoc event
+     * @name $route#$routeUpdate
+     * @eventType broadcast on root scope
+     * @description
+     * The `reloadOnSearch` property has been set to false, and we are reusing the same
+     * instance of the Controller.
+     *
+     * @param {Object} angularEvent Synthetic event object
+     * @param {Route} current Current/previous route information.
+     */
+
+    var forceReload = false,
+        preparedRoute,
+        preparedRouteIsUpdateOnly,
+        $route = {
+          routes: routes,
+
+          /**
+           * @ngdoc method
+           * @name $route#reload
+           *
+           * @description
+           * Causes `$route` service to reload the current route even if
+           * {@link ng.$location $location} hasn't changed.
+           *
+           * As a result of that, {@link ngRoute.directive:ngView ngView}
+           * creates new scope and reinstantiates the controller.
+           */
+          reload: function() {
+            forceReload = true;
+            $rootScope.$evalAsync(function() {
+              // Don't support cancellation of a reload for now...
+              prepareRoute();
+              commitRoute();
+            });
+          },
+
+          /**
+           * @ngdoc method
+           * @name $route#updateParams
+           *
+           * @description
+           * Causes `$route` service to update the current URL, replacing
+           * current route parameters with those specified in `newParams`.
+           * Provided property names that match the route's path segment
+           * definitions will be interpolated into the location's path, while
+           * remaining properties will be treated as query params.
+           *
+           * @param {!Object<string, string>} newParams mapping of URL parameter names to values
+           */
+          updateParams: function(newParams) {
+            if (this.current && this.current.$$route) {
+              newParams = angular.extend({}, this.current.params, newParams);
+              $location.path(interpolate(this.current.$$route.originalPath, newParams));
+              // interpolate modifies newParams, only query params are left
+              $location.search(newParams);
+            } else {
+              throw $routeMinErr('norout', 'Tried updating route when with no current route');
+            }
+          }
+        };
+
+    $rootScope.$on('$locationChangeStart', prepareRoute);
+    $rootScope.$on('$locationChangeSuccess', commitRoute);
+
+    return $route;
+
+    /////////////////////////////////////////////////////
+
+    /**
+     * @param on {string} current url
+     * @param route {Object} route regexp to match the url against
+     * @return {?Object}
+     *
+     * @description
+     * Check if the route matches the current url.
+     *
+     * Inspired by match in
+     * visionmedia/express/lib/router/router.js.
+     */
+    function switchRouteMatcher(on, route) {
+      var keys = route.keys,
+          params = {};
+
+      if (!route.regexp) return null;
+
+      var m = route.regexp.exec(on);
+      if (!m) return null;
+
+      for (var i = 1, len = m.length; i < len; ++i) {
+        var key = keys[i - 1];
+
+        var val = m[i];
+
+        if (key && val) {
+          params[key.name] = val;
+        }
+      }
+      return params;
+    }
+
+    function prepareRoute($locationEvent) {
+      var lastRoute = $route.current;
+
+      preparedRoute = parseRoute();
+      preparedRouteIsUpdateOnly = preparedRoute && lastRoute && preparedRoute.$$route === lastRoute.$$route
+          && angular.equals(preparedRoute.pathParams, lastRoute.pathParams)
+          && !preparedRoute.reloadOnSearch && !forceReload;
+
+      if (!preparedRouteIsUpdateOnly && (lastRoute || preparedRoute)) {
+        if ($rootScope.$broadcast('$routeChangeStart', preparedRoute, lastRoute).defaultPrevented) {
+          if ($locationEvent) {
+            $locationEvent.preventDefault();
+          }
+        }
+      }
+    }
+
+    function commitRoute() {
+      var lastRoute = $route.current;
+      var nextRoute = preparedRoute;
+
+      if (preparedRouteIsUpdateOnly) {
+        lastRoute.params = nextRoute.params;
+        angular.copy(lastRoute.params, $routeParams);
+        $rootScope.$broadcast('$routeUpdate', lastRoute);
+      } else if (nextRoute || lastRoute) {
+        forceReload = false;
+        $route.current = nextRoute;
+        if (nextRoute) {
+          if (nextRoute.redirectTo) {
+            if (angular.isString(nextRoute.redirectTo)) {
+              $location.path(interpolate(nextRoute.redirectTo, nextRoute.params)).search(nextRoute.params)
+                       .replace();
+            } else {
+              $location.url(nextRoute.redirectTo(nextRoute.pathParams, $location.path(), $location.search()))
+                       .replace();
+            }
+          }
+        }
+
+        $q.when(nextRoute).
+          then(function() {
+            if (nextRoute) {
+              var locals = angular.extend({}, nextRoute.resolve),
+                  template, templateUrl;
+
+              angular.forEach(locals, function(value, key) {
+                locals[key] = angular.isString(value) ?
+                    $injector.get(value) : $injector.invoke(value, null, null, key);
+              });
+
+              if (angular.isDefined(template = nextRoute.template)) {
+                if (angular.isFunction(template)) {
+                  template = template(nextRoute.params);
+                }
+              } else if (angular.isDefined(templateUrl = nextRoute.templateUrl)) {
+                if (angular.isFunction(templateUrl)) {
+                  templateUrl = templateUrl(nextRoute.params);
+                }
+                templateUrl = $sce.getTrustedResourceUrl(templateUrl);
+                if (angular.isDefined(templateUrl)) {
+                  nextRoute.loadedTemplateUrl = templateUrl;
+                  template = $templateRequest(templateUrl);
+                }
+              }
+              if (angular.isDefined(template)) {
+                locals['$template'] = template;
+              }
+              return $q.all(locals);
+            }
+          }).
+          // after route change
+          then(function(locals) {
+            if (nextRoute == $route.current) {
+              if (nextRoute) {
+                nextRoute.locals = locals;
+                angular.copy(nextRoute.params, $routeParams);
+              }
+              $rootScope.$broadcast('$routeChangeSuccess', nextRoute, lastRoute);
+            }
+          }, function(error) {
+            if (nextRoute == $route.current) {
+              $rootScope.$broadcast('$routeChangeError', nextRoute, lastRoute, error);
+            }
+          });
+      }
+    }
+
+
+    /**
+     * @returns {Object} the current active route, by matching it against the URL
+     */
+    function parseRoute() {
+      // Match a route
+      var params, match;
+      angular.forEach(routes, function(route, path) {
+        if (!match && (params = switchRouteMatcher($location.path(), route))) {
+          match = inherit(route, {
+            params: angular.extend({}, $location.search(), params),
+            pathParams: params});
+          match.$$route = route;
+        }
+      });
+      // No route matched; fallback to "otherwise" route
+      return match || routes[null] && inherit(routes[null], {params: {}, pathParams:{}});
+    }
+
+    /**
+     * @returns {string} interpolation of the redirect path with the parameters
+     */
+    function interpolate(string, params) {
+      var result = [];
+      angular.forEach((string || '').split(':'), function(segment, i) {
+        if (i === 0) {
+          result.push(segment);
+        } else {
+          var segmentMatch = segment.match(/(\w+)(?:[?*])?(.*)/);
+          var key = segmentMatch[1];
+          result.push(params[key]);
+          result.push(segmentMatch[2] || '');
+          delete params[key];
+        }
+      });
+      return result.join('');
+    }
+  }];
+}
+
+ngRouteModule.provider('$routeParams', $RouteParamsProvider);
+
+
+/**
+ * @ngdoc service
+ * @name $routeParams
+ * @requires $route
+ *
+ * @description
+ * The `$routeParams` service allows you to retrieve the current set of route parameters.
+ *
+ * Requires the {@link ngRoute `ngRoute`} module to be installed.
+ *
+ * The route parameters are a combination of {@link ng.$location `$location`}'s
+ * {@link ng.$location#search `search()`} and {@link ng.$location#path `path()`}.
+ * The `path` parameters are extracted when the {@link ngRoute.$route `$route`} path is matched.
+ *
+ * In case of parameter name collision, `path` params take precedence over `search` params.
+ *
+ * The service guarantees that the identity of the `$routeParams` object will remain unchanged
+ * (but its properties will likely change) even when a route change occurs.
+ *
+ * Note that the `$routeParams` are only updated *after* a route change completes successfully.
+ * This means that you cannot rely on `$routeParams` being correct in route resolve functions.
+ * Instead you can use `$route.current.params` to access the new route's parameters.
+ *
+ * @example
+ * ```js
+ *  // Given:
+ *  // URL: http://server.com/index.html#/Chapter/1/Section/2?search=moby
+ *  // Route: /Chapter/:chapterId/Section/:sectionId
+ *  //
+ *  // Then
+ *  $routeParams ==> {chapterId:'1', sectionId:'2', search:'moby'}
+ * ```
+ */
+function $RouteParamsProvider() {
+  this.$get = function() { return {}; };
+}
+
+ngRouteModule.directive('ngView', ngViewFactory);
+ngRouteModule.directive('ngView', ngViewFillContentFactory);
+
+
+/**
+ * @ngdoc directive
+ * @name ngView
+ * @restrict ECA
+ *
+ * @description
+ * # Overview
+ * `ngView` is a directive that complements the {@link ngRoute.$route $route} service by
+ * including the rendered template of the current route into the main layout (`index.html`) file.
+ * Every time the current route changes, the included view changes with it according to the
+ * configuration of the `$route` service.
+ *
+ * Requires the {@link ngRoute `ngRoute`} module to be installed.
+ *
+ * @animations
+ * enter - animation is used to bring new content into the browser.
+ * leave - animation is used to animate existing content away.
+ *
+ * The enter and leave animation occur concurrently.
+ *
+ * @scope
+ * @priority 400
+ * @param {string=} onload Expression to evaluate whenever the view updates.
+ *
+ * @param {string=} autoscroll Whether `ngView` should call {@link ng.$anchorScroll
+ *                  $anchorScroll} to scroll the viewport after the view is updated.
+ *
+ *                  - If the attribute is not set, disable scrolling.
+ *                  - If the attribute is set without value, enable scrolling.
+ *                  - Otherwise enable scrolling only if the `autoscroll` attribute value evaluated
+ *                    as an expression yields a truthy value.
+ * @example
+    <example name="ngView-directive" module="ngViewExample"
+             deps="angular-route.js;angular-animate.js"
+             animations="true" fixBase="true">
+      <file name="index.html">
+        <div ng-controller="MainCtrl as main">
+          Choose:
+          <a href="Book/Moby">Moby</a> |
+          <a href="Book/Moby/ch/1">Moby: Ch1</a> |
+          <a href="Book/Gatsby">Gatsby</a> |
+          <a href="Book/Gatsby/ch/4?key=value">Gatsby: Ch4</a> |
+          <a href="Book/Scarlet">Scarlet Letter</a><br/>
+
+          <div class="view-animate-container">
+            <div ng-view class="view-animate"></div>
+          </div>
+          <hr />
+
+          <pre>$location.path() = {{main.$location.path()}}</pre>
+          <pre>$route.current.templateUrl = {{main.$route.current.templateUrl}}</pre>
+          <pre>$route.current.params = {{main.$route.current.params}}</pre>
+          <pre>$routeParams = {{main.$routeParams}}</pre>
+        </div>
+      </file>
+
+      <file name="book.html">
+        <div>
+          controller: {{book.name}}<br />
+          Book Id: {{book.params.bookId}}<br />
+        </div>
+      </file>
+
+      <file name="chapter.html">
+        <div>
+          controller: {{chapter.name}}<br />
+          Book Id: {{chapter.params.bookId}}<br />
+          Chapter Id: {{chapter.params.chapterId}}
+        </div>
+      </file>
+
+      <file name="animations.css">
+        .view-animate-container {
+          position:relative;
+          height:100px!important;
+          background:white;
+          border:1px solid black;
+          height:40px;
+          overflow:hidden;
+        }
+
+        .view-animate {
+          padding:10px;
+        }
+
+        .view-animate.ng-enter, .view-animate.ng-leave {
+          -webkit-transition:all cubic-bezier(0.250, 0.460, 0.450, 0.940) 1.5s;
+          transition:all cubic-bezier(0.250, 0.460, 0.450, 0.940) 1.5s;
+
+          display:block;
+          width:100%;
+          border-left:1px solid black;
+
+          position:absolute;
+          top:0;
+          left:0;
+          right:0;
+          bottom:0;
+          padding:10px;
+        }
+
+        .view-animate.ng-enter {
+          left:100%;
+        }
+        .view-animate.ng-enter.ng-enter-active {
+          left:0;
+        }
+        .view-animate.ng-leave.ng-leave-active {
+          left:-100%;
+        }
+      </file>
+
+      <file name="script.js">
+        angular.module('ngViewExample', ['ngRoute', 'ngAnimate'])
+          .config(['$routeProvider', '$locationProvider',
+            function($routeProvider, $locationProvider) {
+              $routeProvider
+                .when('/Book/:bookId', {
+                  templateUrl: 'book.html',
+                  controller: 'BookCtrl',
+                  controllerAs: 'book'
+                })
+                .when('/Book/:bookId/ch/:chapterId', {
+                  templateUrl: 'chapter.html',
+                  controller: 'ChapterCtrl',
+                  controllerAs: 'chapter'
+                });
+
+              $locationProvider.html5Mode(true);
+          }])
+          .controller('MainCtrl', ['$route', '$routeParams', '$location',
+            function($route, $routeParams, $location) {
+              this.$route = $route;
+              this.$location = $location;
+              this.$routeParams = $routeParams;
+          }])
+          .controller('BookCtrl', ['$routeParams', function($routeParams) {
+            this.name = "BookCtrl";
+            this.params = $routeParams;
+          }])
+          .controller('ChapterCtrl', ['$routeParams', function($routeParams) {
+            this.name = "ChapterCtrl";
+            this.params = $routeParams;
+          }]);
+
+      </file>
+
+      <file name="protractor.js" type="protractor">
+        it('should load and compile correct template', function() {
+          element(by.linkText('Moby: Ch1')).click();
+          var content = element(by.css('[ng-view]')).getText();
+          expect(content).toMatch(/controller\: ChapterCtrl/);
+          expect(content).toMatch(/Book Id\: Moby/);
+          expect(content).toMatch(/Chapter Id\: 1/);
+
+          element(by.partialLinkText('Scarlet')).click();
+
+          content = element(by.css('[ng-view]')).getText();
+          expect(content).toMatch(/controller\: BookCtrl/);
+          expect(content).toMatch(/Book Id\: Scarlet/);
+        });
+      </file>
+    </example>
+ */
+
+
+/**
+ * @ngdoc event
+ * @name ngView#$viewContentLoaded
+ * @eventType emit on the current ngView scope
+ * @description
+ * Emitted every time the ngView content is reloaded.
+ */
+ngViewFactory.$inject = ['$route', '$anchorScroll', '$animate'];
+function ngViewFactory($route, $anchorScroll, $animate) {
+  return {
+    restrict: 'ECA',
+    terminal: true,
+    priority: 400,
+    transclude: 'element',
+    link: function(scope, $element, attr, ctrl, $transclude) {
+        var currentScope,
+            currentElement,
+            previousLeaveAnimation,
+            autoScrollExp = attr.autoscroll,
+            onloadExp = attr.onload || '';
+
+        scope.$on('$routeChangeSuccess', update);
+        update();
+
+        function cleanupLastView() {
+          if (previousLeaveAnimation) {
+            $animate.cancel(previousLeaveAnimation);
+            previousLeaveAnimation = null;
+          }
+
+          if (currentScope) {
+            currentScope.$destroy();
+            currentScope = null;
+          }
+          if (currentElement) {
+            previousLeaveAnimation = $animate.leave(currentElement);
+            previousLeaveAnimation.then(function() {
+              previousLeaveAnimation = null;
+            });
+            currentElement = null;
+          }
+        }
+
+        function update() {
+          var locals = $route.current && $route.current.locals,
+              template = locals && locals.$template;
+
+          if (angular.isDefined(template)) {
+            var newScope = scope.$new();
+            var current = $route.current;
+
+            // Note: This will also link all children of ng-view that were contained in the original
+            // html. If that content contains controllers, ... they could pollute/change the scope.
+            // However, using ng-view on an element with additional content does not make sense...
+            // Note: We can't remove them in the cloneAttchFn of $transclude as that
+            // function is called before linking the content, which would apply child
+            // directives to non existing elements.
+            var clone = $transclude(newScope, function(clone) {
+              $animate.enter(clone, null, currentElement || $element).then(function onNgViewEnter() {
+                if (angular.isDefined(autoScrollExp)
+                  && (!autoScrollExp || scope.$eval(autoScrollExp))) {
+                  $anchorScroll();
+                }
+              });
+              cleanupLastView();
+            });
+
+            currentElement = clone;
+            currentScope = current.scope = newScope;
+            currentScope.$emit('$viewContentLoaded');
+            currentScope.$eval(onloadExp);
+          } else {
+            cleanupLastView();
+          }
+        }
+    }
+  };
+}
+
+// This directive is called during the $transclude call of the first `ngView` directive.
+// It will replace and compile the content of the element with the loaded template.
+// We need this directive so that the element content is already filled when
+// the link function of another directive on the same element as ngView
+// is called.
+ngViewFillContentFactory.$inject = ['$compile', '$controller', '$route'];
+function ngViewFillContentFactory($compile, $controller, $route) {
+  return {
+    restrict: 'ECA',
+    priority: -400,
+    link: function(scope, $element) {
+      var current = $route.current,
+          locals = current.locals;
+
+      $element.html(locals.$template);
+
+      var link = $compile($element.contents());
+
+      if (current.controller) {
+        locals.$scope = scope;
+        var controller = $controller(current.controller, locals);
+        if (current.controllerAs) {
+          scope[current.controllerAs] = controller;
+        }
+        $element.data('$ngControllerController', controller);
+        $element.children().data('$ngControllerController', controller);
+      }
+
+      link(scope);
+    }
+  };
+}
+
+
+})(window, window.angular);
diff --git a/guacamole/src/main/webapp/lib/angular/angular-touch.js b/guacamole/src/main/webapp/lib/angular/angular-touch.js
new file mode 100644
index 0000000..d5056be
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/angular/angular-touch.js
@@ -0,0 +1,631 @@
+/**
+ * @license AngularJS v1.3.16
+ * (c) 2010-2014 Google, Inc. http://angularjs.org
+ * License: MIT
+ */
+(function(window, angular, undefined) {'use strict';
+
+/**
+ * @ngdoc module
+ * @name ngTouch
+ * @description
+ *
+ * # ngTouch
+ *
+ * The `ngTouch` module provides touch events and other helpers for touch-enabled devices.
+ * The implementation is based on jQuery Mobile touch event handling
+ * ([jquerymobile.com](http://jquerymobile.com/)).
+ *
+ *
+ * See {@link ngTouch.$swipe `$swipe`} for usage.
+ *
+ * <div doc-module-components="ngTouch"></div>
+ *
+ */
+
+// define ngTouch module
+/* global -ngTouch */
+var ngTouch = angular.module('ngTouch', []);
+
+function nodeName_(element) {
+  return angular.lowercase(element.nodeName || (element[0] && element[0].nodeName));
+}
+
+/* global ngTouch: false */
+
+    /**
+     * @ngdoc service
+     * @name $swipe
+     *
+     * @description
+     * The `$swipe` service is a service that abstracts the messier details of hold-and-drag swipe
+     * behavior, to make implementing swipe-related directives more convenient.
+     *
+     * Requires the {@link ngTouch `ngTouch`} module to be installed.
+     *
+     * `$swipe` is used by the `ngSwipeLeft` and `ngSwipeRight` directives in `ngTouch`, and by
+     * `ngCarousel` in a separate component.
+     *
+     * # Usage
+     * The `$swipe` service is an object with a single method: `bind`. `bind` takes an element
+     * which is to be watched for swipes, and an object with four handler functions. See the
+     * documentation for `bind` below.
+     */
+
+ngTouch.factory('$swipe', [function() {
+  // The total distance in any direction before we make the call on swipe vs. scroll.
+  var MOVE_BUFFER_RADIUS = 10;
+
+  var POINTER_EVENTS = {
+    'mouse': {
+      start: 'mousedown',
+      move: 'mousemove',
+      end: 'mouseup'
+    },
+    'touch': {
+      start: 'touchstart',
+      move: 'touchmove',
+      end: 'touchend',
+      cancel: 'touchcancel'
+    }
+  };
+
+  function getCoordinates(event) {
+    var originalEvent = event.originalEvent || event;
+    var touches = originalEvent.touches && originalEvent.touches.length ? originalEvent.touches : [originalEvent];
+    var e = (originalEvent.changedTouches && originalEvent.changedTouches[0]) || touches[0];
+
+    return {
+      x: e.clientX,
+      y: e.clientY
+    };
+  }
+
+  function getEvents(pointerTypes, eventType) {
+    var res = [];
+    angular.forEach(pointerTypes, function(pointerType) {
+      var eventName = POINTER_EVENTS[pointerType][eventType];
+      if (eventName) {
+        res.push(eventName);
+      }
+    });
+    return res.join(' ');
+  }
+
+  return {
+    /**
+     * @ngdoc method
+     * @name $swipe#bind
+     *
+     * @description
+     * The main method of `$swipe`. It takes an element to be watched for swipe motions, and an
+     * object containing event handlers.
+     * The pointer types that should be used can be specified via the optional
+     * third argument, which is an array of strings `'mouse'` and `'touch'`. By default,
+     * `$swipe` will listen for `mouse` and `touch` events.
+     *
+     * The four events are `start`, `move`, `end`, and `cancel`. `start`, `move`, and `end`
+     * receive as a parameter a coordinates object of the form `{ x: 150, y: 310 }`.
+     *
+     * `start` is called on either `mousedown` or `touchstart`. After this event, `$swipe` is
+     * watching for `touchmove` or `mousemove` events. These events are ignored until the total
+     * distance moved in either dimension exceeds a small threshold.
+     *
+     * Once this threshold is exceeded, either the horizontal or vertical delta is greater.
+     * - If the horizontal distance is greater, this is a swipe and `move` and `end` events follow.
+     * - If the vertical distance is greater, this is a scroll, and we let the browser take over.
+     *   A `cancel` event is sent.
+     *
+     * `move` is called on `mousemove` and `touchmove` after the above logic has determined that
+     * a swipe is in progress.
+     *
+     * `end` is called when a swipe is successfully completed with a `touchend` or `mouseup`.
+     *
+     * `cancel` is called either on a `touchcancel` from the browser, or when we begin scrolling
+     * as described above.
+     *
+     */
+    bind: function(element, eventHandlers, pointerTypes) {
+      // Absolute total movement, used to control swipe vs. scroll.
+      var totalX, totalY;
+      // Coordinates of the start position.
+      var startCoords;
+      // Last event's position.
+      var lastPos;
+      // Whether a swipe is active.
+      var active = false;
+
+      pointerTypes = pointerTypes || ['mouse', 'touch'];
+      element.on(getEvents(pointerTypes, 'start'), function(event) {
+        startCoords = getCoordinates(event);
+        active = true;
+        totalX = 0;
+        totalY = 0;
+        lastPos = startCoords;
+        eventHandlers['start'] && eventHandlers['start'](startCoords, event);
+      });
+      var events = getEvents(pointerTypes, 'cancel');
+      if (events) {
+        element.on(events, function(event) {
+          active = false;
+          eventHandlers['cancel'] && eventHandlers['cancel'](event);
+        });
+      }
+
+      element.on(getEvents(pointerTypes, 'move'), function(event) {
+        if (!active) return;
+
+        // Android will send a touchcancel if it thinks we're starting to scroll.
+        // So when the total distance (+ or - or both) exceeds 10px in either direction,
+        // we either:
+        // - On totalX > totalY, we send preventDefault() and treat this as a swipe.
+        // - On totalY > totalX, we let the browser handle it as a scroll.
+
+        if (!startCoords) return;
+        var coords = getCoordinates(event);
+
+        totalX += Math.abs(coords.x - lastPos.x);
+        totalY += Math.abs(coords.y - lastPos.y);
+
+        lastPos = coords;
+
+        if (totalX < MOVE_BUFFER_RADIUS && totalY < MOVE_BUFFER_RADIUS) {
+          return;
+        }
+
+        // One of totalX or totalY has exceeded the buffer, so decide on swipe vs. scroll.
+        if (totalY > totalX) {
+          // Allow native scrolling to take over.
+          active = false;
+          eventHandlers['cancel'] && eventHandlers['cancel'](event);
+          return;
+        } else {
+          // Prevent the browser from scrolling.
+          event.preventDefault();
+          eventHandlers['move'] && eventHandlers['move'](coords, event);
+        }
+      });
+
+      element.on(getEvents(pointerTypes, 'end'), function(event) {
+        if (!active) return;
+        active = false;
+        eventHandlers['end'] && eventHandlers['end'](getCoordinates(event), event);
+      });
+    }
+  };
+}]);
+
+/* global ngTouch: false,
+  nodeName_: false
+*/
+
+/**
+ * @ngdoc directive
+ * @name ngClick
+ *
+ * @description
+ * A more powerful replacement for the default ngClick designed to be used on touchscreen
+ * devices. Most mobile browsers wait about 300ms after a tap-and-release before sending
+ * the click event. This version handles them immediately, and then prevents the
+ * following click event from propagating.
+ *
+ * Requires the {@link ngTouch `ngTouch`} module to be installed.
+ *
+ * This directive can fall back to using an ordinary click event, and so works on desktop
+ * browsers as well as mobile.
+ *
+ * This directive also sets the CSS class `ng-click-active` while the element is being held
+ * down (by a mouse click or touch) so you can restyle the depressed element if you wish.
+ *
+ * @element ANY
+ * @param {expression} ngClick {@link guide/expression Expression} to evaluate
+ * upon tap. (Event object is available as `$event`)
+ *
+ * @example
+    <example module="ngClickExample" deps="angular-touch.js">
+      <file name="index.html">
+        <button ng-click="count = count + 1" ng-init="count=0">
+          Increment
+        </button>
+        count: {{ count }}
+      </file>
+      <file name="script.js">
+        angular.module('ngClickExample', ['ngTouch']);
+      </file>
+    </example>
+ */
+
+ngTouch.config(['$provide', function($provide) {
+  $provide.decorator('ngClickDirective', ['$delegate', function($delegate) {
+    // drop the default ngClick directive
+    $delegate.shift();
+    return $delegate;
+  }]);
+}]);
+
+ngTouch.directive('ngClick', ['$parse', '$timeout', '$rootElement',
+    function($parse, $timeout, $rootElement) {
+  var TAP_DURATION = 750; // Shorter than 750ms is a tap, longer is a taphold or drag.
+  var MOVE_TOLERANCE = 12; // 12px seems to work in most mobile browsers.
+  var PREVENT_DURATION = 2500; // 2.5 seconds maximum from preventGhostClick call to click
+  var CLICKBUSTER_THRESHOLD = 25; // 25 pixels in any dimension is the limit for busting clicks.
+
+  var ACTIVE_CLASS_NAME = 'ng-click-active';
+  var lastPreventedTime;
+  var touchCoordinates;
+  var lastLabelClickCoordinates;
+
+
+  // TAP EVENTS AND GHOST CLICKS
+  //
+  // Why tap events?
+  // Mobile browsers detect a tap, then wait a moment (usually ~300ms) to see if you're
+  // double-tapping, and then fire a click event.
+  //
+  // This delay sucks and makes mobile apps feel unresponsive.
+  // So we detect touchstart, touchmove, touchcancel and touchend ourselves and determine when
+  // the user has tapped on something.
+  //
+  // What happens when the browser then generates a click event?
+  // The browser, of course, also detects the tap and fires a click after a delay. This results in
+  // tapping/clicking twice. We do "clickbusting" to prevent it.
+  //
+  // How does it work?
+  // We attach global touchstart and click handlers, that run during the capture (early) phase.
+  // So the sequence for a tap is:
+  // - global touchstart: Sets an "allowable region" at the point touched.
+  // - element's touchstart: Starts a touch
+  // (- touchmove or touchcancel ends the touch, no click follows)
+  // - element's touchend: Determines if the tap is valid (didn't move too far away, didn't hold
+  //   too long) and fires the user's tap handler. The touchend also calls preventGhostClick().
+  // - preventGhostClick() removes the allowable region the global touchstart created.
+  // - The browser generates a click event.
+  // - The global click handler catches the click, and checks whether it was in an allowable region.
+  //     - If preventGhostClick was called, the region will have been removed, the click is busted.
+  //     - If the region is still there, the click proceeds normally. Therefore clicks on links and
+  //       other elements without ngTap on them work normally.
+  //
+  // This is an ugly, terrible hack!
+  // Yeah, tell me about it. The alternatives are using the slow click events, or making our users
+  // deal with the ghost clicks, so I consider this the least of evils. Fortunately Angular
+  // encapsulates this ugly logic away from the user.
+  //
+  // Why not just put click handlers on the element?
+  // We do that too, just to be sure. If the tap event caused the DOM to change,
+  // it is possible another element is now in that position. To take account for these possibly
+  // distinct elements, the handlers are global and care only about coordinates.
+
+  // Checks if the coordinates are close enough to be within the region.
+  function hit(x1, y1, x2, y2) {
+    return Math.abs(x1 - x2) < CLICKBUSTER_THRESHOLD && Math.abs(y1 - y2) < CLICKBUSTER_THRESHOLD;
+  }
+
+  // Checks a list of allowable regions against a click location.
+  // Returns true if the click should be allowed.
+  // Splices out the allowable region from the list after it has been used.
+  function checkAllowableRegions(touchCoordinates, x, y) {
+    for (var i = 0; i < touchCoordinates.length; i += 2) {
+      if (hit(touchCoordinates[i], touchCoordinates[i + 1], x, y)) {
+        touchCoordinates.splice(i, i + 2);
+        return true; // allowable region
+      }
+    }
+    return false; // No allowable region; bust it.
+  }
+
+  // Global click handler that prevents the click if it's in a bustable zone and preventGhostClick
+  // was called recently.
+  function onClick(event) {
+    if (Date.now() - lastPreventedTime > PREVENT_DURATION) {
+      return; // Too old.
+    }
+
+    var touches = event.touches && event.touches.length ? event.touches : [event];
+    var x = touches[0].clientX;
+    var y = touches[0].clientY;
+    // Work around desktop Webkit quirk where clicking a label will fire two clicks (on the label
+    // and on the input element). Depending on the exact browser, this second click we don't want
+    // to bust has either (0,0), negative coordinates, or coordinates equal to triggering label
+    // click event
+    if (x < 1 && y < 1) {
+      return; // offscreen
+    }
+    if (lastLabelClickCoordinates &&
+        lastLabelClickCoordinates[0] === x && lastLabelClickCoordinates[1] === y) {
+      return; // input click triggered by label click
+    }
+    // reset label click coordinates on first subsequent click
+    if (lastLabelClickCoordinates) {
+      lastLabelClickCoordinates = null;
+    }
+    // remember label click coordinates to prevent click busting of trigger click event on input
+    if (nodeName_(event.target) === 'label') {
+      lastLabelClickCoordinates = [x, y];
+    }
+
+    // Look for an allowable region containing this click.
+    // If we find one, that means it was created by touchstart and not removed by
+    // preventGhostClick, so we don't bust it.
+    if (checkAllowableRegions(touchCoordinates, x, y)) {
+      return;
+    }
+
+    // If we didn't find an allowable region, bust the click.
+    event.stopPropagation();
+    event.preventDefault();
+
+    // Blur focused form elements
+    event.target && event.target.blur && event.target.blur();
+  }
+
+
+  // Global touchstart handler that creates an allowable region for a click event.
+  // This allowable region can be removed by preventGhostClick if we want to bust it.
+  function onTouchStart(event) {
+    var touches = event.touches && event.touches.length ? event.touches : [event];
+    var x = touches[0].clientX;
+    var y = touches[0].clientY;
+    touchCoordinates.push(x, y);
+
+    $timeout(function() {
+      // Remove the allowable region.
+      for (var i = 0; i < touchCoordinates.length; i += 2) {
+        if (touchCoordinates[i] == x && touchCoordinates[i + 1] == y) {
+          touchCoordinates.splice(i, i + 2);
+          return;
+        }
+      }
+    }, PREVENT_DURATION, false);
+  }
+
+  // On the first call, attaches some event handlers. Then whenever it gets called, it creates a
+  // zone around the touchstart where clicks will get busted.
+  function preventGhostClick(x, y) {
+    if (!touchCoordinates) {
+      $rootElement[0].addEventListener('click', onClick, true);
+      $rootElement[0].addEventListener('touchstart', onTouchStart, true);
+      touchCoordinates = [];
+    }
+
+    lastPreventedTime = Date.now();
+
+    checkAllowableRegions(touchCoordinates, x, y);
+  }
+
+  // Actual linking function.
+  return function(scope, element, attr) {
+    var clickHandler = $parse(attr.ngClick),
+        tapping = false,
+        tapElement,  // Used to blur the element after a tap.
+        startTime,   // Used to check if the tap was held too long.
+        touchStartX,
+        touchStartY;
+
+    function resetState() {
+      tapping = false;
+      element.removeClass(ACTIVE_CLASS_NAME);
+    }
+
+    element.on('touchstart', function(event) {
+      tapping = true;
+      tapElement = event.target ? event.target : event.srcElement; // IE uses srcElement.
+      // Hack for Safari, which can target text nodes instead of containers.
+      if (tapElement.nodeType == 3) {
+        tapElement = tapElement.parentNode;
+      }
+
+      element.addClass(ACTIVE_CLASS_NAME);
+
+      startTime = Date.now();
+
+      // Use jQuery originalEvent
+      var originalEvent = event.originalEvent || event;
+      var touches = originalEvent.touches && originalEvent.touches.length ? originalEvent.touches : [originalEvent];
+      var e = touches[0];
+      touchStartX = e.clientX;
+      touchStartY = e.clientY;
+    });
+
+    element.on('touchmove', function(event) {
+      resetState();
+    });
+
+    element.on('touchcancel', function(event) {
+      resetState();
+    });
+
+    element.on('touchend', function(event) {
+      var diff = Date.now() - startTime;
+
+      // Use jQuery originalEvent
+      var originalEvent = event.originalEvent || event;
+      var touches = (originalEvent.changedTouches && originalEvent.changedTouches.length) ?
+          originalEvent.changedTouches :
+          ((originalEvent.touches && originalEvent.touches.length) ? originalEvent.touches : [originalEvent]);
+      var e = touches[0];
+      var x = e.clientX;
+      var y = e.clientY;
+      var dist = Math.sqrt(Math.pow(x - touchStartX, 2) + Math.pow(y - touchStartY, 2));
+
+      if (tapping && diff < TAP_DURATION && dist < MOVE_TOLERANCE) {
+        // Call preventGhostClick so the clickbuster will catch the corresponding click.
+        preventGhostClick(x, y);
+
+        // Blur the focused element (the button, probably) before firing the callback.
+        // This doesn't work perfectly on Android Chrome, but seems to work elsewhere.
+        // I couldn't get anything to work reliably on Android Chrome.
+        if (tapElement) {
+          tapElement.blur();
+        }
+
+        if (!angular.isDefined(attr.disabled) || attr.disabled === false) {
+          element.triggerHandler('click', [event]);
+        }
+      }
+
+      resetState();
+    });
+
+    // Hack for iOS Safari's benefit. It goes searching for onclick handlers and is liable to click
+    // something else nearby.
+    element.onclick = function(event) { };
+
+    // Actual click handler.
+    // There are three different kinds of clicks, only two of which reach this point.
+    // - On desktop browsers without touch events, their clicks will always come here.
+    // - On mobile browsers, the simulated "fast" click will call this.
+    // - But the browser's follow-up slow click will be "busted" before it reaches this handler.
+    // Therefore it's safe to use this directive on both mobile and desktop.
+    element.on('click', function(event, touchend) {
+      scope.$apply(function() {
+        clickHandler(scope, {$event: (touchend || event)});
+      });
+    });
+
+    element.on('mousedown', function(event) {
+      element.addClass(ACTIVE_CLASS_NAME);
+    });
+
+    element.on('mousemove mouseup', function(event) {
+      element.removeClass(ACTIVE_CLASS_NAME);
+    });
+
+  };
+}]);
+
+/* global ngTouch: false */
+
+/**
+ * @ngdoc directive
+ * @name ngSwipeLeft
+ *
+ * @description
+ * Specify custom behavior when an element is swiped to the left on a touchscreen device.
+ * A leftward swipe is a quick, right-to-left slide of the finger.
+ * Though ngSwipeLeft is designed for touch-based devices, it will work with a mouse click and drag
+ * too.
+ *
+ * To disable the mouse click and drag functionality, add `ng-swipe-disable-mouse` to
+ * the `ng-swipe-left` or `ng-swipe-right` DOM Element.
+ *
+ * Requires the {@link ngTouch `ngTouch`} module to be installed.
+ *
+ * @element ANY
+ * @param {expression} ngSwipeLeft {@link guide/expression Expression} to evaluate
+ * upon left swipe. (Event object is available as `$event`)
+ *
+ * @example
+    <example module="ngSwipeLeftExample" deps="angular-touch.js">
+      <file name="index.html">
+        <div ng-show="!showActions" ng-swipe-left="showActions = true">
+          Some list content, like an email in the inbox
+        </div>
+        <div ng-show="showActions" ng-swipe-right="showActions = false">
+          <button ng-click="reply()">Reply</button>
+          <button ng-click="delete()">Delete</button>
+        </div>
+      </file>
+      <file name="script.js">
+        angular.module('ngSwipeLeftExample', ['ngTouch']);
+      </file>
+    </example>
+ */
+
+/**
+ * @ngdoc directive
+ * @name ngSwipeRight
+ *
+ * @description
+ * Specify custom behavior when an element is swiped to the right on a touchscreen device.
+ * A rightward swipe is a quick, left-to-right slide of the finger.
+ * Though ngSwipeRight is designed for touch-based devices, it will work with a mouse click and drag
+ * too.
+ *
+ * Requires the {@link ngTouch `ngTouch`} module to be installed.
+ *
+ * @element ANY
+ * @param {expression} ngSwipeRight {@link guide/expression Expression} to evaluate
+ * upon right swipe. (Event object is available as `$event`)
+ *
+ * @example
+    <example module="ngSwipeRightExample" deps="angular-touch.js">
+      <file name="index.html">
+        <div ng-show="!showActions" ng-swipe-left="showActions = true">
+          Some list content, like an email in the inbox
+        </div>
+        <div ng-show="showActions" ng-swipe-right="showActions = false">
+          <button ng-click="reply()">Reply</button>
+          <button ng-click="delete()">Delete</button>
+        </div>
+      </file>
+      <file name="script.js">
+        angular.module('ngSwipeRightExample', ['ngTouch']);
+      </file>
+    </example>
+ */
+
+function makeSwipeDirective(directiveName, direction, eventName) {
+  ngTouch.directive(directiveName, ['$parse', '$swipe', function($parse, $swipe) {
+    // The maximum vertical delta for a swipe should be less than 75px.
+    var MAX_VERTICAL_DISTANCE = 75;
+    // Vertical distance should not be more than a fraction of the horizontal distance.
+    var MAX_VERTICAL_RATIO = 0.3;
+    // At least a 30px lateral motion is necessary for a swipe.
+    var MIN_HORIZONTAL_DISTANCE = 30;
+
+    return function(scope, element, attr) {
+      var swipeHandler = $parse(attr[directiveName]);
+
+      var startCoords, valid;
+
+      function validSwipe(coords) {
+        // Check that it's within the coordinates.
+        // Absolute vertical distance must be within tolerances.
+        // Horizontal distance, we take the current X - the starting X.
+        // This is negative for leftward swipes and positive for rightward swipes.
+        // After multiplying by the direction (-1 for left, +1 for right), legal swipes
+        // (ie. same direction as the directive wants) will have a positive delta and
+        // illegal ones a negative delta.
+        // Therefore this delta must be positive, and larger than the minimum.
+        if (!startCoords) return false;
+        var deltaY = Math.abs(coords.y - startCoords.y);
+        var deltaX = (coords.x - startCoords.x) * direction;
+        return valid && // Short circuit for already-invalidated swipes.
+            deltaY < MAX_VERTICAL_DISTANCE &&
+            deltaX > 0 &&
+            deltaX > MIN_HORIZONTAL_DISTANCE &&
+            deltaY / deltaX < MAX_VERTICAL_RATIO;
+      }
+
+      var pointerTypes = ['touch'];
+      if (!angular.isDefined(attr['ngSwipeDisableMouse'])) {
+        pointerTypes.push('mouse');
+      }
+      $swipe.bind(element, {
+        'start': function(coords, event) {
+          startCoords = coords;
+          valid = true;
+        },
+        'cancel': function(event) {
+          valid = false;
+        },
+        'end': function(coords, event) {
+          if (validSwipe(coords)) {
+            scope.$apply(function() {
+              element.triggerHandler(eventName);
+              swipeHandler(scope, {$event: event});
+            });
+          }
+        }
+      }, pointerTypes);
+    };
+  }]);
+}
+
+// Left is negative X-coordinate, right is positive.
+makeSwipeDirective('ngSwipeLeft', -1, 'swipeleft');
+makeSwipeDirective('ngSwipeRight', 1, 'swiperight');
+
+
+
+})(window, window.angular);
diff --git a/guacamole/src/main/webapp/lib/angular/angular.min.js b/guacamole/src/main/webapp/lib/angular/angular.min.js
new file mode 100644
index 0000000..ba80974
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/angular/angular.min.js
@@ -0,0 +1,252 @@
+/*
+ AngularJS v1.3.16
+ (c) 2010-2014 Google, Inc. http://angularjs.org
+ License: MIT
+*/
+(function(T,V,s){'use strict';function F(b){return function(){var a=arguments[0],c;c="["+(b?b+":":"")+a+"] http://errors.angularjs.org/1.3.16/"+(b?b+"/":"")+a;for(a=1;a<arguments.length;a++){c=c+(1==a?"?":"&")+"p"+(a-1)+"=";var d=encodeURIComponent,e;e=arguments[a];e="function"==typeof e?e.toString().replace(/ \{[\s\S]*$/,""):"undefined"==typeof e?"undefined":"string"!=typeof e?JSON.stringify(e):e;c+=d(e)}return Error(c)}}function Sa(b){if(null==b||Ta(b))return!1;var a="length"in Object( [...]
+return b.nodeType===ma&&a?!0:O(b)||w(b)||0===a||"number"===typeof a&&0<a&&a-1 in b}function q(b,a,c){var d,e;if(b)if(E(b))for(d in b)"prototype"==d||"length"==d||"name"==d||b.hasOwnProperty&&!b.hasOwnProperty(d)||a.call(c,b[d],d,b);else if(w(b)||Sa(b)){var f="object"!==typeof b;d=0;for(e=b.length;d<e;d++)(f||d in b)&&a.call(c,b[d],d,b)}else if(b.forEach&&b.forEach!==q)b.forEach(a,c,b);else for(d in b)b.hasOwnProperty(d)&&a.call(c,b[d],d,b);return b}function Jd(b,a,c){for(var d=Object.key [...]
+e=0;e<d.length;e++)a.call(c,b[d[e]],d[e]);return d}function pc(b){return function(a,c){b(c,a)}}function Kd(){return++rb}function qc(b,a){a?b.$$hashKey=a:delete b.$$hashKey}function x(b){for(var a=b.$$hashKey,c=1,d=arguments.length;c<d;c++){var e=arguments[c];if(e)for(var f=Object.keys(e),g=0,h=f.length;g<h;g++){var l=f[g];b[l]=e[l]}}qc(b,a);return b}function aa(b){return parseInt(b,10)}function Pb(b,a){return x(Object.create(b),a)}function A(){}function na(b){return b}function ca(b){retu [...]
+function C(b){return"undefined"===typeof b}function y(b){return"undefined"!==typeof b}function I(b){return null!==b&&"object"===typeof b}function O(b){return"string"===typeof b}function Q(b){return"number"===typeof b}function ea(b){return"[object Date]"===Aa.call(b)}function E(b){return"function"===typeof b}function Ua(b){return"[object RegExp]"===Aa.call(b)}function Ta(b){return b&&b.window===b}function Va(b){return b&&b.$evalAsync&&b.$watch}function Wa(b){return"boolean"===typeof b}fun [...]
+!(b.nodeName||b.prop&&b.attr&&b.find))}function Ld(b){var a={};b=b.split(",");var c;for(c=0;c<b.length;c++)a[b[c]]=!0;return a}function sa(b){return L(b.nodeName||b[0]&&b[0].nodeName)}function Xa(b,a){var c=b.indexOf(a);0<=c&&b.splice(c,1);return a}function Ba(b,a,c,d){if(Ta(b)||Va(b))throw Ja("cpws");if(a){if(b===a)throw Ja("cpi");c=c||[];d=d||[];if(I(b)){var e=c.indexOf(b);if(-1!==e)return d[e];c.push(b);d.push(a)}if(w(b))for(var f=a.length=0;f<b.length;f++)e=Ba(b[f],null,c,d),I(b[f])& [...]
+d.push(e)),a.push(e);else{var g=a.$$hashKey;w(a)?a.length=0:q(a,function(b,c){delete a[c]});for(f in b)b.hasOwnProperty(f)&&(e=Ba(b[f],null,c,d),I(b[f])&&(c.push(b[f]),d.push(e)),a[f]=e);qc(a,g)}}else if(a=b)w(b)?a=Ba(b,[],c,d):ea(b)?a=new Date(b.getTime()):Ua(b)?(a=new RegExp(b.source,b.toString().match(/[^\/]*$/)[0]),a.lastIndex=b.lastIndex):I(b)&&(e=Object.create(Object.getPrototypeOf(b)),a=Ba(b,e,c,d));return a}function oa(b,a){if(w(b)){a=a||[];for(var c=0,d=b.length;c<d;c++)a[c]=b[c [...]
+a||{},b)if("$"!==c.charAt(0)||"$"!==c.charAt(1))a[c]=b[c];return a||b}function fa(b,a){if(b===a)return!0;if(null===b||null===a)return!1;if(b!==b&&a!==a)return!0;var c=typeof b,d;if(c==typeof a&&"object"==c)if(w(b)){if(!w(a))return!1;if((c=b.length)==a.length){for(d=0;d<c;d++)if(!fa(b[d],a[d]))return!1;return!0}}else{if(ea(b))return ea(a)?fa(b.getTime(),a.getTime()):!1;if(Ua(b))return Ua(a)?b.toString()==a.toString():!1;if(Va(b)||Va(a)||Ta(b)||Ta(a)||w(a)||ea(a)||Ua(a))return!1;c={};for(d [...]
+d.charAt(0)&&!E(b[d])){if(!fa(b[d],a[d]))return!1;c[d]=!0}for(d in a)if(!c.hasOwnProperty(d)&&"$"!==d.charAt(0)&&a[d]!==s&&!E(a[d]))return!1;return!0}return!1}function Ya(b,a,c){return b.concat(Za.call(a,c))}function sc(b,a){var c=2<arguments.length?Za.call(arguments,2):[];return!E(a)||a instanceof RegExp?a:c.length?function(){return arguments.length?a.apply(b,Ya(c,arguments,0)):a.apply(b,c)}:function(){return arguments.length?a.apply(b,arguments):a.call(b)}}function Md(b,a){var c=a;"str [...]
+"$"===b.charAt(0)&&"$"===b.charAt(1)?c=s:Ta(a)?c="$WINDOW":a&&V===a?c="$DOCUMENT":Va(a)&&(c="$SCOPE");return c}function $a(b,a){if("undefined"===typeof b)return s;Q(a)||(a=a?2:null);return JSON.stringify(b,Md,a)}function tc(b){return O(b)?JSON.parse(b):b}function ta(b){b=z(b).clone();try{b.empty()}catch(a){}var c=z("<div>").append(b).html();try{return b[0].nodeType===ab?L(c):c.match(/^(<[^>]+>)/)[1].replace(/^<([\w\-]+)/,function(a,b){return"<"+L(b)})}catch(d){return L(c)}}function uc(b) [...]
+function vc(b){var a={},c,d;q((b||"").split("&"),function(b){b&&(c=b.replace(/\+/g,"%20").split("="),d=uc(c[0]),y(d)&&(b=y(c[1])?uc(c[1]):!0,wc.call(a,d)?w(a[d])?a[d].push(b):a[d]=[a[d],b]:a[d]=b))});return a}function Qb(b){var a=[];q(b,function(b,d){w(b)?q(b,function(b){a.push(Ca(d,!0)+(!0===b?"":"="+Ca(b,!0)))}):a.push(Ca(d,!0)+(!0===b?"":"="+Ca(b,!0)))});return a.length?a.join("&"):""}function sb(b){return Ca(b,!0).replace(/%26/gi,"&").replace(/%3D/gi,"=").replace(/%2B/gi,"+")}functio [...]
+"@").replace(/%3A/gi,":").replace(/%24/g,"$").replace(/%2C/gi,",").replace(/%3B/gi,";").replace(/%20/g,a?"%20":"+")}function Nd(b,a){var c,d,e=tb.length;b=z(b);for(d=0;d<e;++d)if(c=tb[d]+a,O(c=b.attr(c)))return c;return null}function Od(b,a){var c,d,e={};q(tb,function(a){a+="app";!c&&b.hasAttribute&&b.hasAttribute(a)&&(c=b,d=b.getAttribute(a))});q(tb,function(a){a+="app";var e;!c&&(e=b.querySelector("["+a.replace(":","\\:")+"]"))&&(c=e,d=e.getAttribute(a))});c&&(e.strictDi=null!==Nd(c,"s [...]
+a(c,d?[d]:[],e))}function xc(b,a,c){I(c)||(c={});c=x({strictDi:!1},c);var d=function(){b=z(b);if(b.injector()){var d=b[0]===V?"document":ta(b);throw Ja("btstrpd",d.replace(/</,"<").replace(/>/,">"));}a=a||[];a.unshift(["$provide",function(a){a.value("$rootElement",b)}]);c.debugInfoEnabled&&a.push(["$compileProvider",function(a){a.debugInfoEnabled(!0)}]);a.unshift("ng");d=bb(a,c.strictDi);d.invoke(["$rootScope","$rootElement","$compile","$injector",function(a,b,c,d){a.$apply(functio [...]
+d);c(b)(a)})}]);return d},e=/^NG_ENABLE_DEBUG_INFO!/,f=/^NG_DEFER_BOOTSTRAP!/;T&&e.test(T.name)&&(c.debugInfoEnabled=!0,T.name=T.name.replace(e,""));if(T&&!f.test(T.name))return d();T.name=T.name.replace(f,"");ba.resumeBootstrap=function(b){q(b,function(b){a.push(b)});return d()};E(ba.resumeDeferredBootstrap)&&ba.resumeDeferredBootstrap()}function Pd(){T.name="NG_ENABLE_DEBUG_INFO!"+T.name;T.location.reload()}function Qd(b){b=ba.element(b).injector();if(!b)throw Ja("test");return b.get(" [...]
+function yc(b,a){a=a||"_";return b.replace(Rd,function(b,d){return(d?a:"")+b.toLowerCase()})}function Sd(){var b;zc||((pa=T.jQuery)&&pa.fn.on?(z=pa,x(pa.fn,{scope:Ka.scope,isolateScope:Ka.isolateScope,controller:Ka.controller,injector:Ka.injector,inheritedData:Ka.inheritedData}),b=pa.cleanData,pa.cleanData=function(a){var c;if(Rb)Rb=!1;else for(var d=0,e;null!=(e=a[d]);d++)(c=pa._data(e,"events"))&&c.$destroy&&pa(e).triggerHandler("$destroy");b(a)}):z=R,ba.element=z,zc=!0)}function Sb(b, [...]
+a||"?",c||"required");return b}function La(b,a,c){c&&w(b)&&(b=b[b.length-1]);Sb(E(b),a,"not a function, got "+(b&&"object"===typeof b?b.constructor.name||"Object":typeof b));return b}function Ma(b,a){if("hasOwnProperty"===b)throw Ja("badname",a);}function Ac(b,a,c){if(!a)return b;a=a.split(".");for(var d,e=b,f=a.length,g=0;g<f;g++)d=a[g],b&&(b=(e=b)[d]);return!c&&E(b)?sc(e,b):b}function ub(b){var a=b[0];b=b[b.length-1];var c=[a];do{a=a.nextSibling;if(!a)break;c.push(a)}while(a!==b);retur [...]
+function Td(b){function a(a,b,c){return a[b]||(a[b]=c())}var c=F("$injector"),d=F("ng");b=a(b,"angular",Object);b.$$minErr=b.$$minErr||F;return a(b,"module",function(){var b={};return function(f,g,h){if("hasOwnProperty"===f)throw d("badname","module");g&&b.hasOwnProperty(f)&&(b[f]=null);return a(b,f,function(){function a(c,d,e,f){f||(f=b);return function(){f[e||"push"]([c,d,arguments]);return u}}if(!g)throw c("nomod",f);var b=[],d=[],e=[],n=a("$injector","invoke","push",d),u={_invokeQueu [...]
+_runBlocks:e,requires:g,name:f,provider:a("$provide","provider"),factory:a("$provide","factory"),service:a("$provide","service"),value:a("$provide","value"),constant:a("$provide","constant","unshift"),animation:a("$animateProvider","register"),filter:a("$filterProvider","register"),controller:a("$controllerProvider","register"),directive:a("$compileProvider","directive"),config:n,run:function(a){e.push(a);return this}};h&&n(h);return u})}})}function Ud(b){x(b,{bootstrap:xc,copy:Ba,extend [...]
+element:z,forEach:q,injector:bb,noop:A,bind:sc,toJson:$a,fromJson:tc,identity:na,isUndefined:C,isDefined:y,isString:O,isFunction:E,isObject:I,isNumber:Q,isElement:rc,isArray:w,version:Vd,isDate:ea,lowercase:L,uppercase:vb,callbacks:{counter:0},getTestability:Qd,$$minErr:F,$$csp:cb,reloadWithDebugInfo:Pd});db=Td(T);try{db("ngLocale")}catch(a){db("ngLocale",[]).provider("$locale",Wd)}db("ng",["ngLocale"],["$provide",function(a){a.provider({$$sanitizeUri:Xd});a.provider("$compile",Bc).direc [...]
+input:Cc,textarea:Cc,form:Zd,script:$d,select:ae,style:be,option:ce,ngBind:de,ngBindHtml:ee,ngBindTemplate:fe,ngClass:ge,ngClassEven:he,ngClassOdd:ie,ngCloak:je,ngController:ke,ngForm:le,ngHide:me,ngIf:ne,ngInclude:oe,ngInit:pe,ngNonBindable:qe,ngPluralize:re,ngRepeat:se,ngShow:te,ngStyle:ue,ngSwitch:ve,ngSwitchWhen:we,ngSwitchDefault:xe,ngOptions:ye,ngTransclude:ze,ngModel:Ae,ngList:Be,ngChange:Ce,pattern:Dc,ngPattern:Dc,required:Ec,ngRequired:Ec,minlength:Fc,ngMinlength:Fc,maxlength:Gc [...]
+ngValue:De,ngModelOptions:Ee}).directive({ngInclude:Fe}).directive(wb).directive(Hc);a.provider({$anchorScroll:Ge,$animate:He,$browser:Ie,$cacheFactory:Je,$controller:Ke,$document:Le,$exceptionHandler:Me,$filter:Ic,$interpolate:Ne,$interval:Oe,$http:Pe,$httpBackend:Qe,$location:Re,$log:Se,$parse:Te,$rootScope:Ue,$q:Ve,$$q:We,$sce:Xe,$sceDelegate:Ye,$sniffer:Ze,$templateCache:$e,$templateRequest:af,$$testability:bf,$timeout:cf,$window:df,$$rAF:ef,$$asyncCallback:ff,$$jqLite:gf})}])}functi [...]
+function(a,b,d,e){return e?d.toUpperCase():d}).replace(jf,"Moz$1")}function Jc(b){b=b.nodeType;return b===ma||!b||9===b}function Kc(b,a){var c,d,e=a.createDocumentFragment(),f=[];if(Tb.test(b)){c=c||e.appendChild(a.createElement("div"));d=(kf.exec(b)||["",""])[1].toLowerCase();d=ha[d]||ha._default;c.innerHTML=d[1]+b.replace(lf,"<$1></$2>")+d[2];for(d=d[0];d--;)c=c.lastChild;f=Ya(f,c.childNodes);c=e.firstChild;c.textContent=""}else f.push(a.createTextNode(b));e.textContent="";e.innerHTML= [...]
+return e}function R(b){if(b instanceof R)return b;var a;O(b)&&(b=U(b),a=!0);if(!(this instanceof R)){if(a&&"<"!=b.charAt(0))throw Ub("nosel");return new R(b)}if(a){a=V;var c;b=(c=mf.exec(b))?[a.createElement(c[1])]:(c=Kc(b,a))?c.childNodes:[]}Lc(this,b)}function Vb(b){return b.cloneNode(!0)}function xb(b,a){a||yb(b);if(b.querySelectorAll)for(var c=b.querySelectorAll("*"),d=0,e=c.length;d<e;d++)yb(c[d])}function Mc(b,a,c,d){if(y(d))throw Ub("offargs");var e=(d=zb(b))&&d.events,f=d&&d.hand [...]
+function(a){if(y(c)){var d=e[a];Xa(d||[],c);if(d&&0<d.length)return}b.removeEventListener(a,f,!1);delete e[a]});else for(a in e)"$destroy"!==a&&b.removeEventListener(a,f,!1),delete e[a]}function yb(b,a){var c=b.ng339,d=c&&Ab[c];d&&(a?delete d.data[a]:(d.handle&&(d.events.$destroy&&d.handle({},"$destroy"),Mc(b)),delete Ab[c],b.ng339=s))}function zb(b,a){var c=b.ng339,c=c&&Ab[c];a&&!c&&(b.ng339=c=++nf,c=Ab[c]={events:{},data:{},handle:s});return c}function Wb(b,a,c){if(Jc(b)){var d=y(c),e= [...]
+f=!a;b=(b=zb(b,!e))&&b.data;if(d)b[a]=c;else{if(f)return b;if(e)return b&&b[a];x(b,a)}}}function Bb(b,a){return b.getAttribute?-1<(" "+(b.getAttribute("class")||"")+" ").replace(/[\n\t]/g," ").indexOf(" "+a+" "):!1}function Cb(b,a){a&&b.setAttribute&&q(a.split(" "),function(a){b.setAttribute("class",U((" "+(b.getAttribute("class")||"")+" ").replace(/[\n\t]/g," ").replace(" "+U(a)+" "," ")))})}function Db(b,a){if(a&&b.setAttribute){var c=(" "+(b.getAttribute("class")||"")+" ").replace(/[\ [...]
+q(a.split(" "),function(a){a=U(a);-1===c.indexOf(" "+a+" ")&&(c+=a+" ")});b.setAttribute("class",U(c))}}function Lc(b,a){if(a)if(a.nodeType)b[b.length++]=a;else{var c=a.length;if("number"===typeof c&&a.window!==a){if(c)for(var d=0;d<c;d++)b[b.length++]=a[d]}else b[b.length++]=a}}function Nc(b,a){return Eb(b,"$"+(a||"ngController")+"Controller")}function Eb(b,a,c){9==b.nodeType&&(b=b.documentElement);for(a=w(a)?a:[a];b;){for(var d=0,e=a.length;d<e;d++)if((c=z.data(b,a[d]))!==s)return c;b= [...]
+11===b.nodeType&&b.host}}function Oc(b){for(xb(b,!0);b.firstChild;)b.removeChild(b.firstChild)}function Pc(b,a){a||xb(b);var c=b.parentNode;c&&c.removeChild(b)}function of(b,a){a=a||T;if("complete"===a.document.readyState)a.setTimeout(b);else z(a).on("load",b)}function Qc(b,a){var c=Fb[a.toLowerCase()];return c&&Rc[sa(b)]&&c}function pf(b,a){var c=b.nodeName;return("INPUT"===c||"TEXTAREA"===c)&&Sc[a]}function qf(b,a){var c=function(c,e){c.isDefaultPrevented=function(){return c.defaultPre [...]
+a[e||c.type],g=f?f.length:0;if(g){if(C(c.immediatePropagationStopped)){var h=c.stopImmediatePropagation;c.stopImmediatePropagation=function(){c.immediatePropagationStopped=!0;c.stopPropagation&&c.stopPropagation();h&&h.call(c)}}c.isImmediatePropagationStopped=function(){return!0===c.immediatePropagationStopped};1<g&&(f=oa(f));for(var l=0;l<g;l++)c.isImmediatePropagationStopped()||f[l].call(b,c)}};c.elem=b;return c}function gf(){this.$get=function(){return x(R,{hasClass:function(b,a){b.at [...]
+return Bb(b,a)},addClass:function(b,a){b.attr&&(b=b[0]);return Db(b,a)},removeClass:function(b,a){b.attr&&(b=b[0]);return Cb(b,a)}})}}function Na(b,a){var c=b&&b.$$hashKey;if(c)return"function"===typeof c&&(c=b.$$hashKey()),c;c=typeof b;return c="function"==c||"object"==c&&null!==b?b.$$hashKey=c+":"+(a||Kd)():c+":"+b}function fb(b,a){if(a){var c=0;this.nextUid=function(){return++c}}q(b,this.put,this)}function rf(b){return(b=b.toString().replace(Tc,"").match(Uc))?"function("+(b[1]||"").re [...]
+" ")+")":"fn"}function bb(b,a){function c(a){return function(b,c){if(I(b))q(b,pc(a));else return a(b,c)}}function d(a,b){Ma(a,"service");if(E(b)||w(b))b=n.instantiate(b);if(!b.$get)throw Da("pget",a);return r[a+"Provider"]=b}function e(a,b){return function(){var c=v.invoke(b,this);if(C(c))throw Da("undef",a);return c}}function f(a,b,c){return d(a,{$get:!1!==c?e(a,b):b})}function g(a){var b=[],c;q(a,function(a){function d(a){var b,c;b=0;for(c=a.length;b<c;b++){var e=a[b],f=n.get(e[0]);f[e [...]
+e[2])}}if(!m.get(a)){m.put(a,!0);try{O(a)?(c=db(a),b=b.concat(g(c.requires)).concat(c._runBlocks),d(c._invokeQueue),d(c._configBlocks)):E(a)?b.push(n.invoke(a)):w(a)?b.push(n.invoke(a)):La(a,"module")}catch(e){throw w(a)&&(a=a[a.length-1]),e.message&&e.stack&&-1==e.stack.indexOf(e.message)&&(e=e.message+"\n"+e.stack),Da("modulerr",a,e.stack||e.message||e);}}});return b}function h(b,c){function d(a,e){if(b.hasOwnProperty(a)){if(b[a]===l)throw Da("cdep",a+" <- "+k.join(" <- "));return b[a] [...]
+b[a]=l,b[a]=c(a,e)}catch(f){throw b[a]===l&&delete b[a],f;}finally{k.shift()}}function e(b,c,f,g){"string"===typeof f&&(g=f,f=null);var k=[],l=bb.$$annotate(b,a,g),h,n,m;n=0;for(h=l.length;n<h;n++){m=l[n];if("string"!==typeof m)throw Da("itkn",m);k.push(f&&f.hasOwnProperty(m)?f[m]:d(m,g))}w(b)&&(b=b[h]);return b.apply(c,k)}return{invoke:e,instantiate:function(a,b,c){var d=Object.create((w(a)?a[a.length-1]:a).prototype||null);a=e(a,d,b,c);return I(a)||E(a)?a:d},get:d,annotate:bb.$$annotat [...]
+"Provider")||b.hasOwnProperty(a)}}}a=!0===a;var l={},k=[],m=new fb([],!0),r={$provide:{provider:c(d),factory:c(f),service:c(function(a,b){return f(a,["$injector",function(a){return a.instantiate(b)}])}),value:c(function(a,b){return f(a,ca(b),!1)}),constant:c(function(a,b){Ma(a,"constant");r[a]=b;u[a]=b}),decorator:function(a,b){var c=n.get(a+"Provider"),d=c.$get;c.$get=function(){var a=v.invoke(d,c);return v.invoke(b,null,{$delegate:a})}}}},n=r.$injector=h(r,function(a,b){ba.isString(b)& [...]
+throw Da("unpr",k.join(" <- "));}),u={},v=u.$injector=h(u,function(a,b){var c=n.get(a+"Provider",b);return v.invoke(c.$get,c,s,a)});q(g(b),function(a){v.invoke(a||A)});return v}function Ge(){var b=!0;this.disableAutoScrolling=function(){b=!1};this.$get=["$window","$location","$rootScope",function(a,c,d){function e(a){var b=null;Array.prototype.some.call(a,function(a){if("a"===sa(a))return b=a,!0});return b}function f(b){if(b){b.scrollIntoView();var c;c=g.yOffset;E(c)?c=c():rc(c)?(c=c[0], [...]
+a.getComputedStyle(c).position?0:c.getBoundingClientRect().bottom):Q(c)||(c=0);c&&(b=b.getBoundingClientRect().top,a.scrollBy(0,b-c))}else a.scrollTo(0,0)}function g(){var a=c.hash(),b;a?(b=h.getElementById(a))?f(b):(b=e(h.getElementsByName(a)))?f(b):"top"===a&&f(null):f(null)}var h=a.document;b&&d.$watch(function(){return c.hash()},function(a,b){a===b&&""===a||of(function(){d.$evalAsync(g)})});return g}]}function ff(){this.$get=["$$rAF","$timeout",function(b,a){return b.supported?functi [...]
+function(b){return a(b,0,!1)}}]}function sf(b,a,c,d){function e(a){try{a.apply(null,Za.call(arguments,1))}finally{if(p--,0===p)for(;H.length;)try{H.pop()()}catch(b){c.error(b)}}}function f(a,b){(function Xb(){q(J,function(a){a()});B=b(Xb,a)})()}function g(){h();l()}function h(){a:{try{M=u.state;break a}catch(a){}M=void 0}M=C(M)?null:M;fa(M,S)&&(M=S);S=M}function l(){if(G!==m.url()||D!==M)G=m.url(),D=M,q($,function(a){a(m.url(),M)})}function k(a){try{return decodeURIComponent(a)}catch(b){ [...]
+var m=this,r=a[0],n=b.location,u=b.history,v=b.setTimeout,P=b.clearTimeout,t={};m.isMock=!1;var p=0,H=[];m.$$completeOutstandingRequest=e;m.$$incOutstandingRequestCount=function(){p++};m.notifyWhenNoOutstandingRequests=function(a){q(J,function(a){a()});0===p?a():H.push(a)};var J=[],B;m.addPollFn=function(a){C(B)&&f(100,v);J.push(a);return a};var M,D,G=n.href,N=a.find("base"),Y=null;h();D=M;m.url=function(a,c,e){C(e)&&(e=null);n!==b.location&&(n=b.location);u!==b.history&&(u=b.history);if [...]
+D===e;if(G===a&&(!d.history||f))return m;var g=G&&Ea(G)===Ea(a);G=a;D=e;!d.history||g&&f?(g||(Y=a),c?n.replace(a):g?(c=n,e=a.indexOf("#"),a=-1===e?"":a.substr(e+1),c.hash=a):n.href=a):(u[c?"replaceState":"pushState"](e,"",a),h(),D=M);return m}return Y||n.href.replace(/%27/g,"'")};m.state=function(){return M};var $=[],W=!1,S=null;m.onUrlChange=function(a){if(!W){if(d.history)z(b).on("popstate",g);z(b).on("hashchange",g);W=!0}$.push(a);return a};m.$$checkUrlChange=l;m.baseHref=function(){v [...]
+return a?a.replace(/^(https?\:)?\/\/[^\/]*/,""):""};var Fa={},y="",hb=m.baseHref();m.cookies=function(a,b){var d,e,f,g;if(a)b===s?r.cookie=encodeURIComponent(a)+"=;path="+hb+";expires=Thu, 01 Jan 1970 00:00:00 GMT":O(b)&&(d=(r.cookie=encodeURIComponent(a)+"="+encodeURIComponent(b)+";path="+hb).length+1,4096<d&&c.warn("Cookie '"+a+"' possibly not set or overflowed because it was too large ("+d+" > 4096 bytes)!"));else{if(r.cookie!==y)for(y=r.cookie,d=y.split("; "),Fa={},f=0;f<d.length;f++ [...]
+e.indexOf("="),0<g&&(a=k(e.substring(0,g)),Fa[a]===s&&(Fa[a]=k(e.substring(g+1))));return Fa}};m.defer=function(a,b){var c;p++;c=v(function(){delete t[c];e(a)},b||0);t[c]=!0;return c};m.defer.cancel=function(a){return t[a]?(delete t[a],P(a),e(A),!0):!1}}function Ie(){this.$get=["$window","$log","$sniffer","$document",function(b,a,c,d){return new sf(b,d,a,c)}]}function Je(){this.$get=function(){function b(b,d){function e(a){a!=r&&(n?n==a&&(n=a.n):n=a,f(a.n,a.p),f(a,r),r=a,r.n=null)}functi [...]
+b&&(a&&(a.p=b),b&&(b.n=a))}if(b in a)throw F("$cacheFactory")("iid",b);var g=0,h=x({},d,{id:b}),l={},k=d&&d.capacity||Number.MAX_VALUE,m={},r=null,n=null;return a[b]={put:function(a,b){if(k<Number.MAX_VALUE){var c=m[a]||(m[a]={key:a});e(c)}if(!C(b))return a in l||g++,l[a]=b,g>k&&this.remove(n.key),b},get:function(a){if(k<Number.MAX_VALUE){var b=m[a];if(!b)return;e(b)}return l[a]},remove:function(a){if(k<Number.MAX_VALUE){var b=m[a];if(!b)return;b==r&&(r=b.p);b==n&&(n=b.n);f(b.n,b.p);dele [...]
+g--},removeAll:function(){l={};g=0;m={};r=n=null},destroy:function(){m=h=l=null;delete a[b]},info:function(){return x({},h,{size:g})}}}var a={};b.info=function(){var b={};q(a,function(a,e){b[e]=a.info()});return b};b.get=function(b){return a[b]};return b}}function $e(){this.$get=["$cacheFactory",function(b){return b("templates")}]}function Bc(b,a){function c(a,b){var c=/^\s*([@&]|=(\*?))(\??)\s*(\w*)\s*$/,d={};q(a,function(a,e){var f=a.match(c);if(!f)throw da("iscp",b,e,a);d[e]={mode:f[1 [...]
+f[2],optional:"?"===f[3],attrName:f[4]||e}});return d}function d(a){var b=a.charAt(0);if(!b||b!==L(b))throw da("baddir",a);return a}var e={},f=/^\s*directive\:\s*([\w\-]+)\s+(.*)$/,g=/(([\w\-]+)(?:\:([^;]+))?;?)/,h=Ld("ngSrc,ngSrcset,src,srcset"),l=/^(?:(\^\^?)?(\?)?(\^\^?)?)?/,k=/^(on[a-z]+|formaction)$/;this.directive=function n(a,f){Ma(a,"directive");O(a)?(d(a),Sb(f,"directiveFactory"),e.hasOwnProperty(a)||(e[a]=[],b.factory(a+"Directive",["$injector","$exceptionHandler",function(b,d) [...]
+q(e[a],function(e,g){try{var h=b.invoke(e);E(h)?h={compile:ca(h)}:!h.compile&&h.link&&(h.compile=ca(h.link));h.priority=h.priority||0;h.index=g;h.name=h.name||a;h.require=h.require||h.controller&&h.name;h.restrict=h.restrict||"EA";I(h.scope)&&(h.$$isolateBindings=c(h.scope,h.name));f.push(h)}catch(l){d(l)}});return f}])),e[a].push(f)):q(a,pc(n));return this};this.aHrefSanitizationWhitelist=function(b){return y(b)?(a.aHrefSanitizationWhitelist(b),this):a.aHrefSanitizationWhitelist()};this [...]
+function(b){return y(b)?(a.imgSrcSanitizationWhitelist(b),this):a.imgSrcSanitizationWhitelist()};var m=!0;this.debugInfoEnabled=function(a){return y(a)?(m=a,this):m};this.$get=["$injector","$interpolate","$exceptionHandler","$templateRequest","$parse","$controller","$rootScope","$document","$sce","$animate","$$sanitizeUri",function(a,b,c,d,t,p,H,J,B,M,D){function G(a,b){try{a.addClass(b)}catch(c){}}function N(a,b,c,d,e){a instanceof z||(a=z(a));q(a,function(b,c){b.nodeType==ab&&b.nodeVal [...]
+(a[c]=z(b).wrap("<span></span>").parent()[0])});var f=Y(a,b,a,c,d,e);N.$$addScopeClass(a);var g=null;return function(b,c,d){Sb(b,"scope");d=d||{};var e=d.parentBoundTranscludeFn,h=d.transcludeControllers;d=d.futureParentElement;e&&e.$$boundTransclude&&(e=e.$$boundTransclude);g||(g=(d=d&&d[0])?"foreignobject"!==sa(d)&&d.toString().match(/SVG/)?"svg":"html":"html");d="html"!==g?z(T(g,z("<div>").append(a).html())):c?Ka.clone.call(a):a;if(h)for(var l in h)d.data("$"+l+"Controller",h[l].insta [...]
+b);c&&c(d,b);f&&f(b,d,d,e);return d}}function Y(a,b,c,d,e,f){function g(a,c,d,e){var f,l,k,n,m,v,u;if(p)for(u=Array(c.length),n=0;n<h.length;n+=3)f=h[n],u[f]=c[f];else u=c;n=0;for(m=h.length;n<m;)l=u[h[n++]],c=h[n++],f=h[n++],c?(c.scope?(k=a.$new(),N.$$addScopeInfo(z(l),k)):k=a,v=c.transcludeOnThisElement?$(a,c.transclude,e,c.elementTranscludeOnThisElement):!c.templateOnThisElement&&e?e:!e&&b?$(a,b):null,c(f,k,l,d,v)):f&&f(a,l.childNodes,s,e)}for(var h=[],l,k,n,m,p,v=0;v<a.length;v++){l= [...]
+W(a[v],[],l,0===v?d:s,e);(f=k.length?y(k,a[v],l,b,c,null,[],[],f):null)&&f.scope&&N.$$addScopeClass(l.$$element);l=f&&f.terminal||!(n=a[v].childNodes)||!n.length?null:Y(n,f?(f.transcludeOnThisElement||!f.templateOnThisElement)&&f.transclude:b);if(f||l)h.push(v,f,l),m=!0,p=p||f;f=null}return m?g:null}function $(a,b,c,d){return function(d,e,f,g,h){d||(d=a.$new(!1,h),d.$$transcluded=!0);return b(d,e,{parentBoundTranscludeFn:c,transcludeControllers:f,futureParentElement:g})}}function W(a,b,c [...]
+c.$attr,l;switch(a.nodeType){case ma:C(b,va(sa(a)),"E",d,e);for(var k,n,m,p=a.attributes,v=0,u=p&&p.length;v<u;v++){var B=!1,P=!1;k=p[v];l=k.name;n=U(k.value);k=va(l);if(m=ac.test(k))l=l.replace(Wc,"").substr(8).replace(/_(.)/g,function(a,b){return b.toUpperCase()});var M=k.replace(/(Start|End)$/,"");F(M)&&k===M+"Start"&&(B=l,P=l.substr(0,l.length-5)+"end",l=l.substr(0,l.length-6));k=va(l.toLowerCase());h[k]=l;if(m||!c.hasOwnProperty(k))c[k]=n,Qc(a,k)&&(c[k]=!0);R(a,b,n,k,m);C(b,k,"A",d, [...]
+a.className;I(a)&&(a=a.animVal);if(O(a)&&""!==a)for(;l=g.exec(a);)k=va(l[2]),C(b,k,"C",d,e)&&(c[k]=U(l[3])),a=a.substr(l.index+l[0].length);break;case ab:ia(b,a.nodeValue);break;case 8:try{if(l=f.exec(a.nodeValue))k=va(l[1]),C(b,k,"M",d,e)&&(c[k]=U(l[2]))}catch(t){}}b.sort(xa);return b}function S(a,b,c){var d=[],e=0;if(b&&a.hasAttribute&&a.hasAttribute(b)){do{if(!a)throw da("uterdir",b,c);a.nodeType==ma&&(a.hasAttribute(b)&&e++,a.hasAttribute(c)&&e--);d.push(a);a=a.nextSibling}while(0<e) [...]
+return z(d)}function Fa(a,b,c){return function(d,e,f,g,h){e=S(e[0],b,c);return a(d,e,f,g,h)}}function y(a,d,e,f,g,h,k,n,m){function B(a,b,c,d){if(a){c&&(a=Fa(a,c,d));a.require=K.require;a.directiveName=ia;if(J===K||K.$$isolateScope)a=X(a,{isolateScope:!0});k.push(a)}if(b){c&&(b=Fa(b,c,d));b.require=K.require;b.directiveName=ia;if(J===K||K.$$isolateScope)b=X(b,{isolateScope:!0});n.push(b)}}function P(a,b,c,d){var e,f="data",g=!1,h=c,k;if(O(b)){k=b.match(l);b=b.substring(k[0].length);k[3]& [...]
+null:k[1]=k[3]);"^"===k[1]?f="inheritedData":"^^"===k[1]&&(f="inheritedData",h=c.parent());"?"===k[2]&&(g=!0);e=null;d&&"data"===f&&(e=d[b])&&(e=e.instance);e=e||h[f]("$"+b+"Controller");if(!e&&!g)throw da("ctreq",b,a);return e||null}w(b)&&(e=[],q(b,function(b){e.push(P(a,b,c,d))}));return e}function M(a,c,f,g,h){function l(a,b,c){var d;Va(a)||(c=b,b=a,a=s);A&&(d=G);c||(c=A?S.parent():S);return h(a,b,d,c,Zb)}var m,v,B,D,G,ib,S,W;d===f?(W=e,S=e.$$element):(S=z(f),W=new $b(S,e));J&&(D=c.$n [...]
+(ib=l,ib.$$boundTransclude=h);H&&($={},G={},q(H,function(a){var b={$scope:a===J||a.$$isolateScope?D:c,$element:S,$attrs:W,$transclude:ib};B=a.controller;"@"==B&&(B=W[a.name]);b=p(B,b,!0,a.controllerAs);G[a.name]=b;A||S.data("$"+a.name+"Controller",b.instance);$[a.name]=b}));if(J){N.$$addScopeInfo(S,D,!0,!(Y&&(Y===J||Y===J.$$originalDirective)));N.$$addScopeClass(S,!0);g=$&&$[J.name];var ua=D;g&&g.identifier&&!0===J.bindToController&&(ua=g.instance);q(D.$$isolateBindings=J.$$isolateBindin [...]
+d){var e=a.attrName,f=a.optional,g,h,k,l;switch(a.mode){case "@":W.$observe(e,function(a){ua[d]=a});W.$$observers[e].$$scope=c;W[e]&&(ua[d]=b(W[e])(c));break;case "=":if(f&&!W[e])break;h=t(W[e]);l=h.literal?fa:function(a,b){return a===b||a!==a&&b!==b};k=h.assign||function(){g=ua[d]=h(c);throw da("nonassign",W[e],J.name);};g=ua[d]=h(c);f=function(a){l(a,ua[d])||(l(a,g)?k(c,a=ua[d]):ua[d]=a);return g=a};f.$stateful=!0;f=a.collection?c.$watchCollection(W[e],f):c.$watch(t(W[e],f),null,h.lite [...]
+f);break;case "&":h=t(W[e]),ua[d]=function(a){return h(c,a)}}})}$&&(q($,function(a){a()}),$=null);g=0;for(m=k.length;g<m;g++)v=k[g],Xc(v,v.isolateScope?D:c,S,W,v.require&&P(v.directiveName,v.require,S,G),ib);var Zb=c;J&&(J.template||null===J.templateUrl)&&(Zb=D);a&&a(Zb,f.childNodes,s,h);for(g=n.length-1;0<=g;g--)v=n[g],Xc(v,v.isolateScope?D:c,S,W,v.require&&P(v.directiveName,v.require,S,G),ib)}m=m||{};for(var D=-Number.MAX_VALUE,G,H=m.controllerDirectives,$,J=m.newIsolateScopeDirective, [...]
+Ga=m.nonTlbTranscludeDirective,C=!1,Yb=!1,A=m.hasElementTranscludeDirective,x=e.$$element=z(d),K,ia,F,gb=f,xa,qa=0,L=a.length;qa<L;qa++){K=a[qa];var R=K.$$start,jb=K.$$end;R&&(x=S(d,R,jb));F=s;if(D>K.priority)break;if(F=K.scope)K.templateUrl||(I(F)?(Oa("new/isolated scope",J||G,K,x),J=K):Oa("new/isolated scope",J,K,x)),G=G||K;ia=K.name;!K.templateUrl&&K.controller&&(F=K.controller,H=H||{},Oa("'"+ia+"' controller",H[ia],K,x),H[ia]=K);if(F=K.transclude)C=!0,K.$$tlb||(Oa("transclusion",Ga,K [...]
+"element"==F?(A=!0,D=K.priority,F=x,x=e.$$element=z(V.createComment(" "+ia+": "+e[ia]+" ")),d=x[0],Q(g,Za.call(F,0),d),gb=N(F,f,D,h&&h.name,{nonTlbTranscludeDirective:Ga})):(F=z(Vb(d)).contents(),x.empty(),gb=N(F,f));if(K.template)if(Yb=!0,Oa("template",Y,K,x),Y=K,F=E(K.template)?K.template(x,e):K.template,F=Yc(F),K.replace){h=K;F=Tb.test(F)?Zc(T(K.templateNamespace,U(F))):[];d=F[0];if(1!=F.length||d.nodeType!==ma)throw da("tplrt",ia,"");Q(g,x,d);L={$attr:{}};F=W(d,[],L);var tf=a.splice( [...]
+(qa+1));J&&hb(F);a=a.concat(F).concat(tf);Vc(e,L);L=a.length}else x.html(F);if(K.templateUrl)Yb=!0,Oa("template",Y,K,x),Y=K,K.replace&&(h=K),M=Xb(a.splice(qa,a.length-qa),x,e,g,C&&gb,k,n,{controllerDirectives:H,newIsolateScopeDirective:J,templateDirective:Y,nonTlbTranscludeDirective:Ga}),L=a.length;else if(K.compile)try{xa=K.compile(x,e,gb),E(xa)?B(null,xa,R,jb):xa&&B(xa.pre,xa.post,R,jb)}catch(ac){c(ac,ta(x))}K.terminal&&(M.terminal=!0,D=Math.max(D,K.priority))}M.scope=G&&!0===G.scope;M [...]
+C;M.elementTranscludeOnThisElement=A;M.templateOnThisElement=Yb;M.transclude=gb;m.hasElementTranscludeDirective=A;return M}function hb(a){for(var b=0,c=a.length;b<c;b++)a[b]=Pb(a[b],{$$isolateScope:!0})}function C(b,d,f,g,h,k,l){if(d===h)return null;h=null;if(e.hasOwnProperty(d)){var m;d=a.get(d+"Directive");for(var p=0,B=d.length;p<B;p++)try{m=d[p],(g===s||g>m.priority)&&-1!=m.restrict.indexOf(f)&&(k&&(m=Pb(m,{$$start:k,$$end:l})),b.push(m),h=m)}catch(u){c(u)}}return h}function F(b){if( [...]
+a.get(b+"Directive"),d=0,f=c.length;d<f;d++)if(b=c[d],b.multiElement)return!0;return!1}function Vc(a,b){var c=b.$attr,d=a.$attr,e=a.$$element;q(a,function(d,e){"$"!=e.charAt(0)&&(b[e]&&b[e]!==d&&(d+=("style"===e?";":" ")+b[e]),a.$set(e,d,!0,c[e]))});q(b,function(b,f){"class"==f?(G(e,b),a["class"]=(a["class"]?a["class"]+" ":"")+b):"style"==f?(e.attr("style",e.attr("style")+";"+b),a.style=(a.style?a.style+";":"")+b):"$"==f.charAt(0)||a.hasOwnProperty(f)||(a[f]=b,d[f]=c[f])})}function Xb(a, [...]
+h,k){var l=[],n,m,p=b[0],v=a.shift(),u=Pb(v,{templateUrl:null,transclude:null,replace:null,$$originalDirective:v}),M=E(v.templateUrl)?v.templateUrl(b,c):v.templateUrl,D=v.templateNamespace;b.empty();d(B.getTrustedResourceUrl(M)).then(function(d){var B,P;d=Yc(d);if(v.replace){d=Tb.test(d)?Zc(T(D,U(d))):[];B=d[0];if(1!=d.length||B.nodeType!==ma)throw da("tplrt",v.name,M);d={$attr:{}};Q(e,b,B);var t=W(B,[],d);I(v.scope)&&hb(t);a=t.concat(a);Vc(c,d)}else B=p,b.html(d);a.unshift(u);n=y(a,B,c, [...]
+k);q(e,function(a,c){a==B&&(e[c]=b[0])});for(m=Y(b[0].childNodes,f);l.length;){d=l.shift();P=l.shift();var H=l.shift(),N=l.shift(),t=b[0];if(!d.$$destroyed){if(P!==p){var J=P.className;k.hasElementTranscludeDirective&&v.replace||(t=Vb(B));Q(H,z(P),t);G(z(t),J)}P=n.transcludeOnThisElement?$(d,n.transclude,N):N;n(m,d,t,e,P)}}l=null});return function(a,b,c,d,e){a=e;b.$$destroyed||(l?l.push(b,c,d,a):(n.transcludeOnThisElement&&(a=$(b,n.transclude,e)),n(m,b,c,d,a)))}}function xa(a,b){var c=b. [...]
+a.priority;return 0!==c?c:a.name!==b.name?a.name<b.name?-1:1:a.index-b.index}function Oa(a,b,c,d){if(b)throw da("multidir",b.name,c.name,a,ta(d));}function ia(a,c){var d=b(c,!0);d&&a.push({priority:0,compile:function(a){a=a.parent();var b=!!a.length;b&&N.$$addBindingClass(a);return function(a,c){var e=c.parent();b||N.$$addBindingClass(e);N.$$addBindingInfo(e,d.expressions);a.$watch(d,function(a){c[0].nodeValue=a})}}})}function T(a,b){a=L(a||"html");switch(a){case "svg":case "math":var c= [...]
+c.innerHTML="<"+a+">"+b+"</"+a+">";return c.childNodes[0].childNodes;default:return b}}function jb(a,b){if("srcdoc"==b)return B.HTML;var c=sa(a);if("xlinkHref"==b||"form"==c&&"action"==b||"img"!=c&&("src"==b||"ngSrc"==b))return B.RESOURCE_URL}function R(a,c,d,e,f){var g=jb(a,e);f=h[e]||f;var l=b(d,!0,g,f);if(l){if("multiple"===e&&"select"===sa(a))throw da("selmulti",ta(a));c.push({priority:100,compile:function(){return{pre:function(a,c,h){c=h.$$observers||(h.$$observers={});if(k.test(e)) [...]
+var n=h[e];n!==d&&(l=n&&b(n,!0,g,f),d=n);l&&(h[e]=l(a),(c[e]||(c[e]=[])).$$inter=!0,(h.$$observers&&h.$$observers[e].$$scope||a).$watch(l,function(a,b){"class"===e&&a!=b?h.$updateClass(a,b):h.$set(e,a)}))}}}})}}function Q(a,b,c){var d=b[0],e=b.length,f=d.parentNode,g,h;if(a)for(g=0,h=a.length;g<h;g++)if(a[g]==d){a[g++]=c;h=g+e-1;for(var l=a.length;g<l;g++,h++)h<l?a[g]=a[h]:delete a[g];a.length-=e-1;a.context===d&&(a.context=c);break}f&&f.replaceChild(c,d);a=V.createDocumentFragment();a.a [...]
+z(c).data(z(d).data());pa?(Rb=!0,pa.cleanData([d])):delete z.cache[d[z.expando]];d=1;for(e=b.length;d<e;d++)f=b[d],z(f).remove(),a.appendChild(f),delete b[d];b[0]=c;b.length=1}function X(a,b){return x(function(){return a.apply(null,arguments)},a,b)}function Xc(a,b,d,e,f,g){try{a(b,d,e,f,g)}catch(h){c(h,ta(d))}}var $b=function(a,b){if(b){var c=Object.keys(b),d,e,f;d=0;for(e=c.length;d<e;d++)f=c[d],this[f]=b[f]}else this.$attr={};this.$$element=a};$b.prototype={$normalize:va,$addClass:func [...]
+0<a.length&&M.addClass(this.$$element,a)},$removeClass:function(a){a&&0<a.length&&M.removeClass(this.$$element,a)},$updateClass:function(a,b){var c=$c(a,b);c&&c.length&&M.addClass(this.$$element,c);(c=$c(b,a))&&c.length&&M.removeClass(this.$$element,c)},$set:function(a,b,d,e){var f=this.$$element[0],g=Qc(f,a),h=pf(f,a),f=a;g?(this.$$element.prop(a,b),e=g):h&&(this[h]=b,f=h);this[a]=b;e?this.$attr[a]=e:(e=this.$attr[a])||(this.$attr[a]=e=yc(a,"-"));g=sa(this.$$element);if("a"===g&&"href"= [...]
+g&&"src"===a)this[a]=b=D(b,"src"===a);else if("img"===g&&"srcset"===a){for(var g="",h=U(b),l=/(\s+\d+x\s*,|\s+\d+w\s*,|\s+,|,\s+)/,l=/\s/.test(h)?l:/(,)/,h=h.split(l),l=Math.floor(h.length/2),k=0;k<l;k++)var n=2*k,g=g+D(U(h[n]),!0),g=g+(" "+U(h[n+1]));h=U(h[2*k]).split(/\s/);g+=D(U(h[0]),!0);2===h.length&&(g+=" "+U(h[1]));this[a]=b=g}!1!==d&&(null===b||b===s?this.$$element.removeAttr(e):this.$$element.attr(e,b));(a=this.$$observers)&&q(a[f],function(a){try{a(b)}catch(d){c(d)}})},$observe [...]
+b){var c=this,d=c.$$observers||(c.$$observers=ga()),e=d[a]||(d[a]=[]);e.push(b);H.$evalAsync(function(){!e.$$inter&&c.hasOwnProperty(a)&&b(c[a])});return function(){Xa(e,b)}}};var Ga=b.startSymbol(),qa=b.endSymbol(),Yc="{{"==Ga||"}}"==qa?na:function(a){return a.replace(/\{\{/g,Ga).replace(/}}/g,qa)},ac=/^ngAttr[A-Z]/;N.$$addBindingInfo=m?function(a,b){var c=a.data("$binding")||[];w(b)?c=c.concat(b):c.push(b);a.data("$binding",c)}:A;N.$$addBindingClass=m?function(a){G(a,"ng-binding")}:A;N [...]
+m?function(a,b,c,d){a.data(c?d?"$isolateScopeNoTemplate":"$isolateScope":"$scope",b)}:A;N.$$addScopeClass=m?function(a,b){G(a,b?"ng-isolate-scope":"ng-scope")}:A;return N}]}function va(b){return eb(b.replace(Wc,""))}function $c(b,a){var c="",d=b.split(/\s+/),e=a.split(/\s+/),f=0;a:for(;f<d.length;f++){for(var g=d[f],h=0;h<e.length;h++)if(g==e[h])continue a;c+=(0<c.length?" ":"")+g}return c}function Zc(b){b=z(b);var a=b.length;if(1>=a)return b;for(;a--;)8===b[a].nodeType&&uf.call(b,a,1);r [...]
+{},a=!1,c=/^(\S+)(\s+as\s+(\w+))?$/;this.register=function(a,c){Ma(a,"controller");I(a)?x(b,a):b[a]=c};this.allowGlobals=function(){a=!0};this.$get=["$injector","$window",function(d,e){function f(a,b,c,d){if(!a||!I(a.$scope))throw F("$controller")("noscp",d,b);a.$scope[b]=c}return function(g,h,l,k){var m,r,n;l=!0===l;k&&O(k)&&(n=k);if(O(g)){k=g.match(c);if(!k)throw vf("ctrlfmt",g);r=k[1];n=n||k[3];g=b.hasOwnProperty(r)?b[r]:Ac(h.$scope,r,!0)||(a?Ac(e,r,!0):s);La(g,r,!0)}if(l)return l=(w( [...]
+1]:g).prototype,m=Object.create(l||null),n&&f(h,n,m,r||g.name),x(function(){d.invoke(g,m,h,r);return m},{instance:m,identifier:n});m=d.instantiate(g,h,r);n&&f(h,n,m,r||g.name);return m}}]}function Le(){this.$get=["$window",function(b){return z(b.document)}]}function Me(){this.$get=["$log",function(b){return function(a,c){b.error.apply(b,arguments)}}]}function bc(b,a){if(O(b)){var c=b.replace(wf,"").trim();if(c){var d=a("Content-Type");(d=d&&0===d.indexOf(ad))||(d=(d=c.match(xf))&&yf[d[0] [...]
+d&&(b=tc(c))}}return b}function bd(b){var a=ga(),c,d,e;if(!b)return a;q(b.split("\n"),function(b){e=b.indexOf(":");c=L(U(b.substr(0,e)));d=U(b.substr(e+1));c&&(a[c]=a[c]?a[c]+", "+d:d)});return a}function cd(b){var a=I(b)?b:s;return function(c){a||(a=bd(b));return c?(c=a[L(c)],void 0===c&&(c=null),c):a}}function dd(b,a,c,d){if(E(d))return d(b,a,c);q(d,function(d){b=d(b,a,c)});return b}function Pe(){var b=this.defaults={transformResponse:[bc],transformRequest:[function(a){return I(a)&&"[o [...]
+Aa.call(a)&&"[object Blob]"!==Aa.call(a)&&"[object FormData]"!==Aa.call(a)?$a(a):a}],headers:{common:{Accept:"application/json, text/plain, */*"},post:oa(cc),put:oa(cc),patch:oa(cc)},xsrfCookieName:"XSRF-TOKEN",xsrfHeaderName:"X-XSRF-TOKEN"},a=!1;this.useApplyAsync=function(b){return y(b)?(a=!!b,this):a};var c=this.interceptors=[];this.$get=["$httpBackend","$browser","$cacheFactory","$rootScope","$q","$injector",function(d,e,f,g,h,l){function k(a){function c(a){var b=x({},a);b.data=a.dat [...]
+a.headers,a.status,e.transformResponse):a.data;a=a.status;return 200<=a&&300>a?b:h.reject(b)}function d(a){var b,c={};q(a,function(a,d){E(a)?(b=a(),null!=b&&(c[d]=b)):c[d]=a});return c}if(!ba.isObject(a))throw F("$http")("badreq",a);var e=x({method:"get",transformRequest:b.transformRequest,transformResponse:b.transformResponse},a);e.headers=function(a){var c=b.headers,e=x({},a.headers),f,g,c=x({},c.common,c[L(a.method)]);a:for(f in c){a=L(f);for(g in e)if(L(g)===a)continue a;e[f]=c[f]}re [...]
+e.method=vb(e.method);var f=[function(a){var d=a.headers,e=dd(a.data,cd(d),s,a.transformRequest);C(e)&&q(d,function(a,b){"content-type"===L(b)&&delete d[b]});C(a.withCredentials)&&!C(b.withCredentials)&&(a.withCredentials=b.withCredentials);return m(a,e).then(c,c)},s],g=h.when(e);for(q(u,function(a){(a.request||a.requestError)&&f.unshift(a.request,a.requestError);(a.response||a.responseError)&&f.push(a.response,a.responseError)});f.length;){a=f.shift();var l=f.shift(),g=g.then(a,l)}g.suc [...]
+"fn");g.then(function(b){a(b.data,b.status,b.headers,e)});return g};g.error=function(a){La(a,"fn");g.then(null,function(b){a(b.data,b.status,b.headers,e)});return g};return g}function m(c,f){function l(b,c,d,e){function f(){m(c,b,d,e)}D&&(200<=b&&300>b?D.put(q,[b,c,bd(d),e]):D.remove(q));a?g.$applyAsync(f):(f(),g.$$phase||g.$apply())}function m(a,b,d,e){b=Math.max(b,0);(200<=b&&300>b?B.resolve:B.reject)({data:a,status:b,headers:cd(d),config:c,statusText:e})}function u(a){m(a.data,a.statu [...]
+a.statusText)}function J(){var a=k.pendingRequests.indexOf(c);-1!==a&&k.pendingRequests.splice(a,1)}var B=h.defer(),M=B.promise,D,G,N=c.headers,q=r(c.url,c.params);k.pendingRequests.push(c);M.then(J,J);!c.cache&&!b.cache||!1===c.cache||"GET"!==c.method&&"JSONP"!==c.method||(D=I(c.cache)?c.cache:I(b.cache)?b.cache:n);D&&(G=D.get(q),y(G)?G&&E(G.then)?G.then(u,u):w(G)?m(G[1],G[0],oa(G[2]),G[3]):m(G,200,{},"OK"):D.put(q,M));C(G)&&((G=ed(c.url)?e.cookies()[c.xsrfCookieName||b.xsrfCookieName]: [...]
+b.xsrfHeaderName]=G),d(c.method,q,f,l,N,c.timeout,c.withCredentials,c.responseType));return M}function r(a,b){if(!b)return a;var c=[];Jd(b,function(a,b){null===a||C(a)||(w(a)||(a=[a]),q(a,function(a){I(a)&&(a=ea(a)?a.toISOString():$a(a));c.push(Ca(b)+"="+Ca(a))}))});0<c.length&&(a+=(-1==a.indexOf("?")?"?":"&")+c.join("&"));return a}var n=f("$http"),u=[];q(c,function(a){u.unshift(O(a)?l.get(a):l.invoke(a))});k.pendingRequests=[];(function(a){q(arguments,function(a){k[a]=function(b,c){retu [...]
+{},{method:a,url:b}))}})})("get","delete","head","jsonp");(function(a){q(arguments,function(a){k[a]=function(b,c,d){return k(x(d||{},{method:a,url:b,data:c}))}})})("post","put","patch");k.defaults=b;return k}]}function zf(){return new T.XMLHttpRequest}function Qe(){this.$get=["$browser","$window","$document",function(b,a,c){return Af(b,zf,b.defer,a.angular.callbacks,c[0])}]}function Af(b,a,c,d,e){function f(a,b,c){var f=e.createElement("script"),m=null;f.type="text/javascript";f.src=a;f. [...]
+m=function(a){f.removeEventListener("load",m,!1);f.removeEventListener("error",m,!1);e.body.removeChild(f);f=null;var g=-1,u="unknown";a&&("load"!==a.type||d[b].called||(a={type:"error"}),u=a.type,g="error"===a.type?404:200);c&&c(g,u)};f.addEventListener("load",m,!1);f.addEventListener("error",m,!1);e.body.appendChild(f);return m}return function(e,h,l,k,m,r,n,u){function v(){p&&p();H&&H.abort()}function P(a,d,e,f,g){B!==s&&c.cancel(B);p=H=null;a(d,e,f,g);b.$$completeOutstandingRequest(A) [...]
+h=h||b.url();if("jsonp"==L(e)){var t="_"+(d.counter++).toString(36);d[t]=function(a){d[t].data=a;d[t].called=!0};var p=f(h.replace("JSON_CALLBACK","angular.callbacks."+t),t,function(a,b){P(k,a,d[t].data,"",b);d[t]=A})}else{var H=a();H.open(e,h,!0);q(m,function(a,b){y(a)&&H.setRequestHeader(b,a)});H.onload=function(){var a=H.statusText||"",b="response"in H?H.response:H.responseText,c=1223===H.status?204:H.status;0===c&&(c=b?200:"file"==ya(h).protocol?404:0);P(k,c,b,H.getAllResponseHeaders [...]
+function(){P(k,-1,null,null,"")};H.onerror=e;H.onabort=e;n&&(H.withCredentials=!0);if(u)try{H.responseType=u}catch(J){if("json"!==u)throw J;}H.send(l||null)}if(0<r)var B=c(v,r);else r&&E(r.then)&&r.then(v)}}function Ne(){var b="{{",a="}}";this.startSymbol=function(a){return a?(b=a,this):b};this.endSymbol=function(b){return b?(a=b,this):a};this.$get=["$parse","$exceptionHandler","$sce",function(c,d,e){function f(a){return"\\\\\\"+a}function g(f,g,u,v){function P(c){return c.replace(k,b).r [...]
+a)}function t(a){try{var b=a;a=u?e.getTrusted(u,b):e.valueOf(b);var c;if(v&&!y(a))c=a;else if(null==a)c="";else{switch(typeof a){case "string":break;case "number":a=""+a;break;default:a=$a(a)}c=a}return c}catch(g){c=dc("interr",f,g.toString()),d(c)}}v=!!v;for(var p,H,q=0,B=[],M=[],D=f.length,G=[],N=[];q<D;)if(-1!=(p=f.indexOf(b,q))&&-1!=(H=f.indexOf(a,p+h)))q!==p&&G.push(P(f.substring(q,p))),q=f.substring(p+h,H),B.push(q),M.push(c(q,t)),q=H+l,N.push(G.length),G.push("");else{q!==D&&G.pus [...]
+break}if(u&&1<G.length)throw dc("noconcat",f);if(!g||B.length){var Y=function(a){for(var b=0,c=B.length;b<c;b++){if(v&&C(a[b]))return;G[N[b]]=a[b]}return G.join("")};return x(function(a){var b=0,c=B.length,e=Array(c);try{for(;b<c;b++)e[b]=M[b](a);return Y(e)}catch(g){a=dc("interr",f,g.toString()),d(a)}},{exp:f,expressions:B,$$watchDelegate:function(a,b,c){var d;return a.$watchGroup(M,function(c,e){var f=Y(c);E(b)&&b.call(this,f,c!==e?d:f,a);d=f},c)}})}}var h=b.length,l=a.length,k=new Reg [...]
+f),"g"),m=new RegExp(a.replace(/./g,f),"g");g.startSymbol=function(){return b};g.endSymbol=function(){return a};return g}]}function Oe(){this.$get=["$rootScope","$window","$q","$$q",function(b,a,c,d){function e(e,h,l,k){var m=a.setInterval,r=a.clearInterval,n=0,u=y(k)&&!k,v=(u?d:c).defer(),P=v.promise;l=y(l)?l:0;P.then(null,null,e);P.$$intervalId=m(function(){v.notify(n++);0<l&&n>=l&&(v.resolve(n),r(P.$$intervalId),delete f[P.$$intervalId]);u||b.$apply()},h);f[P.$$intervalId]=v;return P} [...]
+e.cancel=function(b){return b&&b.$$intervalId in f?(f[b.$$intervalId].reject("canceled"),a.clearInterval(b.$$intervalId),delete f[b.$$intervalId],!0):!1};return e}]}function Wd(){this.$get=function(){return{id:"en-us",NUMBER_FORMATS:{DECIMAL_SEP:".",GROUP_SEP:",",PATTERNS:[{minInt:1,minFrac:0,maxFrac:3,posPre:"",posSuf:"",negPre:"-",negSuf:"",gSize:3,lgSize:3},{minInt:1,minFrac:2,maxFrac:2,posPre:"\u00a4",posSuf:"",negPre:"(\u00a4",negSuf:")",gSize:3,lgSize:3}],CURRENCY_SYM:"$"},DATETIME [...]
+SHORTMONTH:"Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec".split(" "),DAY:"Sunday Monday Tuesday Wednesday Thursday Friday Saturday".split(" "),SHORTDAY:"Sun Mon Tue Wed Thu Fri Sat".split(" "),AMPMS:["AM","PM"],medium:"MMM d, y h:mm:ss a","short":"M/d/yy h:mm a",fullDate:"EEEE, MMMM d, y",longDate:"MMMM d, y",mediumDate:"MMM d, y",shortDate:"M/d/yy",mediumTime:"h:mm:ss a",shortTime:"h:mm a",ERANAMES:["Before Christ","Anno Domini"],ERAS:["BC","AD"]},pluralCat:function(b){return 1===b?" [...]
+function ec(b){b=b.split("/");for(var a=b.length;a--;)b[a]=sb(b[a]);return b.join("/")}function fd(b,a){var c=ya(b);a.$$protocol=c.protocol;a.$$host=c.hostname;a.$$port=aa(c.port)||Bf[c.protocol]||null}function gd(b,a){var c="/"!==b.charAt(0);c&&(b="/"+b);var d=ya(b);a.$$path=decodeURIComponent(c&&"/"===d.pathname.charAt(0)?d.pathname.substring(1):d.pathname);a.$$search=vc(d.search);a.$$hash=decodeURIComponent(d.hash);a.$$path&&"/"!=a.$$path.charAt(0)&&(a.$$path="/"+a.$$path)}function wa [...]
+a.indexOf(b))return a.substr(b.length)}function Ea(b){var a=b.indexOf("#");return-1==a?b:b.substr(0,a)}function Gb(b){return b.replace(/(#.+)|#$/,"$1")}function fc(b){return b.substr(0,Ea(b).lastIndexOf("/")+1)}function gc(b,a){this.$$html5=!0;a=a||"";var c=fc(b);fd(b,this);this.$$parse=function(a){var b=wa(c,a);if(!O(b))throw Hb("ipthprfx",a,c);gd(b,this);this.$$path||(this.$$path="/");this.$$compose()};this.$$compose=function(){var a=Qb(this.$$search),b=this.$$hash?"#"+sb(this.$$hash): [...]
+ec(this.$$path)+(a?"?"+a:"")+b;this.$$absUrl=c+this.$$url.substr(1)};this.$$parseLinkUrl=function(d,e){if(e&&"#"===e[0])return this.hash(e.slice(1)),!0;var f,g;(f=wa(b,d))!==s?(g=f,g=(f=wa(a,f))!==s?c+(wa("/",f)||f):b+g):(f=wa(c,d))!==s?g=c+f:c==d+"/"&&(g=c);g&&this.$$parse(g);return!!g}}function hc(b,a){var c=fc(b);fd(b,this);this.$$parse=function(d){d=wa(b,d)||wa(c,d);var e;"#"===d.charAt(0)?(e=wa(a,d),C(e)&&(e=d)):e=this.$$html5?d:"";gd(e,this);d=this.$$path;var f=/^\/[A-Z]:(\/.*)/;0= [...]
+(e=e.replace(b,""));f.exec(e)||(d=(e=f.exec(d))?e[1]:d);this.$$path=d;this.$$compose()};this.$$compose=function(){var c=Qb(this.$$search),e=this.$$hash?"#"+sb(this.$$hash):"";this.$$url=ec(this.$$path)+(c?"?"+c:"")+e;this.$$absUrl=b+(this.$$url?a+this.$$url:"")};this.$$parseLinkUrl=function(a,c){return Ea(b)==Ea(a)?(this.$$parse(a),!0):!1}}function hd(b,a){this.$$html5=!0;hc.apply(this,arguments);var c=fc(b);this.$$parseLinkUrl=function(d,e){if(e&&"#"===e[0])return this.hash(e.slice(1)), [...]
+g;b==Ea(d)?f=d:(g=wa(c,d))?f=b+a+g:c===d+"/"&&(f=c);f&&this.$$parse(f);return!!f};this.$$compose=function(){var c=Qb(this.$$search),e=this.$$hash?"#"+sb(this.$$hash):"";this.$$url=ec(this.$$path)+(c?"?"+c:"")+e;this.$$absUrl=b+a+this.$$url}}function Ib(b){return function(){return this[b]}}function id(b,a){return function(c){if(C(c))return this[b];this[b]=a(c);this.$$compose();return this}}function Re(){var b="",a={enabled:!1,requireBase:!0,rewriteLinks:!0};this.hashPrefix=function(a){ret [...]
+(b=a,this):b};this.html5Mode=function(b){return Wa(b)?(a.enabled=b,this):I(b)?(Wa(b.enabled)&&(a.enabled=b.enabled),Wa(b.requireBase)&&(a.requireBase=b.requireBase),Wa(b.rewriteLinks)&&(a.rewriteLinks=b.rewriteLinks),this):a};this.$get=["$rootScope","$browser","$sniffer","$rootElement","$window",function(c,d,e,f,g){function h(a,b,c){var e=k.url(),f=k.$$state;try{d.url(a,b,c),k.$$state=d.state()}catch(g){throw k.url(e),k.$$state=f,g;}}function l(a,b){c.$broadcast("$locationChangeSuccess", [...]
+a,k.$$state,b)}var k,m;m=d.baseHref();var r=d.url(),n;if(a.enabled){if(!m&&a.requireBase)throw Hb("nobase");n=r.substring(0,r.indexOf("/",r.indexOf("//")+2))+(m||"/");m=e.history?gc:hd}else n=Ea(r),m=hc;k=new m(n,"#"+b);k.$$parseLinkUrl(r,r);k.$$state=d.state();var u=/^\s*(javascript|mailto):/i;f.on("click",function(b){if(a.rewriteLinks&&!b.ctrlKey&&!b.metaKey&&!b.shiftKey&&2!=b.which&&2!=b.button){for(var e=z(b.target);"a"!==sa(e[0]);)if(e[0]===f[0]||!(e=e.parent())[0])return;var h=e.pr [...]
+l=e.attr("href")||e.attr("xlink:href");I(h)&&"[object SVGAnimatedString]"===h.toString()&&(h=ya(h.animVal).href);u.test(h)||!h||e.attr("target")||b.isDefaultPrevented()||!k.$$parseLinkUrl(h,l)||(b.preventDefault(),k.absUrl()!=d.url()&&(c.$apply(),g.angular["ff-684208-preventDefault"]=!0))}});Gb(k.absUrl())!=Gb(r)&&d.url(k.absUrl(),!0);var v=!0;d.onUrlChange(function(a,b){c.$evalAsync(function(){var d=k.absUrl(),e=k.$$state,f;k.$$parse(a);k.$$state=b;f=c.$broadcast("$locationChangeStart", [...]
+k.absUrl()===a&&(f?(k.$$parse(d),k.$$state=e,h(d,!1,e)):(v=!1,l(d,e)))});c.$$phase||c.$digest()});c.$watch(function(){var a=Gb(d.url()),b=Gb(k.absUrl()),f=d.state(),g=k.$$replace,n=a!==b||k.$$html5&&e.history&&f!==k.$$state;if(v||n)v=!1,c.$evalAsync(function(){var b=k.absUrl(),d=c.$broadcast("$locationChangeStart",b,a,k.$$state,f).defaultPrevented;k.absUrl()===b&&(d?(k.$$parse(a),k.$$state=f):(n&&h(b,g,f===k.$$state?null:k.$$state),l(a,f)))});k.$$replace=!1});return k}]}function Se(){var [...]
+this.debugEnabled=function(a){return y(a)?(b=a,this):b};this.$get=["$window",function(c){function d(a){a instanceof Error&&(a.stack?a=a.message&&-1===a.stack.indexOf(a.message)?"Error: "+a.message+"\n"+a.stack:a.stack:a.sourceURL&&(a=a.message+"\n"+a.sourceURL+":"+a.line));return a}function e(a){var b=c.console||{},e=b[a]||b.log||A;a=!1;try{a=!!e.apply}catch(l){}return a?function(){var a=[];q(arguments,function(b){a.push(d(b))});return e.apply(b,a)}:function(a,b){e(a,null==b?"":b)}}retur [...]
+info:e("info"),warn:e("warn"),error:e("error"),debug:function(){var c=e("debug");return function(){b&&c.apply(a,arguments)}}()}}]}function ra(b,a){if("__defineGetter__"===b||"__defineSetter__"===b||"__lookupGetter__"===b||"__lookupSetter__"===b||"__proto__"===b)throw ja("isecfld",a);return b}function ka(b,a){if(b){if(b.constructor===b)throw ja("isecfn",a);if(b.window===b)throw ja("isecwindow",a);if(b.children&&(b.nodeName||b.prop&&b.attr&&b.find))throw ja("isecdom",a);if(b===Object)throw [...]
+a);}return b}function ic(b){return b.constant}function kb(b,a,c,d,e){ka(b,e);ka(a,e);c=c.split(".");for(var f,g=0;1<c.length;g++){f=ra(c.shift(),e);var h=0===g&&a&&a[f]||b[f];h||(h={},b[f]=h);b=ka(h,e)}f=ra(c.shift(),e);ka(b[f],e);return b[f]=d}function Pa(b){return"constructor"==b}function jd(b,a,c,d,e,f,g){ra(b,f);ra(a,f);ra(c,f);ra(d,f);ra(e,f);var h=function(a){return ka(a,f)},l=g||Pa(b)?h:na,k=g||Pa(a)?h:na,m=g||Pa(c)?h:na,r=g||Pa(d)?h:na,n=g||Pa(e)?h:na;return function(f,g){var h=g [...]
+g:f;if(null==h)return h;h=l(h[b]);if(!a)return h;if(null==h)return s;h=k(h[a]);if(!c)return h;if(null==h)return s;h=m(h[c]);if(!d)return h;if(null==h)return s;h=r(h[d]);return e?null==h?s:h=n(h[e]):h}}function Cf(b,a){return function(c,d){return b(c,d,ka,a)}}function Df(b,a,c){var d=a.expensiveChecks,e=d?Ef:Ff,f=e[b];if(f)return f;var g=b.split("."),h=g.length;if(a.csp)f=6>h?jd(g[0],g[1],g[2],g[3],g[4],c,d):function(a,b){var e=0,f;do f=jd(g[e++],g[e++],g[e++],g[e++],g[e++],c,d)(a,b),b=s, [...]
+h);return f};else{var l="";d&&(l+="s = eso(s, fe);\nl = eso(l, fe);\n");var k=d;q(g,function(a,b){ra(a,c);var e=(b?"s":'((l&&l.hasOwnProperty("'+a+'"))?l:s)')+"."+a;if(d||Pa(a))e="eso("+e+", fe)",k=!0;l+="if(s == null) return undefined;\ns="+e+";\n"});l+="return s;";a=new Function("s","l","eso","fe",l);a.toString=ca(l);k&&(a=Cf(a,c));f=a}f.sharedGetter=!0;f.assign=function(a,c,d){return kb(a,d,b,c,b)};return e[b]=f}function jc(b){return E(b.valueOf)?b.valueOf():Gf.call(b)}function Te(){v [...]
+a=ga();this.$get=["$filter","$sniffer",function(c,d){function e(a){var b=a;a.sharedGetter&&(b=function(b,c){return a(b,c)},b.literal=a.literal,b.constant=a.constant,b.assign=a.assign);return b}function f(a,b){for(var c=0,d=a.length;c<d;c++){var e=a[c];e.constant||(e.inputs?f(e.inputs,b):-1===b.indexOf(e)&&b.push(e))}return b}function g(a,b){return null==a||null==b?a===b:"object"===typeof a&&(a=jc(a),"object"===typeof a)?!1:a===b||a!==a&&b!==b}function h(a,b,c,d){var e=d.$$inputs||(d.$$in [...]
+[])),h;if(1===e.length){var l=g,e=e[0];return a.$watch(function(a){var b=e(a);g(b,l)||(h=d(a),l=b&&jc(b));return h},b,c)}for(var k=[],n=0,m=e.length;n<m;n++)k[n]=g;return a.$watch(function(a){for(var b=!1,c=0,f=e.length;c<f;c++){var l=e[c](a);if(b||(b=!g(l,k[c])))k[c]=l&&jc(l)}b&&(h=d(a));return h},b,c)}function l(a,b,c,d){var e,f;return e=a.$watch(function(a){return d(a)},function(a,c,d){f=a;E(b)&&b.apply(this,arguments);y(a)&&d.$$postDigest(function(){y(f)&&e()})},c)}function k(a,b,c,d [...]
+!0;q(a,function(a){y(a)||(b=!1)});return b}var f,g;return f=a.$watch(function(a){return d(a)},function(a,c,d){g=a;E(b)&&b.call(this,a,c,d);e(a)&&d.$$postDigest(function(){e(g)&&f()})},c)}function m(a,b,c,d){var e;return e=a.$watch(function(a){return d(a)},function(a,c,d){E(b)&&b.apply(this,arguments);e()},c)}function r(a,b){if(!b)return a;var c=a.$$watchDelegate,c=c!==k&&c!==l?function(c,d){var e=a(c,d);return b(e,c,d)}:function(c,d){var e=a(c,d),f=b(e,c,d);return y(e)?f:e};a.$$watchDele [...]
+h?c.$$watchDelegate=a.$$watchDelegate:b.$stateful||(c.$$watchDelegate=h,c.inputs=[a]);return c}var n={csp:d.csp,expensiveChecks:!1},u={csp:d.csp,expensiveChecks:!0};return function(d,f,g){var p,q,s;switch(typeof d){case "string":s=d=d.trim();var B=g?a:b;p=B[s];p||(":"===d.charAt(0)&&":"===d.charAt(1)&&(q=!0,d=d.substring(2)),g=g?u:n,p=new kc(g),p=(new lb(p,c,g)).parse(d),p.constant?p.$$watchDelegate=m:q?(p=e(p),p.$$watchDelegate=p.literal?k:l):p.inputs&&(p.$$watchDelegate=h),B[s]=p);retu [...]
+case "function":return r(d,f);default:return r(A,f)}}}]}function Ve(){this.$get=["$rootScope","$exceptionHandler",function(b,a){return kd(function(a){b.$evalAsync(a)},a)}]}function We(){this.$get=["$browser","$exceptionHandler",function(b,a){return kd(function(a){b.defer(a)},a)}]}function kd(b,a){function c(a,b,c){function d(b){return function(c){e||(e=!0,b.call(a,c))}}var e=!1;return[d(b),d(c)]}function d(){this.$$state={status:0}}function e(a,b){return function(c){b.call(a,c)}}function [...]
+c.pending&&(c.processScheduled=!0,b(function(){var b,d,e;e=c.pending;c.processScheduled=!1;c.pending=s;for(var f=0,g=e.length;f<g;++f){d=e[f][0];b=e[f][c.status];try{E(b)?d.resolve(b(c.value)):1===c.status?d.resolve(c.value):d.reject(c.value)}catch(h){d.reject(h),a(h)}}}))}function g(){this.promise=new d;this.resolve=e(this,this.resolve);this.reject=e(this,this.reject);this.notify=e(this,this.notify)}var h=F("$q",TypeError);d.prototype={then:function(a,b,c){var d=new g;this.$$state.pendi [...]
+[];this.$$state.pending.push([d,a,b,c]);0<this.$$state.status&&f(this.$$state);return d.promise},"catch":function(a){return this.then(null,a)},"finally":function(a,b){return this.then(function(b){return k(b,!0,a)},function(b){return k(b,!1,a)},b)}};g.prototype={resolve:function(a){this.promise.$$state.status||(a===this.promise?this.$$reject(h("qcycle",a)):this.$$resolve(a))},$$resolve:function(b){var d,e;e=c(this,this.$$resolve,this.$$reject);try{if(I(b)||E(b))d=b&&b.then;E(d)?(this.prom [...]
+-1,d.call(b,e[0],e[1],this.notify)):(this.promise.$$state.value=b,this.promise.$$state.status=1,f(this.promise.$$state))}catch(g){e[1](g),a(g)}},reject:function(a){this.promise.$$state.status||this.$$reject(a)},$$reject:function(a){this.promise.$$state.value=a;this.promise.$$state.status=2;f(this.promise.$$state)},notify:function(c){var d=this.promise.$$state.pending;0>=this.promise.$$state.status&&d&&d.length&&b(function(){for(var b,e,f=0,g=d.length;f<g;f++){e=d[f][0];b=d[f][3];try{e.no [...]
+b(c):c)}catch(h){a(h)}}})}};var l=function(a,b){var c=new g;b?c.resolve(a):c.reject(a);return c.promise},k=function(a,b,c){var d=null;try{E(c)&&(d=c())}catch(e){return l(e,!1)}return d&&E(d.then)?d.then(function(){return l(a,b)},function(a){return l(a,!1)}):l(a,b)},m=function(a,b,c,d){var e=new g;e.resolve(a);return e.promise.then(b,c,d)},r=function u(a){if(!E(a))throw h("norslvr",a);if(!(this instanceof u))return new u(a);var b=new g;a(function(a){b.resolve(a)},function(a){b.reject(a)}) [...]
+r.defer=function(){return new g};r.reject=function(a){var b=new g;b.reject(a);return b.promise};r.when=m;r.all=function(a){var b=new g,c=0,d=w(a)?[]:{};q(a,function(a,e){c++;m(a).then(function(a){d.hasOwnProperty(e)||(d[e]=a,--c||b.resolve(d))},function(a){d.hasOwnProperty(e)||b.reject(a)})});0===c&&b.resolve(d);return b.promise};return r}function ef(){this.$get=["$window","$timeout",function(b,a){function c(){for(var a=0;a<m.length;a++){var b=m[a];b&&(m[a]=null,b())}k=m.length=0}functio [...]
+m.length;k++;m.push(a);0===b&&(l=h(c));return function(){0<=b&&(b=m[b]=null,0===--k&&l&&(l(),l=null,m.length=0))}}var e=b.requestAnimationFrame||b.webkitRequestAnimationFrame,f=b.cancelAnimationFrame||b.webkitCancelAnimationFrame||b.webkitCancelRequestAnimationFrame,g=!!e,h=g?function(a){var b=e(a);return function(){f(b)}}:function(b){var c=a(b,16.66,!1);return function(){a.cancel(c)}};d.supported=g;var l,k=0,m=[];return d}]}function Ue(){function b(a){function b(){this.$$watchers=this.$ [...]
+this.$$childHead=this.$$childTail=null;this.$$listeners={};this.$$listenerCount={};this.$id=++rb;this.$$ChildScope=null}b.prototype=a;return b}var a=10,c=F("$rootScope"),d=null,e=null;this.digestTtl=function(b){arguments.length&&(a=b);return a};this.$get=["$injector","$exceptionHandler","$parse","$browser",function(f,g,h,l){function k(a){a.currentScope.$$destroyed=!0}function m(){this.$id=++rb;this.$$phase=this.$parent=this.$$watchers=this.$$nextSibling=this.$$prevSibling=this.$$childHea [...]
+null;this.$root=this;this.$$destroyed=!1;this.$$listeners={};this.$$listenerCount={};this.$$isolateBindings=null}function r(a){if(t.$$phase)throw c("inprog",t.$$phase);t.$$phase=a}function n(a,b,c){do a.$$listenerCount[c]-=b,0===a.$$listenerCount[c]&&delete a.$$listenerCount[c];while(a=a.$parent)}function u(){}function v(){for(;J.length;)try{J.shift()()}catch(a){g(a)}e=null}function s(){null===e&&(e=l.defer(function(){t.$apply(v)}))}m.prototype={constructor:m,$new:function(a,c){var d;c=c [...]
+(d=new m,d.$root=this.$root):(this.$$ChildScope||(this.$$ChildScope=b(this)),d=new this.$$ChildScope);d.$parent=c;d.$$prevSibling=c.$$childTail;c.$$childHead?(c.$$childTail.$$nextSibling=d,c.$$childTail=d):c.$$childHead=c.$$childTail=d;(a||c!=this)&&d.$on("$destroy",k);return d},$watch:function(a,b,c){var e=h(a);if(e.$$watchDelegate)return e.$$watchDelegate(this,b,c,e);var f=this.$$watchers,g={fn:b,last:u,get:e,exp:a,eq:!!c};d=null;E(b)||(g.fn=A);f||(f=this.$$watchers=[]);f.unshift(g);re [...]
+g);d=null}},$watchGroup:function(a,b){function c(){h=!1;l?(l=!1,b(e,e,g)):b(e,d,g)}var d=Array(a.length),e=Array(a.length),f=[],g=this,h=!1,l=!0;if(!a.length){var k=!0;g.$evalAsync(function(){k&&b(e,e,g)});return function(){k=!1}}if(1===a.length)return this.$watch(a[0],function(a,c,f){e[0]=a;d[0]=c;b(e,a===c?e:d,f)});q(a,function(a,b){var l=g.$watch(a,function(a,f){e[b]=a;d[b]=f;h||(h=!0,g.$evalAsync(c))});f.push(l)});return function(){for(;f.length;)f.shift()()}},$watchCollection:functi [...]
+a;var b,d,g,h;if(!C(e)){if(I(e))if(Sa(e))for(f!==n&&(f=n,u=f.length=0,k++),a=e.length,u!==a&&(k++,f.length=u=a),b=0;b<a;b++)h=f[b],g=e[b],d=h!==h&&g!==g,d||h===g||(k++,f[b]=g);else{f!==r&&(f=r={},u=0,k++);a=0;for(b in e)e.hasOwnProperty(b)&&(a++,g=e[b],h=f[b],b in f?(d=h!==h&&g!==g,d||h===g||(k++,f[b]=g)):(u++,f[b]=g,k++));if(u>a)for(b in k++,f)e.hasOwnProperty(b)||(u--,delete f[b])}else f!==e&&(f=e,k++);return k}}c.$stateful=!0;var d=this,e,f,g,l=1<b.length,k=0,m=h(a,c),n=[],r={},p=!0,u [...]
+function(){p?(p=!1,b(e,e,d)):b(e,g,d);if(l)if(I(e))if(Sa(e)){g=Array(e.length);for(var a=0;a<e.length;a++)g[a]=e[a]}else for(a in g={},e)wc.call(e,a)&&(g[a]=e[a]);else g=e})},$digest:function(){var b,f,h,k,m,n,q=a,s,S=[],P,J;r("$digest");l.$$checkUrlChange();this===t&&null!==e&&(l.defer.cancel(e),v());d=null;do{n=!1;for(s=this;p.length;){try{J=p.shift(),J.scope.$eval(J.expression,J.locals)}catch(y){g(y)}d=null}a:do{if(k=s.$$watchers)for(m=k.length;m--;)try{if(b=k[m])if((f=b.get(s))!==(h= [...]
+!(b.eq?fa(f,h):"number"===typeof f&&"number"===typeof h&&isNaN(f)&&isNaN(h)))n=!0,d=b,b.last=b.eq?Ba(f,null):f,b.fn(f,h===u?f:h,s),5>q&&(P=4-q,S[P]||(S[P]=[]),S[P].push({msg:E(b.exp)?"fn: "+(b.exp.name||b.exp.toString()):b.exp,newVal:f,oldVal:h}));else if(b===d){n=!1;break a}}catch(F){g(F)}if(!(k=s.$$childHead||s!==this&&s.$$nextSibling))for(;s!==this&&!(k=s.$$nextSibling);)s=s.$parent}while(s=k);if((n||p.length)&&!q--)throw t.$$phase=null,c("infdig",a,S);}while(n||p.length);for(t.$$phas [...]
+$destroy:function(){if(!this.$$destroyed){var a=this.$parent;this.$broadcast("$destroy");this.$$destroyed=!0;if(this!==t){for(var b in this.$$listenerCount)n(this,this.$$listenerCount[b],b);a.$$childHead==this&&(a.$$childHead=this.$$nextSibling);a.$$childTail==this&&(a.$$childTail=this.$$prevSibling);this.$$prevSibling&&(this.$$prevSibling.$$nextSibling=this.$$nextSibling);this.$$nextSibling&&(this.$$nextSibling.$$prevSibling=this.$$prevSibling);this.$destroy=this.$digest=this.$apply=thi [...]
+this.$applyAsync=A;this.$on=this.$watch=this.$watchGroup=function(){return A};this.$$listeners={};this.$parent=this.$$nextSibling=this.$$prevSibling=this.$$childHead=this.$$childTail=this.$root=this.$$watchers=null}}},$eval:function(a,b){return h(a)(this,b)},$evalAsync:function(a,b){t.$$phase||p.length||l.defer(function(){p.length&&t.$digest()});p.push({scope:this,expression:a,locals:b})},$$postDigest:function(a){H.push(a)},$apply:function(a){try{return r("$apply"),this.$eval(a)}catch(b) [...]
+null;try{t.$digest()}catch(c){throw g(c),c;}}},$applyAsync:function(a){function b(){c.$eval(a)}var c=this;a&&J.push(b);s()},$on:function(a,b){var c=this.$$listeners[a];c||(this.$$listeners[a]=c=[]);c.push(b);var d=this;do d.$$listenerCount[a]||(d.$$listenerCount[a]=0),d.$$listenerCount[a]++;while(d=d.$parent);var e=this;return function(){var d=c.indexOf(b);-1!==d&&(c[d]=null,n(e,1,a))}},$emit:function(a,b){var c=[],d,e=this,f=!1,h={name:a,targetScope:e,stopPropagation:function(){f=!0},pr [...]
+!0},defaultPrevented:!1},l=Ya([h],arguments,1),k,m;do{d=e.$$listeners[a]||c;h.currentScope=e;k=0;for(m=d.length;k<m;k++)if(d[k])try{d[k].apply(null,l)}catch(n){g(n)}else d.splice(k,1),k--,m--;if(f)return h.currentScope=null,h;e=e.$parent}while(e);h.currentScope=null;return h},$broadcast:function(a,b){var c=this,d=this,e={name:a,targetScope:this,preventDefault:function(){e.defaultPrevented=!0},defaultPrevented:!1};if(!this.$$listenerCount[a])return e;for(var f=Ya([e],arguments,1),h,l;c=d; [...]
+c;d=c.$$listeners[a]||[];h=0;for(l=d.length;h<l;h++)if(d[h])try{d[h].apply(null,f)}catch(k){g(k)}else d.splice(h,1),h--,l--;if(!(d=c.$$listenerCount[a]&&c.$$childHead||c!==this&&c.$$nextSibling))for(;c!==this&&!(d=c.$$nextSibling);)c=c.$parent}e.currentScope=null;return e}};var t=new m,p=t.$$asyncQueue=[],H=t.$$postDigestQueue=[],J=t.$$applyAsyncQueue=[];return t}]}function Xd(){var b=/^\s*(https?|ftp|mailto|tel|file):/,a=/^\s*((https?|ftp|file|blob):|data:image\/)/;this.aHrefSanitizatio [...]
+function(a){return y(a)?(b=a,this):b};this.imgSrcSanitizationWhitelist=function(b){return y(b)?(a=b,this):a};this.$get=function(){return function(c,d){var e=d?a:b,f;f=ya(c).href;return""===f||f.match(e)?c:"unsafe:"+f}}}function Hf(b){if("self"===b)return b;if(O(b)){if(-1<b.indexOf("***"))throw za("iwcard",b);b=ld(b).replace("\\*\\*",".*").replace("\\*","[^:/.?&;]*");return new RegExp("^"+b+"$")}if(Ua(b))return new RegExp("^"+b.source+"$");throw za("imatcher");}function md(b){var a=[];y(b [...]
+return a}function Ye(){this.SCE_CONTEXTS=la;var b=["self"],a=[];this.resourceUrlWhitelist=function(a){arguments.length&&(b=md(a));return b};this.resourceUrlBlacklist=function(b){arguments.length&&(a=md(b));return a};this.$get=["$injector",function(c){function d(a,b){return"self"===a?ed(b):!!a.exec(b.href)}function e(a){var b=function(a){this.$$unwrapTrustedValue=function(){return a}};a&&(b.prototype=new a);b.prototype.valueOf=function(){return this.$$unwrapTrustedValue()};b.prototype.toS [...]
+return b}var f=function(a){throw za("unsafe");};c.has("$sanitize")&&(f=c.get("$sanitize"));var g=e(),h={};h[la.HTML]=e(g);h[la.CSS]=e(g);h[la.URL]=e(g);h[la.JS]=e(g);h[la.RESOURCE_URL]=e(h[la.URL]);return{trustAs:function(a,b){var c=h.hasOwnProperty(a)?h[a]:null;if(!c)throw za("icontext",a,b);if(null===b||b===s||""===b)return b;if("string"!==typeof b)throw za("itype",a);return new c(b)},getTrusted:function(c,e){if(null===e||e===s||""===e)return e;var g=h.hasOwnProperty(c)?h[c]:null;if(g& [...]
+g)return e.$$unwrapTrustedValue();if(c===la.RESOURCE_URL){var g=ya(e.toString()),r,n,u=!1;r=0;for(n=b.length;r<n;r++)if(d(b[r],g)){u=!0;break}if(u)for(r=0,n=a.length;r<n;r++)if(d(a[r],g)){u=!1;break}if(u)return e;throw za("insecurl",e.toString());}if(c===la.HTML)return f(e);throw za("unsafe");},valueOf:function(a){return a instanceof g?a.$$unwrapTrustedValue():a}}}]}function Xe(){var b=!0;this.enabled=function(a){arguments.length&&(b=!!a);return b};this.$get=["$parse","$sceDelegate",func [...]
+8>Qa)throw za("iequirks");var d=oa(la);d.isEnabled=function(){return b};d.trustAs=c.trustAs;d.getTrusted=c.getTrusted;d.valueOf=c.valueOf;b||(d.trustAs=d.getTrusted=function(a,b){return b},d.valueOf=na);d.parseAs=function(b,c){var e=a(c);return e.literal&&e.constant?e:a(c,function(a){return d.getTrusted(b,a)})};var e=d.parseAs,f=d.getTrusted,g=d.trustAs;q(la,function(a,b){var c=L(b);d[eb("parse_as_"+c)]=function(b){return e(a,b)};d[eb("get_trusted_"+c)]=function(b){return f(a,b)};d[eb("t [...]
+c)]=function(b){return g(a,b)}});return d}]}function Ze(){this.$get=["$window","$document",function(b,a){var c={},d=aa((/android (\d+)/.exec(L((b.navigator||{}).userAgent))||[])[1]),e=/Boxee/i.test((b.navigator||{}).userAgent),f=a[0]||{},g,h=/^(Moz|webkit|ms)(?=[A-Z])/,l=f.body&&f.body.style,k=!1,m=!1;if(l){for(var r in l)if(k=h.exec(r)){g=k[0];g=g.substr(0,1).toUpperCase()+g.substr(1);break}g||(g="WebkitOpacity"in l&&"webkit");k=!!("transition"in l||g+"Transition"in l);m=!!("animation"i [...]
+l);!d||k&&m||(k=O(f.body.style.webkitTransition),m=O(f.body.style.webkitAnimation))}return{history:!(!b.history||!b.history.pushState||4>d||e),hasEvent:function(a){if("input"===a&&11>=Qa)return!1;if(C(c[a])){var b=f.createElement("div");c[a]="on"+a in b}return c[a]},csp:cb(),vendorPrefix:g,transitions:k,animations:m,android:d}}]}function af(){this.$get=["$templateCache","$http","$q",function(b,a,c){function d(e,f){d.totalPendingRequests++;var g=a.defaults&&a.defaults.transformResponse;w( [...]
+bc}):g===bc&&(g=null);return a.get(e,{cache:b,transformResponse:g})["finally"](function(){d.totalPendingRequests--}).then(function(a){return a.data},function(a){if(!f)throw da("tpload",e);return c.reject(a)})}d.totalPendingRequests=0;return d}]}function bf(){this.$get=["$rootScope","$browser","$location",function(b,a,c){return{findBindings:function(a,b,c){a=a.getElementsByClassName("ng-binding");var g=[];q(a,function(a){var d=ba.element(a).data("$binding");d&&q(d,function(d){c?(new RegEx [...]
+ld(b)+"(\\s|\\||$)")).test(d)&&g.push(a):-1!=d.indexOf(b)&&g.push(a)})});return g},findModels:function(a,b,c){for(var g=["ng-","data-ng-","ng\\:"],h=0;h<g.length;++h){var l=a.querySelectorAll("["+g[h]+"model"+(c?"=":"*=")+'"'+b+'"]');if(l.length)return l}},getLocation:function(){return c.url()},setLocation:function(a){a!==c.url()&&(c.url(a),b.$digest())},whenStable:function(b){a.notifyWhenNoOutstandingRequests(b)}}}]}function cf(){this.$get=["$rootScope","$browser","$q","$$q","$exception [...]
+function(b,a,c,d,e){function f(f,l,k){var m=y(k)&&!k,r=(m?d:c).defer(),n=r.promise;l=a.defer(function(){try{r.resolve(f())}catch(a){r.reject(a),e(a)}finally{delete g[n.$$timeoutId]}m||b.$apply()},l);n.$$timeoutId=l;g[l]=r;return n}var g={};f.cancel=function(b){return b&&b.$$timeoutId in g?(g[b.$$timeoutId].reject("canceled"),delete g[b.$$timeoutId],a.defer.cancel(b.$$timeoutId)):!1};return f}]}function ya(b){Qa&&(X.setAttribute("href",b),b=X.href);X.setAttribute("href",b);return{href:X.h [...]
+X.protocol.replace(/:$/,""):"",host:X.host,search:X.search?X.search.replace(/^\?/,""):"",hash:X.hash?X.hash.replace(/^#/,""):"",hostname:X.hostname,port:X.port,pathname:"/"===X.pathname.charAt(0)?X.pathname:"/"+X.pathname}}function ed(b){b=O(b)?ya(b):b;return b.protocol===nd.protocol&&b.host===nd.host}function df(){this.$get=ca(T)}function Ic(b){function a(c,d){if(I(c)){var e={};q(c,function(b,c){e[c]=a(c,b)});return e}return b.factory(c+"Filter",d)}this.register=a;this.$get=["$injector" [...]
+"Filter")}}];a("currency",od);a("date",pd);a("filter",If);a("json",Jf);a("limitTo",Kf);a("lowercase",Lf);a("number",qd);a("orderBy",rd);a("uppercase",Mf)}function If(){return function(b,a,c){if(!w(b))return b;var d;switch(null!==a?typeof a:"null"){case "function":break;case "boolean":case "null":case "number":case "string":d=!0;case "object":a=Nf(a,c,d);break;default:return b}return b.filter(a)}}function Nf(b,a,c){var d=I(b)&&"$"in b;!0===a?a=fa:E(a)||(a=function(a,b){if(C(a))return!1;if [...]
+null===b)return a===b;if(I(a)||I(b))return!1;a=L(""+a);b=L(""+b);return-1!==a.indexOf(b)});return function(e){return d&&!I(e)?Ha(e,b.$,a,!1):Ha(e,b,a,c)}}function Ha(b,a,c,d,e){var f=null!==b?typeof b:"null",g=null!==a?typeof a:"null";if("string"===g&&"!"===a.charAt(0))return!Ha(b,a.substring(1),c,d);if(w(b))return b.some(function(b){return Ha(b,a,c,d)});switch(f){case "object":var h;if(d){for(h in b)if("$"!==h.charAt(0)&&Ha(b[h],a,c,!0))return!0;return e?!1:Ha(b,a,c,!1)}if("object"===g) [...]
+a[h],!E(e)&&!C(e)&&(f="$"===h,!Ha(f?b:b[h],e,c,f,f)))return!1;return!0}return c(b,a);case "function":return!1;default:return c(b,a)}}function od(b){var a=b.NUMBER_FORMATS;return function(b,d,e){C(d)&&(d=a.CURRENCY_SYM);C(e)&&(e=a.PATTERNS[1].maxFrac);return null==b?b:sd(b,a.PATTERNS[1],a.GROUP_SEP,a.DECIMAL_SEP,e).replace(/\u00A4/g,d)}}function qd(b){var a=b.NUMBER_FORMATS;return function(b,d){return null==b?b:sd(b,a.PATTERNS[0],a.GROUP_SEP,a.DECIMAL_SEP,d)}}function sd(b,a,c,d,e){if(!is [...]
+I(b))return"";var f=0>b;b=Math.abs(b);var g=b+"",h="",l=[],k=!1;if(-1!==g.indexOf("e")){var m=g.match(/([\d\.]+)e(-?)(\d+)/);m&&"-"==m[2]&&m[3]>e+1?b=0:(h=g,k=!0)}if(k)0<e&&1>b&&(h=b.toFixed(e),b=parseFloat(h));else{g=(g.split(td)[1]||"").length;C(e)&&(e=Math.min(Math.max(a.minFrac,g),a.maxFrac));b=+(Math.round(+(b.toString()+"e"+e)).toString()+"e"+-e);var g=(""+b).split(td),k=g[0],g=g[1]||"",r=0,n=a.lgSize,u=a.gSize;if(k.length>=n+u)for(r=k.length-n,m=0;m<r;m++)0===(r-m)%u&&0!==m&&(h+=c [...]
+for(m=r;m<k.length;m++)0===(k.length-m)%n&&0!==m&&(h+=c),h+=k.charAt(m);for(;g.length<e;)g+="0";e&&"0"!==e&&(h+=d+g.substr(0,e))}0===b&&(f=!1);l.push(f?a.negPre:a.posPre,h,f?a.negSuf:a.posSuf);return l.join("")}function Jb(b,a,c){var d="";0>b&&(d="-",b=-b);for(b=""+b;b.length<a;)b="0"+b;c&&(b=b.substr(b.length-a));return d+b}function Z(b,a,c,d){c=c||0;return function(e){e=e["get"+b]();if(0<c||e>-c)e+=c;0===e&&-12==c&&(e=12);return Jb(e,a,d)}}function Kb(b,a){return function(c,d){var e=c[ [...]
+f=vb(a?"SHORT"+b:b);return d[f][e]}}function ud(b){var a=(new Date(b,0,1)).getDay();return new Date(b,0,(4>=a?5:12)-a)}function vd(b){return function(a){var c=ud(a.getFullYear());a=+new Date(a.getFullYear(),a.getMonth(),a.getDate()+(4-a.getDay()))-+c;a=1+Math.round(a/6048E5);return Jb(a,b)}}function lc(b,a){return 0>=b.getFullYear()?a.ERAS[0]:a.ERAS[1]}function pd(b){function a(a){var b;if(b=a.match(c)){a=new Date(0);var f=0,g=0,h=b[8]?a.setUTCFullYear:a.setFullYear,l=b[8]?a.setUTCHours: [...]
+b[9]&&(f=aa(b[9]+b[10]),g=aa(b[9]+b[11]));h.call(a,aa(b[1]),aa(b[2])-1,aa(b[3]));f=aa(b[4]||0)-f;g=aa(b[5]||0)-g;h=aa(b[6]||0);b=Math.round(1E3*parseFloat("0."+(b[7]||0)));l.call(a,f,g,h,b)}return a}var c=/^(\d{4})-?(\d\d)-?(\d\d)(?:T(\d\d)(?::?(\d\d)(?::?(\d\d)(?:\.(\d+))?)?)?(Z|([+-])(\d\d):?(\d\d))?)?$/;return function(c,e,f){var g="",h=[],l,k;e=e||"mediumDate";e=b.DATETIME_FORMATS[e]||e;O(c)&&(c=Of.test(c)?aa(c):a(c));Q(c)&&(c=new Date(c));if(!ea(c))return c;for(;e;)(k=Pf.exec(e))?(h [...]
+e=h.pop()):(h.push(e),e=null);f&&"UTC"===f&&(c=new Date(c.getTime()),c.setMinutes(c.getMinutes()+c.getTimezoneOffset()));q(h,function(a){l=Qf[a];g+=l?l(c,b.DATETIME_FORMATS):a.replace(/(^'|'$)/g,"").replace(/''/g,"'")});return g}}function Jf(){return function(b,a){C(a)&&(a=2);return $a(b,a)}}function Kf(){return function(b,a){Q(b)&&(b=b.toString());return w(b)||O(b)?(a=Infinity===Math.abs(Number(a))?Number(a):aa(a))?0<a?b.slice(0,a):b.slice(a):O(b)?"":[]:b}}function rd(b){return function [...]
+b){return b?function(b,c){return a(c,b)}:a}function f(a){switch(typeof a){case "number":case "boolean":case "string":return!0;default:return!1}}function g(a){return null===a?"null":"function"===typeof a.valueOf&&(a=a.valueOf(),f(a))||"function"===typeof a.toString&&(a=a.toString(),f(a))?a:""}function h(a,b){var c=typeof a,d=typeof b;c===d&&"object"===c&&(a=g(a),b=g(b));return c===d?("string"===c&&(a=a.toLowerCase(),b=b.toLowerCase()),a===b?0:a<b?-1:1):c<d?-1:1}if(!Sa(a))return a;c=w(c)?c [...]
+c.length&&(c=["+"]);c=c.map(function(a){var c=!1,d=a||na;if(O(a)){if("+"==a.charAt(0)||"-"==a.charAt(0))c="-"==a.charAt(0),a=a.substring(1);if(""===a)return e(h,c);d=b(a);if(d.constant){var f=d();return e(function(a,b){return h(a[f],b[f])},c)}}return e(function(a,b){return h(d(a),d(b))},c)});return Za.call(a).sort(e(function(a,b){for(var d=0;d<c.length;d++){var e=c[d](a,b);if(0!==e)return e}return 0},d))}}function Ia(b){E(b)&&(b={link:b});b.restrict=b.restrict||"AC";return ca(b)}function [...]
+d,e){var f=this,g=[],h=f.$$parentForm=b.parent().controller("form")||Lb;f.$error={};f.$$success={};f.$pending=s;f.$name=e(a.name||a.ngForm||"")(c);f.$dirty=!1;f.$pristine=!0;f.$valid=!0;f.$invalid=!1;f.$submitted=!1;h.$addControl(f);f.$rollbackViewValue=function(){q(g,function(a){a.$rollbackViewValue()})};f.$commitViewValue=function(){q(g,function(a){a.$commitViewValue()})};f.$addControl=function(a){Ma(a.$name,"input");g.push(a);a.$name&&(f[a.$name]=a)};f.$$renameControl=function(a,b){va [...]
+f[c]===a&&delete f[c];f[b]=a;a.$name=b};f.$removeControl=function(a){a.$name&&f[a.$name]===a&&delete f[a.$name];q(f.$pending,function(b,c){f.$setValidity(c,null,a)});q(f.$error,function(b,c){f.$setValidity(c,null,a)});q(f.$$success,function(b,c){f.$setValidity(c,null,a)});Xa(g,a)};xd({ctrl:this,$element:b,set:function(a,b,c){var d=a[b];d?-1===d.indexOf(c)&&d.push(c):a[b]=[c]},unset:function(a,b,c){var d=a[b];d&&(Xa(d,c),0===d.length&&delete a[b])},parentForm:h,$animate:d});f.$setDirty=fu [...]
+Ra);d.addClass(b,Mb);f.$dirty=!0;f.$pristine=!1;h.$setDirty()};f.$setPristine=function(){d.setClass(b,Ra,Mb+" ng-submitted");f.$dirty=!1;f.$pristine=!0;f.$submitted=!1;q(g,function(a){a.$setPristine()})};f.$setUntouched=function(){q(g,function(a){a.$setUntouched()})};f.$setSubmitted=function(){d.addClass(b,"ng-submitted");f.$submitted=!0;h.$setSubmitted()}}function mc(b){b.$formatters.push(function(a){return b.$isEmpty(a)?a:a.toString()})}function mb(b,a,c,d,e,f){var g=L(a[0].type);if(!e [...]
+!1;a.on("compositionstart",function(a){h=!0});a.on("compositionend",function(){h=!1;l()})}var l=function(b){k&&(f.defer.cancel(k),k=null);if(!h){var e=a.val();b=b&&b.type;"password"===g||c.ngTrim&&"false"===c.ngTrim||(e=U(e));(d.$viewValue!==e||""===e&&d.$$hasNativeValidators)&&d.$setViewValue(e,b)}};if(e.hasEvent("input"))a.on("input",l);else{var k,m=function(a,b,c){k||(k=f.defer(function(){k=null;b&&b.value===c||l(a)}))};a.on("keydown",function(a){var b=a.keyCode;91===b||15<b&&19>b||37 [...]
+b||m(a,this,this.value)});if(e.hasEvent("paste"))a.on("paste cut",m)}a.on("change",l);d.$render=function(){a.val(d.$isEmpty(d.$viewValue)?"":d.$viewValue)}}function Nb(b,a){return function(c,d){var e,f;if(ea(c))return c;if(O(c)){'"'==c.charAt(0)&&'"'==c.charAt(c.length-1)&&(c=c.substring(1,c.length-1));if(Rf.test(c))return new Date(c);b.lastIndex=0;if(e=b.exec(c))return e.shift(),f=d?{yyyy:d.getFullYear(),MM:d.getMonth()+1,dd:d.getDate(),HH:d.getHours(),mm:d.getMinutes(),ss:d.getSeconds( [...]
+1E3}:{yyyy:1970,MM:1,dd:1,HH:0,mm:0,ss:0,sss:0},q(e,function(b,c){c<a.length&&(f[a[c]]=+b)}),new Date(f.yyyy,f.MM-1,f.dd,f.HH,f.mm,f.ss||0,1E3*f.sss||0)}return NaN}}function nb(b,a,c,d){return function(e,f,g,h,l,k,m){function r(a){return a&&!(a.getTime&&a.getTime()!==a.getTime())}function n(a){return y(a)?ea(a)?a:c(a):s}yd(e,f,g,h);mb(e,f,g,h,l,k);var u=h&&h.$options&&h.$options.timezone,v;h.$$parserName=b;h.$parsers.push(function(b){return h.$isEmpty(b)?null:a.test(b)?(b=c(b,v),"UTC"=== [...]
+b.getTimezoneOffset()),b):s});h.$formatters.push(function(a){if(a&&!ea(a))throw Ob("datefmt",a);if(r(a)){if((v=a)&&"UTC"===u){var b=6E4*v.getTimezoneOffset();v=new Date(v.getTime()+b)}return m("date")(a,d,u)}v=null;return""});if(y(g.min)||g.ngMin){var q;h.$validators.min=function(a){return!r(a)||C(q)||c(a)>=q};g.$observe("min",function(a){q=n(a);h.$validate()})}if(y(g.max)||g.ngMax){var t;h.$validators.max=function(a){return!r(a)||C(t)||c(a)<=t};g.$observe("max",function(a){t=n(a);h.$val [...]
+function yd(b,a,c,d){(d.$$hasNativeValidators=I(a[0].validity))&&d.$parsers.push(function(b){var c=a.prop("validity")||{};return c.badInput&&!c.typeMismatch?s:b})}function zd(b,a,c,d,e){if(y(d)){b=b(d);if(!b.constant)throw F("ngModel")("constexpr",c,d);return b(a)}return e}function nc(b,a){b="ngClass"+b;return["$animate",function(c){function d(a,b){var c=[],d=0;a:for(;d<a.length;d++){for(var e=a[d],m=0;m<b.length;m++)if(e==b[m])continue a;c.push(e)}return c}function e(a){if(!w(a)){if(O(a [...]
+if(I(a)){var b=[];q(a,function(a,c){a&&(b=b.concat(c.split(" ")))});return b}}return a}return{restrict:"AC",link:function(f,g,h){function l(a,b){var c=g.data("$classCounts")||{},d=[];q(a,function(a){if(0<b||c[a])c[a]=(c[a]||0)+b,c[a]===+(0<b)&&d.push(a)});g.data("$classCounts",c);return d.join(" ")}function k(b){if(!0===a||f.$index%2===a){var k=e(b||[]);if(!m){var u=l(k,1);h.$addClass(u)}else if(!fa(b,m)){var q=e(m),u=d(k,q),k=d(q,k),u=l(u,1),k=l(k,-1);u&&u.length&&c.addClass(g,u);k&&k.l [...]
+k)}}m=oa(b)}var m;f.$watch(h[b],k,!0);h.$observe("class",function(a){k(f.$eval(h[b]))});"ngClass"!==b&&f.$watch("$index",function(c,d){var g=c&1;if(g!==(d&1)){var k=e(f.$eval(h[b]));g===a?(g=l(k,1),h.$addClass(g)):(g=l(k,-1),h.$removeClass(g))}})}}}]}function xd(b){function a(a,b){b&&!f[a]?(k.addClass(e,a),f[a]=!0):!b&&f[a]&&(k.removeClass(e,a),f[a]=!1)}function c(b,c){b=b?"-"+yc(b,"-"):"";a(ob+b,!0===c);a(Ad+b,!1===c)}var d=b.ctrl,e=b.$element,f={},g=b.set,h=b.unset,l=b.parentForm,k=b.$ [...]
+!(f[ob]=e.hasClass(ob));d.$setValidity=function(b,e,f){e===s?(d.$pending||(d.$pending={}),g(d.$pending,b,f)):(d.$pending&&h(d.$pending,b,f),Bd(d.$pending)&&(d.$pending=s));Wa(e)?e?(h(d.$error,b,f),g(d.$$success,b,f)):(g(d.$error,b,f),h(d.$$success,b,f)):(h(d.$error,b,f),h(d.$$success,b,f));d.$pending?(a(Cd,!0),d.$valid=d.$invalid=s,c("",null)):(a(Cd,!1),d.$valid=Bd(d.$error),d.$invalid=!d.$valid,c("",d.$valid));e=d.$pending&&d.$pending[b]?s:d.$error[b]?!1:d.$$success[b]?!0:null;c(b,e);l. [...]
+e,d)}}function Bd(b){if(b)for(var a in b)return!1;return!0}var Sf=/^\/(.+)\/([a-z]*)$/,L=function(b){return O(b)?b.toLowerCase():b},wc=Object.prototype.hasOwnProperty,vb=function(b){return O(b)?b.toUpperCase():b},Qa,z,pa,Za=[].slice,uf=[].splice,Tf=[].push,Aa=Object.prototype.toString,Ja=F("ng"),ba=T.angular||(T.angular={}),db,rb=0;Qa=V.documentMode;A.$inject=[];na.$inject=[];var w=Array.isArray,U=function(b){return O(b)?b.trim():b},ld=function(b){return b.replace(/([-()\[\]{}+?*.$\^|,:# [...]
+"\\$1").replace(/\x08/g,"\\x08")},cb=function(){if(y(cb.isActive_))return cb.isActive_;var b=!(!V.querySelector("[ng-csp]")&&!V.querySelector("[data-ng-csp]"));if(!b)try{new Function("")}catch(a){b=!0}return cb.isActive_=b},tb=["ng-","data-ng-","ng:","x-ng-"],Rd=/[A-Z]/g,zc=!1,Rb,ma=1,ab=3,Vd={full:"1.3.16",major:1,minor:3,dot:16,codeName:"cookie-oatmealification"};R.expando="ng339";var Ab=R.cache={},nf=1;R._data=function(b){return this.cache[b[this.expando]]||{}};var hf=/([\:\-\_]+(.))/ [...]
+Uf={mouseleave:"mouseout",mouseenter:"mouseover"},Ub=F("jqLite"),mf=/^<(\w+)\s*\/?>(?:<\/\1>|)$/,Tb=/<|&#?\w+;/,kf=/<([\w:]+)/,lf=/<(?!area|br|col|embed|hr|img|input|link|meta|param)(([\w:]+)[^>]*)\/>/gi,ha={option:[1,'<select multiple="multiple">',"</select>"],thead:[1,"<table>","</table>"],col:[2,"<table><colgroup>","</colgroup></table>"],tr:[2,"<table><tbody>","</tbody></table>"],td:[3,"<table><tbody><tr>","</tr></tbody></table>"],_default:[0,"",""]};ha.optgroup=ha.option;ha.tbody=ha. [...]
+ha.caption=ha.thead;ha.th=ha.td;var Ka=R.prototype={ready:function(b){function a(){c||(c=!0,b())}var c=!1;"complete"===V.readyState?setTimeout(a):(this.on("DOMContentLoaded",a),R(T).on("load",a))},toString:function(){var b=[];q(this,function(a){b.push(""+a)});return"["+b.join(", ")+"]"},eq:function(b){return 0<=b?z(this[b]):z(this[this.length+b])},length:0,push:Tf,sort:[].sort,splice:[].splice},Fb={};q("multiple selected checked disabled readOnly required open".split(" "),function(b){Fb[ [...]
+var Rc={};q("input select option textarea button form details".split(" "),function(b){Rc[b]=!0});var Sc={ngMinlength:"minlength",ngMaxlength:"maxlength",ngMin:"min",ngMax:"max",ngPattern:"pattern"};q({data:Wb,removeData:yb},function(b,a){R[a]=b});q({data:Wb,inheritedData:Eb,scope:function(b){return z.data(b,"$scope")||Eb(b.parentNode||b,["$isolateScope","$scope"])},isolateScope:function(b){return z.data(b,"$isolateScope")||z.data(b,"$isolateScopeNoTemplate")},controller:Nc,injector:funct [...]
+"$injector")},removeAttr:function(b,a){b.removeAttribute(a)},hasClass:Bb,css:function(b,a,c){a=eb(a);if(y(c))b.style[a]=c;else return b.style[a]},attr:function(b,a,c){var d=b.nodeType;if(d!==ab&&2!==d&&8!==d)if(d=L(a),Fb[d])if(y(c))c?(b[a]=!0,b.setAttribute(a,d)):(b[a]=!1,b.removeAttribute(d));else return b[a]||(b.attributes.getNamedItem(a)||A).specified?d:s;else if(y(c))b.setAttribute(a,c);else if(b.getAttribute)return b=b.getAttribute(a,2),null===b?s:b},prop:function(b,a,c){if(y(c))b[a [...]
+text:function(){function b(a,b){if(C(b)){var d=a.nodeType;return d===ma||d===ab?a.textContent:""}a.textContent=b}b.$dv="";return b}(),val:function(b,a){if(C(a)){if(b.multiple&&"select"===sa(b)){var c=[];q(b.options,function(a){a.selected&&c.push(a.value||a.text)});return 0===c.length?null:c}return b.value}b.value=a},html:function(b,a){if(C(a))return b.innerHTML;xb(b,!0);b.innerHTML=a},empty:Oc},function(b,a){R.prototype[a]=function(a,d){var e,f,g=this.length;if(b!==Oc&&(2==b.length&&b!== [...]
+a:d)===s){if(I(a)){for(e=0;e<g;e++)if(b===Wb)b(this[e],a);else for(f in a)b(this[e],f,a[f]);return this}e=b.$dv;g=e===s?Math.min(g,1):g;for(f=0;f<g;f++){var h=b(this[f],a,d);e=e?e+h:h}return e}for(e=0;e<g;e++)b(this[e],a,d);return this}});q({removeData:yb,on:function a(c,d,e,f){if(y(f))throw Ub("onargs");if(Jc(c)){var g=zb(c,!0);f=g.events;var h=g.handle;h||(h=g.handle=qf(c,f));for(var g=0<=d.indexOf(" ")?d.split(" "):[d],l=g.length;l--;){d=g[l];var k=f[d];k||(f[d]=[],"mouseenter"===d||" [...]
+d?a(c,Uf[d],function(a){var c=a.relatedTarget;c&&(c===this||this.contains(c))||h(a,d)}):"$destroy"!==d&&c.addEventListener(d,h,!1),k=f[d]);k.push(e)}}},off:Mc,one:function(a,c,d){a=z(a);a.on(c,function f(){a.off(c,d);a.off(c,f)});a.on(c,d)},replaceWith:function(a,c){var d,e=a.parentNode;xb(a);q(new R(c),function(c){d?e.insertBefore(c,d.nextSibling):e.replaceChild(c,a);d=c})},children:function(a){var c=[];q(a.childNodes,function(a){a.nodeType===ma&&c.push(a)});return c},contents:function( [...]
+a.childNodes||[]},append:function(a,c){var d=a.nodeType;if(d===ma||11===d){c=new R(c);for(var d=0,e=c.length;d<e;d++)a.appendChild(c[d])}},prepend:function(a,c){if(a.nodeType===ma){var d=a.firstChild;q(new R(c),function(c){a.insertBefore(c,d)})}},wrap:function(a,c){c=z(c).eq(0).clone()[0];var d=a.parentNode;d&&d.replaceChild(c,a);c.appendChild(a)},remove:Pc,detach:function(a){Pc(a,!0)},after:function(a,c){var d=a,e=a.parentNode;c=new R(c);for(var f=0,g=c.length;f<g;f++){var h=c[f];e.inse [...]
+d.nextSibling);d=h}},addClass:Db,removeClass:Cb,toggleClass:function(a,c,d){c&&q(c.split(" "),function(c){var f=d;C(f)&&(f=!Bb(a,c));(f?Db:Cb)(a,c)})},parent:function(a){return(a=a.parentNode)&&11!==a.nodeType?a:null},next:function(a){return a.nextElementSibling},find:function(a,c){return a.getElementsByTagName?a.getElementsByTagName(c):[]},clone:Vb,triggerHandler:function(a,c,d){var e,f,g=c.type||c,h=zb(a);if(h=(h=h&&h.events)&&h[g])e={preventDefault:function(){this.defaultPrevented=!0} [...]
+this.defaultPrevented},stopImmediatePropagation:function(){this.immediatePropagationStopped=!0},isImmediatePropagationStopped:function(){return!0===this.immediatePropagationStopped},stopPropagation:A,type:g,target:a},c.type&&(e=x(e,c)),c=oa(h),f=d?[e].concat(d):[e],q(c,function(c){e.isImmediatePropagationStopped()||c.apply(a,f)})}},function(a,c){R.prototype[c]=function(c,e,f){for(var g,h=0,l=this.length;h<l;h++)C(g)?(g=a(this[h],c,e,f),y(g)&&(g=z(g))):Lc(g,a(this[h],c,e,f));return y(g)?g [...]
+R.prototype.on;R.prototype.unbind=R.prototype.off});fb.prototype={put:function(a,c){this[Na(a,this.nextUid)]=c},get:function(a){return this[Na(a,this.nextUid)]},remove:function(a){var c=this[a=Na(a,this.nextUid)];delete this[a];return c}};var Uc=/^function\s*[^\(]*\(\s*([^\)]*)\)/m,Vf=/,/,Wf=/^\s*(_?)(\S+?)\1\s*$/,Tc=/((\/\/.*$)|(\/\*[\s\S]*?\*\/))/mg,Da=F("$injector");bb.$$annotate=function(a,c,d){var e;if("function"===typeof a){if(!(e=a.$inject)){e=[];if(a.length){if(c)throw O(d)&&d||( [...]
+rf(a)),Da("strictdi",d);c=a.toString().replace(Tc,"");c=c.match(Uc);q(c[1].split(Vf),function(a){a.replace(Wf,function(a,c,d){e.push(d)})})}a.$inject=e}}else w(a)?(c=a.length-1,La(a[c],"fn"),e=a.slice(0,c)):La(a,"fn",!0);return e};var Xf=F("$animate"),He=["$provide",function(a){this.$$selectors={};this.register=function(c,d){var e=c+"-animation";if(c&&"."!=c.charAt(0))throw Xf("notcsel",c);this.$$selectors[c.substr(1)]=e;a.factory(e,d)};this.classNameFilter=function(a){1===arguments.leng [...]
+a instanceof RegExp?a:null);return this.$$classNameFilter};this.$get=["$$q","$$asyncCallback","$rootScope",function(a,d,e){function f(d){var f,g=a.defer();g.promise.$$cancelFn=function(){f&&f()};e.$$postDigest(function(){f=d(function(){g.resolve()})});return g.promise}function g(a,c){var d=[],e=[],f=ga();q((a.attr("class")||"").split(/\s+/),function(a){f[a]=!0});q(c,function(a,c){var g=f[c];!1===a&&g?e.push(c):!0!==a||g||d.push(c)});return 0<d.length+e.length&&[d.length?d:null,e.length?e [...]
+c,d){for(var e=0,f=c.length;e<f;++e)a[c[e]]=d}function l(){m||(m=a.defer(),d(function(){m.resolve();m=null}));return m.promise}function k(a,c){if(ba.isObject(c)){var d=x(c.from||{},c.to||{});a.css(d)}}var m;return{animate:function(a,c,d){k(a,{from:c,to:d});return l()},enter:function(a,c,d,e){k(a,e);d?d.after(a):c.prepend(a);return l()},leave:function(a,c){k(a,c);a.remove();return l()},move:function(a,c,d,e){return this.enter(a,c,d,e)},addClass:function(a,c,d){return this.setClass(a,c,[], [...]
+c,d){a=z(a);c=O(c)?c:w(c)?c.join(" "):"";q(a,function(a){Db(a,c)});k(a,d);return l()},removeClass:function(a,c,d){return this.setClass(a,[],c,d)},$$removeClassImmediately:function(a,c,d){a=z(a);c=O(c)?c:w(c)?c.join(" "):"";q(a,function(a){Cb(a,c)});k(a,d);return l()},setClass:function(a,c,d,e){var k=this,l=!1;a=z(a);var m=a.data("$$animateClasses");m?e&&m.options&&(m.options=ba.extend(m.options||{},e)):(m={classes:{},options:e},l=!0);e=m.classes;c=w(c)?c:c.split(" ");d=w(d)?d:d.split(" " [...]
+h(e,d,!1);l&&(m.promise=f(function(c){var d=a.data("$$animateClasses");a.removeData("$$animateClasses");if(d){var e=g(a,d.classes);e&&k.$$setClassImmediately(a,e[0],e[1],d.options)}c()}),a.data("$$animateClasses",m));return m.promise},$$setClassImmediately:function(a,c,d,e){c&&this.$$addClassImmediately(a,c);d&&this.$$removeClassImmediately(a,d);k(a,e);return l()},enabled:A,cancel:A}}]}],da=F("$compile");Bc.$inject=["$provide","$$sanitizeUriProvider"];var Wc=/^((?:x|data)[\:\-_])/i,vf=F( [...]
+ad="application/json",cc={"Content-Type":ad+";charset=utf-8"},xf=/^\[|^\{(?!\{)/,yf={"[":/]$/,"{":/}$/},wf=/^\)\]\}',?\n/,dc=F("$interpolate"),Yf=/^([^\?#]*)(\?([^#]*))?(#(.*))?$/,Bf={http:80,https:443,ftp:21},Hb=F("$location"),Zf={$$html5:!1,$$replace:!1,absUrl:Ib("$$absUrl"),url:function(a){if(C(a))return this.$$url;var c=Yf.exec(a);(c[1]||""===a)&&this.path(decodeURIComponent(c[1]));(c[2]||c[1]||""===a)&&this.search(c[3]||"");this.hash(c[5]||"");return this},protocol:Ib("$$protocol"), [...]
+port:Ib("$$port"),path:id("$$path",function(a){a=null!==a?a.toString():"";return"/"==a.charAt(0)?a:"/"+a}),search:function(a,c){switch(arguments.length){case 0:return this.$$search;case 1:if(O(a)||Q(a))a=a.toString(),this.$$search=vc(a);else if(I(a))a=Ba(a,{}),q(a,function(c,e){null==c&&delete a[e]}),this.$$search=a;else throw Hb("isrcharg");break;default:C(c)||null===c?delete this.$$search[a]:this.$$search[a]=c}this.$$compose();return this},hash:id("$$hash",function(a){return null!==a?a [...]
+""}),replace:function(){this.$$replace=!0;return this}};q([hd,hc,gc],function(a){a.prototype=Object.create(Zf);a.prototype.state=function(c){if(!arguments.length)return this.$$state;if(a!==gc||!this.$$html5)throw Hb("nostate");this.$$state=C(c)?null:c;return this}});var ja=F("$parse"),$f=Function.prototype.call,ag=Function.prototype.apply,bg=Function.prototype.bind,pb=ga();q({"null":function(){return null},"true":function(){return!0},"false":function(){return!1},undefined:function(){}},f [...]
+c){a.constant=a.literal=a.sharedGetter=!0;pb[c]=a});pb["this"]=function(a){return a};pb["this"].sharedGetter=!0;var qb=x(ga(),{"+":function(a,c,d,e){d=d(a,c);e=e(a,c);return y(d)?y(e)?d+e:d:y(e)?e:s},"-":function(a,c,d,e){d=d(a,c);e=e(a,c);return(y(d)?d:0)-(y(e)?e:0)},"*":function(a,c,d,e){return d(a,c)*e(a,c)},"/":function(a,c,d,e){return d(a,c)/e(a,c)},"%":function(a,c,d,e){return d(a,c)%e(a,c)},"===":function(a,c,d,e){return d(a,c)===e(a,c)},"!==":function(a,c,d,e){return d(a,c)!==e(a [...]
+c,d,e){return d(a,c)==e(a,c)},"!=":function(a,c,d,e){return d(a,c)!=e(a,c)},"<":function(a,c,d,e){return d(a,c)<e(a,c)},">":function(a,c,d,e){return d(a,c)>e(a,c)},"<=":function(a,c,d,e){return d(a,c)<=e(a,c)},">=":function(a,c,d,e){return d(a,c)>=e(a,c)},"&&":function(a,c,d,e){return d(a,c)&&e(a,c)},"||":function(a,c,d,e){return d(a,c)||e(a,c)},"!":function(a,c,d){return!d(a,c)},"=":!0,"|":!0}),cg={n:"\n",f:"\f",r:"\r",t:"\t",v:"\v","'":"'",'"':'"'},kc=function(a){this.options=a};kc.pro [...]
+lex:function(a){this.text=a;this.index=0;for(this.tokens=[];this.index<this.text.length;)if(a=this.text.charAt(this.index),'"'===a||"'"===a)this.readString(a);else if(this.isNumber(a)||"."===a&&this.isNumber(this.peek()))this.readNumber();else if(this.isIdent(a))this.readIdent();else if(this.is(a,"(){}[].,;:?"))this.tokens.push({index:this.index,text:a}),this.index++;else if(this.isWhitespace(a))this.index++;else{var c=a+this.peek(),d=c+this.peek(2),e=qb[c],f=qb[d];qb[a]||e||f?(a=f?d:e?c [...]
+text:a,operator:!0}),this.index+=a.length):this.throwError("Unexpected next character ",this.index,this.index+1)}return this.tokens},is:function(a,c){return-1!==c.indexOf(a)},peek:function(a){a=a||1;return this.index+a<this.text.length?this.text.charAt(this.index+a):!1},isNumber:function(a){return"0"<=a&&"9">=a&&"string"===typeof a},isWhitespace:function(a){return" "===a||"\r"===a||"\t"===a||"\n"===a||"\v"===a||"\u00a0"===a},isIdent:function(a){return"a"<=a&&"z">=a||"A"<=a&&"Z">=a||"_"== [...]
+a},isExpOperator:function(a){return"-"===a||"+"===a||this.isNumber(a)},throwError:function(a,c,d){d=d||this.index;c=y(c)?"s "+c+"-"+this.index+" ["+this.text.substring(c,d)+"]":" "+d;throw ja("lexerr",a,c,this.text);},readNumber:function(){for(var a="",c=this.index;this.index<this.text.length;){var d=L(this.text.charAt(this.index));if("."==d||this.isNumber(d))a+=d;else{var e=this.peek();if("e"==d&&this.isExpOperator(e))a+=d;else if(this.isExpOperator(d)&&e&&this.isNumber(e)&&"e"==a.charA [...]
+1))a+=d;else if(!this.isExpOperator(d)||e&&this.isNumber(e)||"e"!=a.charAt(a.length-1))break;else this.throwError("Invalid exponent")}this.index++}this.tokens.push({index:c,text:a,constant:!0,value:Number(a)})},readIdent:function(){for(var a=this.index;this.index<this.text.length;){var c=this.text.charAt(this.index);if(!this.isIdent(c)&&!this.isNumber(c))break;this.index++}this.tokens.push({index:a,text:this.text.slice(a,this.index),identifier:!0})},readString:function(a){var c=this.inde [...]
+for(var d="",e=a,f=!1;this.index<this.text.length;){var g=this.text.charAt(this.index),e=e+g;if(f)"u"===g?(f=this.text.substring(this.index+1,this.index+5),f.match(/[\da-f]{4}/i)||this.throwError("Invalid unicode escape [\\u"+f+"]"),this.index+=4,d+=String.fromCharCode(parseInt(f,16))):d+=cg[g]||g,f=!1;else if("\\"===g)f=!0;else{if(g===a){this.index++;this.tokens.push({index:c,text:e,constant:!0,value:d});return}d+=g}this.index++}this.throwError("Unterminated quote",c)}};var lb=function( [...]
+a;this.$filter=c;this.options=d};lb.ZERO=x(function(){return 0},{sharedGetter:!0,constant:!0});lb.prototype={constructor:lb,parse:function(a){this.text=a;this.tokens=this.lexer.lex(a);a=this.statements();0!==this.tokens.length&&this.throwError("is an unexpected token",this.tokens[0]);a.literal=!!a.literal;a.constant=!!a.constant;return a},primary:function(){var a;this.expect("(")?(a=this.filterChain(),this.consume(")")):this.expect("[")?a=this.arrayDeclaration():this.expect("{")?a=this.o [...]
+this.peek().text in pb?a=pb[this.consume().text]:this.peek().identifier?a=this.identifier():this.peek().constant?a=this.constant():this.throwError("not a primary expression",this.peek());for(var c,d;c=this.expect("(","[",".");)"("===c.text?(a=this.functionCall(a,d),d=null):"["===c.text?(d=a,a=this.objectIndex(a)):"."===c.text?(d=a,a=this.fieldAccess(a)):this.throwError("IMPOSSIBLE");return a},throwError:function(a,c){throw ja("syntax",c.text,a,c.index+1,this.text,this.text.substring(c.in [...]
+this.tokens.length)throw ja("ueoe",this.text);return this.tokens[0]},peek:function(a,c,d,e){return this.peekAhead(0,a,c,d,e)},peekAhead:function(a,c,d,e,f){if(this.tokens.length>a){a=this.tokens[a];var g=a.text;if(g===c||g===d||g===e||g===f||!(c||d||e||f))return a}return!1},expect:function(a,c,d,e){return(a=this.peek(a,c,d,e))?(this.tokens.shift(),a):!1},consume:function(a){if(0===this.tokens.length)throw ja("ueoe",this.text);var c=this.expect(a);c||this.throwError("is unexpected, expect [...]
+"]",this.peek());return c},unaryFn:function(a,c){var d=qb[a];return x(function(a,f){return d(a,f,c)},{constant:c.constant,inputs:[c]})},binaryFn:function(a,c,d,e){var f=qb[c];return x(function(c,e){return f(c,e,a,d)},{constant:a.constant&&d.constant,inputs:!e&&[a,d]})},identifier:function(){for(var a=this.consume().text;this.peek(".")&&this.peekAhead(1).identifier&&!this.peekAhead(2,"(");)a+=this.consume().text+this.consume().text;return Df(a,this.options,this.text)},constant:function(){ [...]
+return x(function(){return a},{constant:!0,literal:!0})},statements:function(){for(var a=[];;)if(0<this.tokens.length&&!this.peek("}",")",";","]")&&a.push(this.filterChain()),!this.expect(";"))return 1===a.length?a[0]:function(c,d){for(var e,f=0,g=a.length;f<g;f++)e=a[f](c,d);return e}},filterChain:function(){for(var a=this.expression();this.expect("|");)a=this.filter(a);return a},filter:function(a){var c=this.$filter(this.consume().text),d,e;if(this.peek(":"))for(d=[],e=[];this.expect(" [...]
+var f=[a].concat(d||[]);return x(function(f,h){var l=a(f,h);if(e){e[0]=l;for(l=d.length;l--;)e[l+1]=d[l](f,h);return c.apply(s,e)}return c(l)},{constant:!c.$stateful&&f.every(ic),inputs:!c.$stateful&&f})},expression:function(){return this.assignment()},assignment:function(){var a=this.ternary(),c,d;return(d=this.expect("="))?(a.assign||this.throwError("implies assignment but ["+this.text.substring(0,d.index)+"] can not be assigned to",d),c=this.ternary(),x(function(d,f){return a.assign(d [...]
+{inputs:[a,c]})):a},ternary:function(){var a=this.logicalOR(),c;if(this.expect("?")&&(c=this.assignment(),this.consume(":"))){var d=this.assignment();return x(function(e,f){return a(e,f)?c(e,f):d(e,f)},{constant:a.constant&&c.constant&&d.constant})}return a},logicalOR:function(){for(var a=this.logicalAND(),c;c=this.expect("||");)a=this.binaryFn(a,c.text,this.logicalAND(),!0);return a},logicalAND:function(){for(var a=this.equality(),c;c=this.expect("&&");)a=this.binaryFn(a,c.text,this.equ [...]
+return a},equality:function(){for(var a=this.relational(),c;c=this.expect("==","!=","===","!==");)a=this.binaryFn(a,c.text,this.relational());return a},relational:function(){for(var a=this.additive(),c;c=this.expect("<",">","<=",">=");)a=this.binaryFn(a,c.text,this.additive());return a},additive:function(){for(var a=this.multiplicative(),c;c=this.expect("+","-");)a=this.binaryFn(a,c.text,this.multiplicative());return a},multiplicative:function(){for(var a=this.unary(),c;c=this.expect("*" [...]
+this.binaryFn(a,c.text,this.unary());return a},unary:function(){var a;return this.expect("+")?this.primary():(a=this.expect("-"))?this.binaryFn(lb.ZERO,a.text,this.unary()):(a=this.expect("!"))?this.unaryFn(a.text,this.unary()):this.primary()},fieldAccess:function(a){var c=this.identifier();return x(function(d,e,f){d=f||a(d,e);return null==d?s:c(d)},{assign:function(d,e,f){var g=a(d,f);g||a.assign(d,g={},f);return c.assign(g,e)}})},objectIndex:function(a){var c=this.text,d=this.expressio [...]
+return x(function(e,f){var g=a(e,f),h=d(e,f);ra(h,c);return g?ka(g[h],c):s},{assign:function(e,f,g){var h=ra(d(e,g),c),l=ka(a(e,g),c);l||a.assign(e,l={},g);return l[h]=f}})},functionCall:function(a,c){var d=[];if(")"!==this.peekToken().text){do d.push(this.expression());while(this.expect(","))}this.consume(")");var e=this.text,f=d.length?[]:null;return function(g,h){var l=c?c(g,h):y(c)?s:g,k=a(g,h,l)||A;if(f)for(var m=d.length;m--;)f[m]=ka(d[m](g,h),e);ka(l,e);if(k){if(k.constructor===k) [...]
+e);if(k===$f||k===ag||k===bg)throw ja("isecff",e);}l=k.apply?k.apply(l,f):k(f[0],f[1],f[2],f[3],f[4]);f&&(f.length=0);return ka(l,e)}},arrayDeclaration:function(){var a=[];if("]"!==this.peekToken().text){do{if(this.peek("]"))break;a.push(this.expression())}while(this.expect(","))}this.consume("]");return x(function(c,d){for(var e=[],f=0,g=a.length;f<g;f++)e.push(a[f](c,d));return e},{literal:!0,constant:a.every(ic),inputs:a})},object:function(){var a=[],c=[];if("}"!==this.peekToken().tex [...]
+var d=this.consume();d.constant?a.push(d.value):d.identifier?a.push(d.text):this.throwError("invalid key",d);this.consume(":");c.push(this.expression())}while(this.expect(","))}this.consume("}");return x(function(d,f){for(var g={},h=0,l=c.length;h<l;h++)g[a[h]]=c[h](d,f);return g},{literal:!0,constant:c.every(ic),inputs:c})}};var Ff=ga(),Ef=ga(),Gf=Object.prototype.valueOf,za=F("$sce"),la={HTML:"html",CSS:"css",URL:"url",RESOURCE_URL:"resourceUrl",JS:"js"},da=F("$compile"),X=V.createElem [...]
+nd=ya(T.location.href);Ic.$inject=["$provide"];od.$inject=["$locale"];qd.$inject=["$locale"];var td=".",Qf={yyyy:Z("FullYear",4),yy:Z("FullYear",2,0,!0),y:Z("FullYear",1),MMMM:Kb("Month"),MMM:Kb("Month",!0),MM:Z("Month",2,1),M:Z("Month",1,1),dd:Z("Date",2),d:Z("Date",1),HH:Z("Hours",2),H:Z("Hours",1),hh:Z("Hours",2,-12),h:Z("Hours",1,-12),mm:Z("Minutes",2),m:Z("Minutes",1),ss:Z("Seconds",2),s:Z("Seconds",1),sss:Z("Milliseconds",3),EEEE:Kb("Day"),EEE:Kb("Day",!0),a:function(a,c){return 12 [...]
+c.AMPMS[0]:c.AMPMS[1]},Z:function(a){a=-1*a.getTimezoneOffset();return a=(0<=a?"+":"")+(Jb(Math[0<a?"floor":"ceil"](a/60),2)+Jb(Math.abs(a%60),2))},ww:vd(2),w:vd(1),G:lc,GG:lc,GGG:lc,GGGG:function(a,c){return 0>=a.getFullYear()?c.ERANAMES[0]:c.ERANAMES[1]}},Pf=/((?:[^yMdHhmsaZEwG']+)|(?:'(?:[^']|'')*')|(?:E+|y+|M+|d+|H+|h+|m+|s+|a|Z|G+|w+))(.*)/,Of=/^\-?\d+$/;pd.$inject=["$locale"];var Lf=ca(L),Mf=ca(vb);rd.$inject=["$parse"];var Yd=ca({restrict:"E",compile:function(a,c){if(!c.href&&!c.x [...]
+!c.name)return function(a,c){if("a"===c[0].nodeName.toLowerCase()){var f="[object SVGAnimatedString]"===Aa.call(c.prop("href"))?"xlink:href":"href";c.on("click",function(a){c.attr(f)||a.preventDefault()})}}}}),wb={};q(Fb,function(a,c){if("multiple"!=a){var d=va("ng-"+c);wb[d]=function(){return{restrict:"A",priority:100,link:function(a,f,g){a.$watch(g[d],function(a){g.$set(c,!!a)})}}}}});q(Sc,function(a,c){wb[c]=function(){return{priority:100,link:function(a,e,f){if("ngPattern"===c&&"/"== [...]
+(e=f.ngPattern.match(Sf))){f.$set("ngPattern",new RegExp(e[1],e[2]));return}a.$watch(f[c],function(a){f.$set(c,a)})}}}});q(["src","srcset","href"],function(a){var c=va("ng-"+a);wb[c]=function(){return{priority:99,link:function(d,e,f){var g=a,h=a;"href"===a&&"[object SVGAnimatedString]"===Aa.call(e.prop("href"))&&(h="xlinkHref",f.$attr[h]="xlink:href",g=null);f.$observe(c,function(c){c?(f.$set(h,c),Qa&&g&&e.prop(g,f[h])):"href"===a&&f.$set(h,null)})}}}});var Lb={$addControl:A,$$renameCont [...]
+c){a.$name=c},$removeControl:A,$setValidity:A,$setDirty:A,$setPristine:A,$setSubmitted:A};wd.$inject=["$element","$attrs","$scope","$animate","$interpolate"];var Dd=function(a){return["$timeout",function(c){return{name:"form",restrict:a?"EAC":"E",controller:wd,compile:function(d,e){d.addClass(Ra).addClass(ob);var f=e.name?"name":a&&e.ngForm?"ngForm":!1;return{pre:function(a,d,e,k){if(!("action"in e)){var m=function(c){a.$apply(function(){k.$commitViewValue();k.$setSubmitted()});c.prevent [...]
+d[0].addEventListener("submit",m,!1);d.on("$destroy",function(){c(function(){d[0].removeEventListener("submit",m,!1)},0,!1)})}var r=k.$$parentForm;f&&(kb(a,null,k.$name,k,k.$name),e.$observe(f,function(c){k.$name!==c&&(kb(a,null,k.$name,s,k.$name),r.$$renameControl(k,c),kb(a,null,k.$name,k,k.$name))}));d.on("$destroy",function(){r.$removeControl(k);f&&kb(a,null,e[f],s,k.$name);x(k,Lb)})}}}}}]},Zd=Dd(),le=Dd(!0),Rf=/\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d\.\d+([+-][0-2]\d:[0-5]\d|Z)/ [...]
+eg=/^[a-z0-9!#$%&'*+\/=?^_`{|}~.-]+@[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/i,fg=/^\s*(\-|\+)?(\d+|(\d*(\.\d*)))\s*$/,Ed=/^(\d{4})-(\d{2})-(\d{2})$/,Fd=/^(\d{4})-(\d\d)-(\d\d)T(\d\d):(\d\d)(?::(\d\d)(\.\d{1,3})?)?$/,oc=/^(\d{4})-W(\d\d)$/,Gd=/^(\d{4})-(\d\d)$/,Hd=/^(\d\d):(\d\d)(?::(\d\d)(\.\d{1,3})?)?$/,Id={text:function(a,c,d,e,f,g){mb(a,c,d,e,f,g);mc(e)},date:nb("date",Ed,Nb(Ed,["yyyy","MM","dd"]),"yyyy-MM-dd"),"datetime-local":nb("datetimelocal",Fd,Nb(Fd,"yyy [...]
+"yyyy-MM-ddTHH:mm:ss.sss"),time:nb("time",Hd,Nb(Hd,["HH","mm","ss","sss"]),"HH:mm:ss.sss"),week:nb("week",oc,function(a,c){if(ea(a))return a;if(O(a)){oc.lastIndex=0;var d=oc.exec(a);if(d){var e=+d[1],f=+d[2],g=d=0,h=0,l=0,k=ud(e),f=7*(f-1);c&&(d=c.getHours(),g=c.getMinutes(),h=c.getSeconds(),l=c.getMilliseconds());return new Date(e,0,k.getDate()+f,d,g,h,l)}}return NaN},"yyyy-Www"),month:nb("month",Gd,Nb(Gd,["yyyy","MM"]),"yyyy-MM"),number:function(a,c,d,e,f,g){yd(a,c,d,e);mb(a,c,d,e,f,g) [...]
+"number";e.$parsers.push(function(a){return e.$isEmpty(a)?null:fg.test(a)?parseFloat(a):s});e.$formatters.push(function(a){if(!e.$isEmpty(a)){if(!Q(a))throw Ob("numfmt",a);a=a.toString()}return a});if(y(d.min)||d.ngMin){var h;e.$validators.min=function(a){return e.$isEmpty(a)||C(h)||a>=h};d.$observe("min",function(a){y(a)&&!Q(a)&&(a=parseFloat(a,10));h=Q(a)&&!isNaN(a)?a:s;e.$validate()})}if(y(d.max)||d.ngMax){var l;e.$validators.max=function(a){return e.$isEmpty(a)||C(l)||a<=l};d.$observ [...]
+!Q(a)&&(a=parseFloat(a,10));l=Q(a)&&!isNaN(a)?a:s;e.$validate()})}},url:function(a,c,d,e,f,g){mb(a,c,d,e,f,g);mc(e);e.$$parserName="url";e.$validators.url=function(a,c){var d=a||c;return e.$isEmpty(d)||dg.test(d)}},email:function(a,c,d,e,f,g){mb(a,c,d,e,f,g);mc(e);e.$$parserName="email";e.$validators.email=function(a,c){var d=a||c;return e.$isEmpty(d)||eg.test(d)}},radio:function(a,c,d,e){C(d.name)&&c.attr("name",++rb);c.on("click",function(a){c[0].checked&&e.$setViewValue(d.value,a&&a.t [...]
+function(){c[0].checked=d.value==e.$viewValue};d.$observe("value",e.$render)},checkbox:function(a,c,d,e,f,g,h,l){var k=zd(l,a,"ngTrueValue",d.ngTrueValue,!0),m=zd(l,a,"ngFalseValue",d.ngFalseValue,!1);c.on("click",function(a){e.$setViewValue(c[0].checked,a&&a.type)});e.$render=function(){c[0].checked=e.$viewValue};e.$isEmpty=function(a){return!1===a};e.$formatters.push(function(a){return fa(a,k)});e.$parsers.push(function(a){return a?k:m})},hidden:A,button:A,submit:A,reset:A,file:A},Cc=[ [...]
+"$sniffer","$filter","$parse",function(a,c,d,e){return{restrict:"E",require:["?ngModel"],link:{pre:function(f,g,h,l){l[0]&&(Id[L(h.type)]||Id.text)(f,g,h,l[0],c,a,d,e)}}}}],gg=/^(true|false|\d+)$/,De=function(){return{restrict:"A",priority:100,compile:function(a,c){return gg.test(c.ngValue)?function(a,c,f){f.$set("value",a.$eval(f.ngValue))}:function(a,c,f){a.$watch(f.ngValue,function(a){f.$set("value",a)})}}}},de=["$compile",function(a){return{restrict:"AC",compile:function(c){a.$$addBi [...]
+return function(c,e,f){a.$$addBindingInfo(e,f.ngBind);e=e[0];c.$watch(f.ngBind,function(a){e.textContent=a===s?"":a})}}}}],fe=["$interpolate","$compile",function(a,c){return{compile:function(d){c.$$addBindingClass(d);return function(d,f,g){d=a(f.attr(g.$attr.ngBindTemplate));c.$$addBindingInfo(f,d.expressions);f=f[0];g.$observe("ngBindTemplate",function(a){f.textContent=a===s?"":a})}}}}],ee=["$sce","$parse","$compile",function(a,c,d){return{restrict:"A",compile:function(e,f){var g=c(f.ng [...]
+h=c(f.ngBindHtml,function(a){return(a||"").toString()});d.$$addBindingClass(e);return function(c,e,f){d.$$addBindingInfo(e,f.ngBindHtml);c.$watch(h,function(){e.html(a.getTrustedHtml(g(c))||"")})}}}}],Ce=ca({restrict:"A",require:"ngModel",link:function(a,c,d,e){e.$viewChangeListeners.push(function(){a.$eval(d.ngChange)})}}),ge=nc("",!0),ie=nc("Odd",0),he=nc("Even",1),je=Ia({compile:function(a,c){c.$set("ngCloak",s);a.removeClass("ng-cloak")}}),ke=[function(){return{restrict:"A",scope:!0, [...]
+priority:500}}],Hc={},hg={blur:!0,focus:!0};q("click dblclick mousedown mouseup mouseover mouseout mousemove mouseenter mouseleave keydown keyup keypress submit focus blur copy cut paste".split(" "),function(a){var c=va("ng-"+a);Hc[c]=["$parse","$rootScope",function(d,e){return{restrict:"A",compile:function(f,g){var h=d(g[c],null,!0);return function(c,d){d.on(a,function(d){var f=function(){h(c,{$event:d})};hg[a]&&e.$$phase?c.$evalAsync(f):c.$apply(f)})}}}}]});var ne=["$animate",function( [...]
+transclude:"element",priority:600,terminal:!0,restrict:"A",$$tlb:!0,link:function(c,d,e,f,g){var h,l,k;c.$watch(e.ngIf,function(c){c?l||g(function(c,f){l=f;c[c.length++]=V.createComment(" end ngIf: "+e.ngIf+" ");h={clone:c};a.enter(c,d.parent(),d)}):(k&&(k.remove(),k=null),l&&(l.$destroy(),l=null),h&&(k=ub(h.clone),a.leave(k).then(function(){k=null}),h=null))})}}}],oe=["$templateRequest","$anchorScroll","$animate","$sce",function(a,c,d,e){return{restrict:"ECA",priority:400,terminal:!0,tr [...]
+controller:ba.noop,compile:function(f,g){var h=g.ngInclude||g.src,l=g.onload||"",k=g.autoscroll;return function(f,g,n,q,v){var s=0,t,p,H,J=function(){p&&(p.remove(),p=null);t&&(t.$destroy(),t=null);H&&(d.leave(H).then(function(){p=null}),p=H,H=null)};f.$watch(e.parseAsResourceUrl(h),function(e){var h=function(){!y(k)||k&&!f.$eval(k)||c()},n=++s;e?(a(e,!0).then(function(a){if(n===s){var c=f.$new();q.template=a;a=v(c,function(a){J();d.enter(a,null,g).then(h)});t=c;H=a;t.$emit("$includeCont [...]
+e);f.$eval(l)}},function(){n===s&&(J(),f.$emit("$includeContentError",e))}),f.$emit("$includeContentRequested",e)):(J(),q.template=null)})}}}}],Fe=["$compile",function(a){return{restrict:"ECA",priority:-400,require:"ngInclude",link:function(c,d,e,f){/SVG/.test(d[0].toString())?(d.empty(),a(Kc(f.template,V).childNodes)(c,function(a){d.append(a)},{futureParentElement:d})):(d.html(f.template),a(d.contents())(c))}}}],pe=Ia({priority:450,compile:function(){return{pre:function(a,c,d){a.$eval(d [...]
+Be=function(){return{restrict:"A",priority:100,require:"ngModel",link:function(a,c,d,e){var f=c.attr(d.$attr.ngList)||", ",g="false"!==d.ngTrim,h=g?U(f):f;e.$parsers.push(function(a){if(!C(a)){var c=[];a&&q(a.split(h),function(a){a&&c.push(g?U(a):a)});return c}});e.$formatters.push(function(a){return w(a)?a.join(f):s});e.$isEmpty=function(a){return!a||!a.length}}}},ob="ng-valid",Ad="ng-invalid",Ra="ng-pristine",Mb="ng-dirty",Cd="ng-pending",Ob=new F("ngModel"),ig=["$scope","$exceptionHan [...]
+"$element","$parse","$animate","$timeout","$rootScope","$q","$interpolate",function(a,c,d,e,f,g,h,l,k,m){this.$modelValue=this.$viewValue=Number.NaN;this.$$rawModelValue=s;this.$validators={};this.$asyncValidators={};this.$parsers=[];this.$formatters=[];this.$viewChangeListeners=[];this.$untouched=!0;this.$touched=!1;this.$pristine=!0;this.$dirty=!1;this.$valid=!0;this.$invalid=!1;this.$error={};this.$$success={};this.$pending=s;this.$name=m(d.name||"",!1)(a);var r=f(d.ngModel),n=r.assig [...]
+P=null,t,p=this;this.$$setOptions=function(a){if((p.$options=a)&&a.getterSetter){var c=f(d.ngModel+"()"),g=f(d.ngModel+"($$$p)");u=function(a){var d=r(a);E(d)&&(d=c(a));return d};v=function(a,c){E(r(a))?g(a,{$$$p:p.$modelValue}):n(a,p.$modelValue)}}else if(!r.assign)throw Ob("nonassign",d.ngModel,ta(e));};this.$render=A;this.$isEmpty=function(a){return C(a)||""===a||null===a||a!==a};var H=e.inheritedData("$formController")||Lb,J=0;xd({ctrl:this,$element:e,set:function(a,c){a[c]=!0},unset [...]
+c){delete a[c]},parentForm:H,$animate:g});this.$setPristine=function(){p.$dirty=!1;p.$pristine=!0;g.removeClass(e,Mb);g.addClass(e,Ra)};this.$setDirty=function(){p.$dirty=!0;p.$pristine=!1;g.removeClass(e,Ra);g.addClass(e,Mb);H.$setDirty()};this.$setUntouched=function(){p.$touched=!1;p.$untouched=!0;g.setClass(e,"ng-untouched","ng-touched")};this.$setTouched=function(){p.$touched=!0;p.$untouched=!1;g.setClass(e,"ng-touched","ng-untouched")};this.$rollbackViewValue=function(){h.cancel(P); [...]
+p.$$lastCommittedViewValue;p.$render()};this.$validate=function(){if(!Q(p.$modelValue)||!isNaN(p.$modelValue)){var a=p.$$rawModelValue,c=p.$valid,d=p.$modelValue,e=p.$options&&p.$options.allowInvalid;p.$$runValidators(a,p.$$lastCommittedViewValue,function(f){e||c===f||(p.$modelValue=f?a:s,p.$modelValue!==d&&p.$$writeModelToScope())})}};this.$$runValidators=function(a,c,d){function e(){var d=!0;q(p.$validators,function(e,f){var h=e(a,c);d=d&&h;g(f,h)});return d?!0:(q(p.$asyncValidators,fu [...]
+c){g(c,null)}),!1)}function f(){var d=[],e=!0;q(p.$asyncValidators,function(f,h){var k=f(a,c);if(!k||!E(k.then))throw Ob("$asyncValidators",k);g(h,s);d.push(k.then(function(){g(h,!0)},function(a){e=!1;g(h,!1)}))});d.length?k.all(d).then(function(){h(e)},A):h(!0)}function g(a,c){l===J&&p.$setValidity(a,c)}function h(a){l===J&&d(a)}J++;var l=J;(function(){var a=p.$$parserName||"parse";if(t===s)g(a,null);else return t||(q(p.$validators,function(a,c){g(c,null)}),q(p.$asyncValidators,function [...]
+null)})),g(a,t),t;return!0})()?e()?f():h(!1):h(!1)};this.$commitViewValue=function(){var a=p.$viewValue;h.cancel(P);if(p.$$lastCommittedViewValue!==a||""===a&&p.$$hasNativeValidators)p.$$lastCommittedViewValue=a,p.$pristine&&this.$setDirty(),this.$$parseAndValidate()};this.$$parseAndValidate=function(){var c=p.$$lastCommittedViewValue;if(t=C(c)?s:!0)for(var d=0;d<p.$parsers.length;d++)if(c=p.$parsers[d](c),C(c)){t=!1;break}Q(p.$modelValue)&&isNaN(p.$modelValue)&&(p.$modelValue=u(a));var  [...]
+f=p.$options&&p.$options.allowInvalid;p.$$rawModelValue=c;f&&(p.$modelValue=c,p.$modelValue!==e&&p.$$writeModelToScope());p.$$runValidators(c,p.$$lastCommittedViewValue,function(a){f||(p.$modelValue=a?c:s,p.$modelValue!==e&&p.$$writeModelToScope())})};this.$$writeModelToScope=function(){v(a,p.$modelValue);q(p.$viewChangeListeners,function(a){try{a()}catch(d){c(d)}})};this.$setViewValue=function(a,c){p.$viewValue=a;p.$options&&!p.$options.updateOnDefault||p.$$debounceViewValueCommit(c)};t [...]
+function(c){var d=0,e=p.$options;e&&y(e.debounce)&&(e=e.debounce,Q(e)?d=e:Q(e[c])?d=e[c]:Q(e["default"])&&(d=e["default"]));h.cancel(P);d?P=h(function(){p.$commitViewValue()},d):l.$$phase?p.$commitViewValue():a.$apply(function(){p.$commitViewValue()})};a.$watch(function(){var c=u(a);if(c!==p.$modelValue&&(p.$modelValue===p.$modelValue||c===c)){p.$modelValue=p.$$rawModelValue=c;t=s;for(var d=p.$formatters,e=d.length,f=c;e--;)f=d[e](f);p.$viewValue!==f&&(p.$viewValue=p.$$lastCommittedViewV [...]
+p.$$runValidators(c,f,A))}return c})}],Ae=["$rootScope",function(a){return{restrict:"A",require:["ngModel","^?form","^?ngModelOptions"],controller:ig,priority:1,compile:function(c){c.addClass(Ra).addClass("ng-untouched").addClass(ob);return{pre:function(a,c,f,g){var h=g[0],l=g[1]||Lb;h.$$setOptions(g[2]&&g[2].$options);l.$addControl(h);f.$observe("name",function(a){h.$name!==a&&l.$$renameControl(h,a)});a.$on("$destroy",function(){l.$removeControl(h)})},post:function(c,e,f,g){var h=g[0];i [...]
+h.$options.updateOn)e.on(h.$options.updateOn,function(a){h.$$debounceViewValueCommit(a&&a.type)});e.on("blur",function(e){h.$touched||(a.$$phase?c.$evalAsync(h.$setTouched):c.$apply(h.$setTouched))})}}}}}],jg=/(\s+|^)default(\s+|$)/,Ee=function(){return{restrict:"A",controller:["$scope","$attrs",function(a,c){var d=this;this.$options=a.$eval(c.ngModelOptions);this.$options.updateOn!==s?(this.$options.updateOnDefault=!1,this.$options.updateOn=U(this.$options.updateOn.replace(jg,function() [...]
+!0;return" "}))):this.$options.updateOnDefault=!0}]}},qe=Ia({terminal:!0,priority:1E3}),re=["$locale","$interpolate",function(a,c){var d=/{}/g,e=/^when(Minus)?(.+)$/;return{restrict:"EA",link:function(f,g,h){function l(a){g.text(a||"")}var k=h.count,m=h.$attr.when&&g.attr(h.$attr.when),r=h.offset||0,n=f.$eval(m)||{},u={},m=c.startSymbol(),s=c.endSymbol(),y=m+k+"-"+r+s,t=ba.noop,p;q(h,function(a,c){var d=e.exec(c);d&&(d=(d[1]?"-":"")+L(d[2]),n[d]=g.attr(h.$attr[c]))});q(n,function(a,e){u[ [...]
+y))});f.$watch(k,function(c){c=parseFloat(c);var d=isNaN(c);d||c in n||(c=a.pluralCat(c-r));c===p||d&&isNaN(p)||(t(),t=f.$watch(u[c],l),p=c)})}}}],se=["$parse","$animate",function(a,c){var d=F("ngRepeat"),e=function(a,c,d,e,k,m,q){a[d]=e;k&&(a[k]=m);a.$index=c;a.$first=0===c;a.$last=c===q-1;a.$middle=!(a.$first||a.$last);a.$odd=!(a.$even=0===(c&1))};return{restrict:"A",multiElement:!0,transclude:"element",priority:1E3,terminal:!0,$$tlb:!0,compile:function(f,g){var h=g.ngRepeat,l=V.create [...]
+h+" "),k=h.match(/^\s*([\s\S]+?)\s+in\s+([\s\S]+?)(?:\s+as\s+([\s\S]+?))?(?:\s+track\s+by\s+([\s\S]+?))?\s*$/);if(!k)throw d("iexp",h);var m=k[1],r=k[2],n=k[3],u=k[4],k=m.match(/^(?:(\s*[\$\w]+)|\(\s*([\$\w]+)\s*,\s*([\$\w]+)\s*\))$/);if(!k)throw d("iidexp",m);var v=k[3]||k[1],y=k[2];if(n&&(!/^[$a-zA-Z_][$a-zA-Z0-9_]*$/.test(n)||/^(null|undefined|this|\$index|\$first|\$middle|\$last|\$even|\$odd|\$parent|\$root|\$id)$/.test(n)))throw d("badident",n);var t,p,H,F,B={$id:Na};u?t=a(u):(H=fun [...]
+F=function(a){return a});return function(a,f,g,k,m){t&&(p=function(c,d,e){y&&(B[y]=c);B[v]=d;B.$index=e;return t(a,B)});var u=ga();a.$watchCollection(r,function(g){var k,r,t=f[0],G,B=ga(),x,C,A,N,E,w,I;n&&(a[n]=g);if(Sa(g))E=g,r=p||H;else{r=p||F;E=[];for(I in g)g.hasOwnProperty(I)&&"$"!=I.charAt(0)&&E.push(I);E.sort()}x=E.length;I=Array(x);for(k=0;k<x;k++)if(C=g===E?k:E[k],A=g[C],N=r(C,A,k),u[N])w=u[N],delete u[N],B[N]=w,I[k]=w;else{if(B[N])throw q(I,function(a){a&&a.scope&&(u[a.id]=a)}) [...]
+h,N,A);I[k]={id:N,scope:s,clone:s};B[N]=!0}for(G in u){w=u[G];N=ub(w.clone);c.leave(N);if(N[0].parentNode)for(k=0,r=N.length;k<r;k++)N[k].$$NG_REMOVED=!0;w.scope.$destroy()}for(k=0;k<x;k++)if(C=g===E?k:E[k],A=g[C],w=I[k],w.scope){G=t;do G=G.nextSibling;while(G&&G.$$NG_REMOVED);w.clone[0]!=G&&c.move(ub(w.clone),null,z(t));t=w.clone[w.clone.length-1];e(w.scope,k,v,A,y,C,x)}else m(function(a,d){w.scope=d;var f=l.cloneNode(!1);a[a.length++]=f;c.enter(a,null,z(t));t=f;w.clone=a;B[w.id]=w;e(w. [...]
+A,y,C,x)});u=B})}}}}],te=["$animate",function(a){return{restrict:"A",multiElement:!0,link:function(c,d,e){c.$watch(e.ngShow,function(c){a[c?"removeClass":"addClass"](d,"ng-hide",{tempClasses:"ng-hide-animate"})})}}}],me=["$animate",function(a){return{restrict:"A",multiElement:!0,link:function(c,d,e){c.$watch(e.ngHide,function(c){a[c?"addClass":"removeClass"](d,"ng-hide",{tempClasses:"ng-hide-animate"})})}}}],ue=Ia(function(a,c,d){a.$watch(d.ngStyle,function(a,d){d&&a!==d&&q(d,function(a, [...]
+"")});a&&c.css(a)},!0)}),ve=["$animate",function(a){return{restrict:"EA",require:"ngSwitch",controller:["$scope",function(){this.cases={}}],link:function(c,d,e,f){var g=[],h=[],l=[],k=[],m=function(a,c){return function(){a.splice(c,1)}};c.$watch(e.ngSwitch||e.on,function(c){var d,e;d=0;for(e=l.length;d<e;++d)a.cancel(l[d]);d=l.length=0;for(e=k.length;d<e;++d){var s=ub(h[d].clone);k[d].$destroy();(l[d]=a.leave(s)).then(m(l,d))}h.length=0;k.length=0;(g=f.cases["!"+c]||f.cases["?"])&&q(g,fu [...]
+e){k.push(e);var f=c.element;d[d.length++]=V.createComment(" end ngSwitchWhen: ");h.push({clone:d});a.enter(d,f.parent(),f)})})})}}}],we=Ia({transclude:"element",priority:1200,require:"^ngSwitch",multiElement:!0,link:function(a,c,d,e,f){e.cases["!"+d.ngSwitchWhen]=e.cases["!"+d.ngSwitchWhen]||[];e.cases["!"+d.ngSwitchWhen].push({transclude:f,element:c})}}),xe=Ia({transclude:"element",priority:1200,require:"^ngSwitch",multiElement:!0,link:function(a,c,d,e,f){e.cases["?"]=e.cases["?"]||[]; [...]
+element:c})}}),ze=Ia({restrict:"EAC",link:function(a,c,d,e,f){if(!f)throw F("ngTransclude")("orphan",ta(c));f(function(a){c.empty();c.append(a)})}}),$d=["$templateCache",function(a){return{restrict:"E",terminal:!0,compile:function(c,d){"text/ng-template"==d.type&&a.put(d.id,c[0].text)}}}],kg=F("ngOptions"),ye=ca({restrict:"A",terminal:!0}),ae=["$compile","$parse",function(a,c){var d=/^\s*([\s\S]+?)(?:\s+as\s+([\s\S]+?))?(?:\s+group\s+by\s+([\s\S]+?))?\s+for\s+(?:([\$\w][\$\w]*)|(?:\(\s*( [...]
+e={$setViewValue:A};return{restrict:"E",require:["select","?ngModel"],controller:["$element","$scope","$attrs",function(a,c,d){var l=this,k={},m=e,q;l.databound=d.ngModel;l.init=function(a,c,d){m=a;q=d};l.addOption=function(c,d){Ma(c,'"option value"');k[c]=!0;m.$viewValue==c&&(a.val(c),q.parent()&&q.remove());d&&d[0].hasAttribute("selected")&&(d[0].selected=!0)};l.removeOption=function(a){this.hasOption(a)&&(delete k[a],m.$viewValue===a&&this.renderUnknownOption(a))};l.renderUnknownOptio [...]
+"? "+Na(c)+" ?";q.val(c);a.prepend(q);a.val(c);q.prop("selected",!0)};l.hasOption=function(a){return k.hasOwnProperty(a)};c.$on("$destroy",function(){l.renderUnknownOption=A})}],link:function(e,g,h,l){function k(a,c,d,e){d.$render=function(){var a=d.$viewValue;e.hasOption(a)?(B.parent()&&B.remove(),c.val(a),""===a&&t.prop("selected",!0)):null==a&&t?c.val(""):e.renderUnknownOption(a)};c.on("change",function(){a.$apply(function(){B.parent()&&B.remove();d.$setViewValue(c.val())})})}function [...]
+d.$render=function(){var a=new fb(d.$viewValue);q(c.find("option"),function(c){c.selected=y(a.get(c.value))})};a.$watch(function(){fa(e,d.$viewValue)||(e=oa(d.$viewValue),d.$render())});c.on("change",function(){a.$apply(function(){var a=[];q(c.find("option"),function(c){c.selected&&a.push(c.value)});d.$setViewValue(a)})})}function r(e,f,g){function h(a,c,d){U[A]=d;I&&(U[I]=c);return a(e,U)}function k(a){var c;if(u)if(L&&w(a)){c=new fb([]);for(var d=0;d<a.length;d++)c.put(h(L,null,a[d]),! [...]
+new fb(a);else L&&(a=h(L,null,a));return function(d,e){var f;f=L?L:z?z:D;return u?y(c.remove(h(f,d,e))):a===h(f,d,e)}}function l(){p||(e.$$postDigest(r),p=!0)}function m(a,c,d){a[c]=a[c]||0;a[c]+=d?1:-1}function r(){p=!1;var a={"":[]},c=[""],d,l,s,t,v;s=g.$viewValue;t=O(e)||[];var A=I?Object.keys(t).sort():t,w,z,E,D,S={};v=k(s);var Q=!1,V,X;R={};for(D=0;E=A.length,D<E;D++){w=D;if(I&&(w=A[D],"$"===w.charAt(0)))continue;z=t[w];d=h(M,w,z)||"";(l=a[d])||(l=a[d]=[],c.push(d));d=v(w,z);Q=Q||d; [...]
+z=y(z)?z:"";X=L?L(e,U):I?A[D]:D;L&&(R[X]=w);l.push({id:X,label:z,selected:d})}u||(x||null===s?a[""].unshift({id:"",label:"",selected:!Q}):Q||a[""].unshift({id:"?",label:"",selected:!0}));w=0;for(A=c.length;w<A;w++){d=c[w];l=a[d];T.length<=w?(s={element:F.clone().attr("label",d),label:l.label},t=[s],T.push(t),f.append(s.element)):(t=T[w],s=t[0],s.label!=d&&s.element.attr("label",s.label=d));Q=null;D=0;for(E=l.length;D<E;D++)d=l[D],(v=t[D+1])?(Q=v.element,v.label!==d.label&&(m(S,v.label,!1 [...]
+!0),Q.text(v.label=d.label),Q.prop("label",v.label)),v.id!==d.id&&Q.val(v.id=d.id),Q[0].selected!==d.selected&&(Q.prop("selected",v.selected=d.selected),Qa&&Q.prop("selected",v.selected))):(""===d.id&&x?V=x:(V=C.clone()).val(d.id).prop("selected",d.selected).attr("selected",d.selected).prop("label",d.label).text(d.label),t.push(v={element:V,label:d.label,id:d.id,selected:d.selected}),m(S,d.label,!0),Q?Q.after(V):s.element.append(V),Q=V);for(D++;t.length>D;)d=t.pop(),m(S,d.label,!1),d.ele [...]
+w;){l=T.pop();for(D=1;D<l.length;++D)m(S,l[D].label,!1);l[0].element.remove()}q(S,function(a,c){0<a?n.addOption(c):0>a&&n.removeOption(c)})}var t;if(!(t=v.match(d)))throw kg("iexp",v,ta(f));var B=c(t[2]||t[1]),A=t[4]||t[6],E=/ as /.test(t[0])&&t[1],z=E?c(E):null,I=t[5],M=c(t[3]||""),D=c(t[2]?t[1]:A),O=c(t[7]),L=t[8]?c(t[8]):null,R={},T=[[{element:f,label:""}]],U={};x&&(a(x)(e),x.removeClass("ng-scope"),x.remove());f.empty();f.on("change",function(){e.$apply(function(){var a=O(e)||[],c;if [...]
+function(d){d=L?R[d]:d;c.push("?"===d?s:""===d?null:h(z?z:D,d,a[d]))});else{var d=L?R[f.val()]:f.val();c="?"===d?s:""===d?null:h(z?z:D,d,a[d])}g.$setViewValue(c);r()})});g.$render=r;e.$watchCollection(O,l);e.$watchCollection(function(){var a=O(e),c;if(a&&w(a)){c=Array(a.length);for(var d=0,f=a.length;d<f;d++)c[d]=h(B,d,a[d])}else if(a)for(d in c={},a)a.hasOwnProperty(d)&&(c[d]=h(B,d,a[d]));return c},l);u&&e.$watchCollection(function(){return g.$modelValue},l)}if(l[1]){var n=l[0];l=l[1];v [...]
+v=h.ngOptions,x=!1,t,p=!1,C=z(V.createElement("option")),F=z(V.createElement("optgroup")),B=C.clone();h=0;for(var A=g.children(),E=A.length;h<E;h++)if(""===A[h].value){t=x=A.eq(h);break}n.init(l,x,B);u&&(l.$isEmpty=function(a){return!a||0===a.length});v?r(e,g,l):u?m(e,g,l):k(e,g,l,n)}}}}],ce=["$interpolate",function(a){var c={addOption:A,removeOption:A};return{restrict:"E",priority:100,compile:function(d,e){if(C(e.value)){var f=a(d.text(),!0);f||e.$set("value",d.text())}return function(a [...]
+d.parent(),m=k.data("$selectController")||k.parent().data("$selectController");m&&m.databound||(m=c);f?a.$watch(f,function(a,c){e.$set("value",a);c!==a&&m.removeOption(c);m.addOption(a,d)}):m.addOption(e.value,d);d.on("$destroy",function(){m.removeOption(e.value)})}}}}],be=ca({restrict:"E",terminal:!1}),Ec=function(){return{restrict:"A",require:"?ngModel",link:function(a,c,d,e){e&&(d.required=!0,e.$validators.required=function(a,c){return!d.required||!e.$isEmpty(c)},d.$observe("required" [...]
+Dc=function(){return{restrict:"A",require:"?ngModel",link:function(a,c,d,e){if(e){var f,g=d.ngPattern||d.pattern;d.$observe("pattern",function(a){O(a)&&0<a.length&&(a=new RegExp("^"+a+"$"));if(a&&!a.test)throw F("ngPattern")("noregexp",g,a,ta(c));f=a||s;e.$validate()});e.$validators.pattern=function(a){return e.$isEmpty(a)||C(f)||f.test(a)}}}}},Gc=function(){return{restrict:"A",require:"?ngModel",link:function(a,c,d,e){if(e){var f=-1;d.$observe("maxlength",function(a){a=aa(a);f=isNaN(a)? [...]
+e.$validators.maxlength=function(a,c){return 0>f||e.$isEmpty(c)||c.length<=f}}}}},Fc=function(){return{restrict:"A",require:"?ngModel",link:function(a,c,d,e){if(e){var f=0;d.$observe("minlength",function(a){f=aa(a)||0;e.$validate()});e.$validators.minlength=function(a,c){return e.$isEmpty(c)||c.length>=f}}}}};T.angular.bootstrap?console.log("WARNING: Tried to load angular more than once."):(Sd(),Ud(ba),z(V).ready(function(){Od(V,xc)}))})(window,document);!window.angular.$$csp()&&window.a [...]
+//# sourceMappingURL=angular.min.js.map
diff --git a/guacamole/src/main/webapp/lib/blob/LICENSE.md b/guacamole/src/main/webapp/lib/blob/LICENSE.md
new file mode 100644
index 0000000..11520c9
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/blob/LICENSE.md
@@ -0,0 +1,25 @@
+Copyright © 2014 [Eli Grey][1].
+
+Permission is hereby granted, free of charge, to any person
+obtaining a copy of this software and associated documentation
+files (the "Software"), to deal in the Software without
+restriction, including without limitation the rights to use,
+copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the
+Software is furnished to do so, subject to the following
+conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+OTHER DEALINGS IN THE SOFTWARE.
+
+
+  [1]: http://eligrey.com
diff --git a/guacamole/src/main/webapp/lib/blob/blob.js b/guacamole/src/main/webapp/lib/blob/blob.js
new file mode 100644
index 0000000..3b44c65
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/blob/blob.js
@@ -0,0 +1,197 @@
+/* Blob.js
+ * A Blob implementation.
+ * 2014-07-24
+ *
+ * By Eli Grey, http://eligrey.com
+ * By Devin Samarin, https://github.com/dsamarin
+ * License: X11/MIT
+ *   See https://github.com/eligrey/Blob.js/blob/master/LICENSE.md
+ */
+
+/*global self, unescape */
+/*jslint bitwise: true, regexp: true, confusion: true, es5: true, vars: true, white: true,
+  plusplus: true */
+
+/*! @source http://purl.eligrey.com/github/Blob.js/blob/master/Blob.js */
+
+(function (view) {
+	"use strict";
+
+	view.URL = view.URL || view.webkitURL;
+
+	if (view.Blob && view.URL) {
+		try {
+			new Blob;
+			return;
+		} catch (e) {}
+	}
+
+	// Internally we use a BlobBuilder implementation to base Blob off of
+	// in order to support older browsers that only have BlobBuilder
+	var BlobBuilder = view.BlobBuilder || view.WebKitBlobBuilder || view.MozBlobBuilder || (function(view) {
+		var
+			  get_class = function(object) {
+				return Object.prototype.toString.call(object).match(/^\[object\s(.*)\]$/)[1];
+			}
+			, FakeBlobBuilder = function BlobBuilder() {
+				this.data = [];
+			}
+			, FakeBlob = function Blob(data, type, encoding) {
+				this.data = data;
+				this.size = data.length;
+				this.type = type;
+				this.encoding = encoding;
+			}
+			, FBB_proto = FakeBlobBuilder.prototype
+			, FB_proto = FakeBlob.prototype
+			, FileReaderSync = view.FileReaderSync
+			, FileException = function(type) {
+				this.code = this[this.name = type];
+			}
+			, file_ex_codes = (
+				  "NOT_FOUND_ERR SECURITY_ERR ABORT_ERR NOT_READABLE_ERR ENCODING_ERR "
+				+ "NO_MODIFICATION_ALLOWED_ERR INVALID_STATE_ERR SYNTAX_ERR"
+			).split(" ")
+			, file_ex_code = file_ex_codes.length
+			, real_URL = view.URL || view.webkitURL || view
+			, real_create_object_URL = real_URL.createObjectURL
+			, real_revoke_object_URL = real_URL.revokeObjectURL
+			, URL = real_URL
+			, btoa = view.btoa
+			, atob = view.atob
+
+			, ArrayBuffer = view.ArrayBuffer
+			, Uint8Array = view.Uint8Array
+
+			, origin = /^[\w-]+:\/*\[?[\w\.:-]+\]?(?::[0-9]+)?/
+		;
+		FakeBlob.fake = FB_proto.fake = true;
+		while (file_ex_code--) {
+			FileException.prototype[file_ex_codes[file_ex_code]] = file_ex_code + 1;
+		}
+		// Polyfill URL
+		if (!real_URL.createObjectURL) {
+			URL = view.URL = function(uri) {
+				var
+					  uri_info = document.createElementNS("http://www.w3.org/1999/xhtml", "a")
+					, uri_origin
+				;
+				uri_info.href = uri;
+				if (!("origin" in uri_info)) {
+					if (uri_info.protocol.toLowerCase() === "data:") {
+						uri_info.origin = null;
+					} else {
+						uri_origin = uri.match(origin);
+						uri_info.origin = uri_origin && uri_origin[1];
+					}
+				}
+				return uri_info;
+			};
+		}
+		URL.createObjectURL = function(blob) {
+			var
+				  type = blob.type
+				, data_URI_header
+			;
+			if (type === null) {
+				type = "application/octet-stream";
+			}
+			if (blob instanceof FakeBlob) {
+				data_URI_header = "data:" + type;
+				if (blob.encoding === "base64") {
+					return data_URI_header + ";base64," + blob.data;
+				} else if (blob.encoding === "URI") {
+					return data_URI_header + "," + decodeURIComponent(blob.data);
+				} if (btoa) {
+					return data_URI_header + ";base64," + btoa(blob.data);
+				} else {
+					return data_URI_header + "," + encodeURIComponent(blob.data);
+				}
+			} else if (real_create_object_URL) {
+				return real_create_object_URL.call(real_URL, blob);
+			}
+		};
+		URL.revokeObjectURL = function(object_URL) {
+			if (object_URL.substring(0, 5) !== "data:" && real_revoke_object_URL) {
+				real_revoke_object_URL.call(real_URL, object_URL);
+			}
+		};
+		FBB_proto.append = function(data/*, endings*/) {
+			var bb = this.data;
+			// decode data to a binary string
+			if (Uint8Array && (data instanceof ArrayBuffer || data instanceof Uint8Array)) {
+				var
+					  str = ""
+					, buf = new Uint8Array(data)
+					, i = 0
+					, buf_len = buf.length
+				;
+				for (; i < buf_len; i++) {
+					str += String.fromCharCode(buf[i]);
+				}
+				bb.push(str);
+			} else if (get_class(data) === "Blob" || get_class(data) === "File") {
+				if (FileReaderSync) {
+					var fr = new FileReaderSync;
+					bb.push(fr.readAsBinaryString(data));
+				} else {
+					// async FileReader won't work as BlobBuilder is sync
+					throw new FileException("NOT_READABLE_ERR");
+				}
+			} else if (data instanceof FakeBlob) {
+				if (data.encoding === "base64" && atob) {
+					bb.push(atob(data.data));
+				} else if (data.encoding === "URI") {
+					bb.push(decodeURIComponent(data.data));
+				} else if (data.encoding === "raw") {
+					bb.push(data.data);
+				}
+			} else {
+				if (typeof data !== "string") {
+					data += ""; // convert unsupported types to strings
+				}
+				// decode UTF-16 to binary string
+				bb.push(unescape(encodeURIComponent(data)));
+			}
+		};
+		FBB_proto.getBlob = function(type) {
+			if (!arguments.length) {
+				type = null;
+			}
+			return new FakeBlob(this.data.join(""), type, "raw");
+		};
+		FBB_proto.toString = function() {
+			return "[object BlobBuilder]";
+		};
+		FB_proto.slice = function(start, end, type) {
+			var args = arguments.length;
+			if (args < 3) {
+				type = null;
+			}
+			return new FakeBlob(
+				  this.data.slice(start, args > 1 ? end : this.data.length)
+				, type
+				, this.encoding
+			);
+		};
+		FB_proto.toString = function() {
+			return "[object Blob]";
+		};
+		FB_proto.close = function() {
+			this.size = 0;
+			delete this.data;
+		};
+		return FakeBlobBuilder;
+	}(view));
+
+	view.Blob = function(blobParts, options) {
+		var type = options ? (options.type || "") : "";
+		var builder = new BlobBuilder();
+		if (blobParts) {
+			for (var i = 0, len = blobParts.length; i < len; i++) {
+				builder.append(blobParts[i]);
+			}
+		}
+		return builder.getBlob(type);
+	};
+}(typeof self !== "undefined" && self || typeof window !== "undefined" && window || this.content || this));
diff --git a/guacamole/src/main/webapp/scripts/lib/blob/LICENSE.md b/guacamole/src/main/webapp/lib/filesaver/LICENSE.md
similarity index 100%
rename from guacamole/src/main/webapp/scripts/lib/blob/LICENSE.md
rename to guacamole/src/main/webapp/lib/filesaver/LICENSE.md
diff --git a/guacamole/src/main/webapp/scripts/lib/filesaver/filesaver.js b/guacamole/src/main/webapp/lib/filesaver/filesaver.js
similarity index 100%
rename from guacamole/src/main/webapp/scripts/lib/filesaver/filesaver.js
rename to guacamole/src/main/webapp/lib/filesaver/filesaver.js
diff --git a/guacamole/src/main/webapp/lib/jquery/MIT-LICENSE.txt b/guacamole/src/main/webapp/lib/jquery/MIT-LICENSE.txt
new file mode 100644
index 0000000..cdd31b5
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/jquery/MIT-LICENSE.txt
@@ -0,0 +1,21 @@
+Copyright 2014 jQuery Foundation and other contributors
+http://jquery.com/
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/guacamole/src/main/webapp/lib/jquery/jquery.js b/guacamole/src/main/webapp/lib/jquery/jquery.js
new file mode 100644
index 0000000..ac29c4d
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/jquery/jquery.js
@@ -0,0 +1,9205 @@
+/*!
+ * jQuery JavaScript Library v2.1.3
+ * http://jquery.com/
+ *
+ * Includes Sizzle.js
+ * http://sizzlejs.com/
+ *
+ * Copyright 2005, 2014 jQuery Foundation, Inc. and other contributors
+ * Released under the MIT license
+ * http://jquery.org/license
+ *
+ * Date: 2014-12-18T15:11Z
+ */
+
+(function( global, factory ) {
+
+	if ( typeof module === "object" && typeof module.exports === "object" ) {
+		// For CommonJS and CommonJS-like environments where a proper `window`
+		// is present, execute the factory and get jQuery.
+		// For environments that do not have a `window` with a `document`
+		// (such as Node.js), expose a factory as module.exports.
+		// This accentuates the need for the creation of a real `window`.
+		// e.g. var jQuery = require("jquery")(window);
+		// See ticket #14549 for more info.
+		module.exports = global.document ?
+			factory( global, true ) :
+			function( w ) {
+				if ( !w.document ) {
+					throw new Error( "jQuery requires a window with a document" );
+				}
+				return factory( w );
+			};
+	} else {
+		factory( global );
+	}
+
+// Pass this if window is not defined yet
+}(typeof window !== "undefined" ? window : this, function( window, noGlobal ) {
+
+// Support: Firefox 18+
+// Can't be in strict mode, several libs including ASP.NET trace
+// the stack via arguments.caller.callee and Firefox dies if
+// you try to trace through "use strict" call chains. (#13335)
+//
+
+var arr = [];
+
+var slice = arr.slice;
+
+var concat = arr.concat;
+
+var push = arr.push;
+
+var indexOf = arr.indexOf;
+
+var class2type = {};
+
+var toString = class2type.toString;
+
+var hasOwn = class2type.hasOwnProperty;
+
+var support = {};
+
+
+
+var
+	// Use the correct document accordingly with window argument (sandbox)
+	document = window.document,
+
+	version = "2.1.3",
+
+	// Define a local copy of jQuery
+	jQuery = function( selector, context ) {
+		// The jQuery object is actually just the init constructor 'enhanced'
+		// Need init if jQuery is called (just allow error to be thrown if not included)
+		return new jQuery.fn.init( selector, context );
+	},
+
+	// Support: Android<4.1
+	// Make sure we trim BOM and NBSP
+	rtrim = /^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g,
+
+	// Matches dashed string for camelizing
+	rmsPrefix = /^-ms-/,
+	rdashAlpha = /-([\da-z])/gi,
+
+	// Used by jQuery.camelCase as callback to replace()
+	fcamelCase = function( all, letter ) {
+		return letter.toUpperCase();
+	};
+
+jQuery.fn = jQuery.prototype = {
+	// The current version of jQuery being used
+	jquery: version,
+
+	constructor: jQuery,
+
+	// Start with an empty selector
+	selector: "",
+
+	// The default length of a jQuery object is 0
+	length: 0,
+
+	toArray: function() {
+		return slice.call( this );
+	},
+
+	// Get the Nth element in the matched element set OR
+	// Get the whole matched element set as a clean array
+	get: function( num ) {
+		return num != null ?
+
+			// Return just the one element from the set
+			( num < 0 ? this[ num + this.length ] : this[ num ] ) :
+
+			// Return all the elements in a clean array
+			slice.call( this );
+	},
+
+	// Take an array of elements and push it onto the stack
+	// (returning the new matched element set)
+	pushStack: function( elems ) {
+
+		// Build a new jQuery matched element set
+		var ret = jQuery.merge( this.constructor(), elems );
+
+		// Add the old object onto the stack (as a reference)
+		ret.prevObject = this;
+		ret.context = this.context;
+
+		// Return the newly-formed element set
+		return ret;
+	},
+
+	// Execute a callback for every element in the matched set.
+	// (You can seed the arguments with an array of args, but this is
+	// only used internally.)
+	each: function( callback, args ) {
+		return jQuery.each( this, callback, args );
+	},
+
+	map: function( callback ) {
+		return this.pushStack( jQuery.map(this, function( elem, i ) {
+			return callback.call( elem, i, elem );
+		}));
+	},
+
+	slice: function() {
+		return this.pushStack( slice.apply( this, arguments ) );
+	},
+
+	first: function() {
+		return this.eq( 0 );
+	},
+
+	last: function() {
+		return this.eq( -1 );
+	},
+
+	eq: function( i ) {
+		var len = this.length,
+			j = +i + ( i < 0 ? len : 0 );
+		return this.pushStack( j >= 0 && j < len ? [ this[j] ] : [] );
+	},
+
+	end: function() {
+		return this.prevObject || this.constructor(null);
+	},
+
+	// For internal use only.
+	// Behaves like an Array's method, not like a jQuery method.
+	push: push,
+	sort: arr.sort,
+	splice: arr.splice
+};
+
+jQuery.extend = jQuery.fn.extend = function() {
+	var options, name, src, copy, copyIsArray, clone,
+		target = arguments[0] || {},
+		i = 1,
+		length = arguments.length,
+		deep = false;
+
+	// Handle a deep copy situation
+	if ( typeof target === "boolean" ) {
+		deep = target;
+
+		// Skip the boolean and the target
+		target = arguments[ i ] || {};
+		i++;
+	}
+
+	// Handle case when target is a string or something (possible in deep copy)
+	if ( typeof target !== "object" && !jQuery.isFunction(target) ) {
+		target = {};
+	}
+
+	// Extend jQuery itself if only one argument is passed
+	if ( i === length ) {
+		target = this;
+		i--;
+	}
+
+	for ( ; i < length; i++ ) {
+		// Only deal with non-null/undefined values
+		if ( (options = arguments[ i ]) != null ) {
+			// Extend the base object
+			for ( name in options ) {
+				src = target[ name ];
+				copy = options[ name ];
+
+				// Prevent never-ending loop
+				if ( target === copy ) {
+					continue;
+				}
+
+				// Recurse if we're merging plain objects or arrays
+				if ( deep && copy && ( jQuery.isPlainObject(copy) || (copyIsArray = jQuery.isArray(copy)) ) ) {
+					if ( copyIsArray ) {
+						copyIsArray = false;
+						clone = src && jQuery.isArray(src) ? src : [];
+
+					} else {
+						clone = src && jQuery.isPlainObject(src) ? src : {};
+					}
+
+					// Never move original objects, clone them
+					target[ name ] = jQuery.extend( deep, clone, copy );
+
+				// Don't bring in undefined values
+				} else if ( copy !== undefined ) {
+					target[ name ] = copy;
+				}
+			}
+		}
+	}
+
+	// Return the modified object
+	return target;
+};
+
+jQuery.extend({
+	// Unique for each copy of jQuery on the page
+	expando: "jQuery" + ( version + Math.random() ).replace( /\D/g, "" ),
+
+	// Assume jQuery is ready without the ready module
+	isReady: true,
+
+	error: function( msg ) {
+		throw new Error( msg );
+	},
+
+	noop: function() {},
+
+	isFunction: function( obj ) {
+		return jQuery.type(obj) === "function";
+	},
+
+	isArray: Array.isArray,
+
+	isWindow: function( obj ) {
+		return obj != null && obj === obj.window;
+	},
+
+	isNumeric: function( obj ) {
+		// parseFloat NaNs numeric-cast false positives (null|true|false|"")
+		// ...but misinterprets leading-number strings, particularly hex literals ("0x...")
+		// subtraction forces infinities to NaN
+		// adding 1 corrects loss of precision from parseFloat (#15100)
+		return !jQuery.isArray( obj ) && (obj - parseFloat( obj ) + 1) >= 0;
+	},
+
+	isPlainObject: function( obj ) {
+		// Not plain objects:
+		// - Any object or value whose internal [[Class]] property is not "[object Object]"
+		// - DOM nodes
+		// - window
+		if ( jQuery.type( obj ) !== "object" || obj.nodeType || jQuery.isWindow( obj ) ) {
+			return false;
+		}
+
+		if ( obj.constructor &&
+				!hasOwn.call( obj.constructor.prototype, "isPrototypeOf" ) ) {
+			return false;
+		}
+
+		// If the function hasn't returned already, we're confident that
+		// |obj| is a plain object, created by {} or constructed with new Object
+		return true;
+	},
+
+	isEmptyObject: function( obj ) {
+		var name;
+		for ( name in obj ) {
+			return false;
+		}
+		return true;
+	},
+
+	type: function( obj ) {
+		if ( obj == null ) {
+			return obj + "";
+		}
+		// Support: Android<4.0, iOS<6 (functionish RegExp)
+		return typeof obj === "object" || typeof obj === "function" ?
+			class2type[ toString.call(obj) ] || "object" :
+			typeof obj;
+	},
+
+	// Evaluates a script in a global context
+	globalEval: function( code ) {
+		var script,
+			indirect = eval;
+
+		code = jQuery.trim( code );
+
+		if ( code ) {
+			// If the code includes a valid, prologue position
+			// strict mode pragma, execute code by injecting a
+			// script tag into the document.
+			if ( code.indexOf("use strict") === 1 ) {
+				script = document.createElement("script");
+				script.text = code;
+				document.head.appendChild( script ).parentNode.removeChild( script );
+			} else {
+			// Otherwise, avoid the DOM node creation, insertion
+			// and removal by using an indirect global eval
+				indirect( code );
+			}
+		}
+	},
+
+	// Convert dashed to camelCase; used by the css and data modules
+	// Support: IE9-11+
+	// Microsoft forgot to hump their vendor prefix (#9572)
+	camelCase: function( string ) {
+		return string.replace( rmsPrefix, "ms-" ).replace( rdashAlpha, fcamelCase );
+	},
+
+	nodeName: function( elem, name ) {
+		return elem.nodeName && elem.nodeName.toLowerCase() === name.toLowerCase();
+	},
+
+	// args is for internal usage only
+	each: function( obj, callback, args ) {
+		var value,
+			i = 0,
+			length = obj.length,
+			isArray = isArraylike( obj );
+
+		if ( args ) {
+			if ( isArray ) {
+				for ( ; i < length; i++ ) {
+					value = callback.apply( obj[ i ], args );
+
+					if ( value === false ) {
+						break;
+					}
+				}
+			} else {
+				for ( i in obj ) {
+					value = callback.apply( obj[ i ], args );
+
+					if ( value === false ) {
+						break;
+					}
+				}
+			}
+
+		// A special, fast, case for the most common use of each
+		} else {
+			if ( isArray ) {
+				for ( ; i < length; i++ ) {
+					value = callback.call( obj[ i ], i, obj[ i ] );
+
+					if ( value === false ) {
+						break;
+					}
+				}
+			} else {
+				for ( i in obj ) {
+					value = callback.call( obj[ i ], i, obj[ i ] );
+
+					if ( value === false ) {
+						break;
+					}
+				}
+			}
+		}
+
+		return obj;
+	},
+
+	// Support: Android<4.1
+	trim: function( text ) {
+		return text == null ?
+			"" :
+			( text + "" ).replace( rtrim, "" );
+	},
+
+	// results is for internal usage only
+	makeArray: function( arr, results ) {
+		var ret = results || [];
+
+		if ( arr != null ) {
+			if ( isArraylike( Object(arr) ) ) {
+				jQuery.merge( ret,
+					typeof arr === "string" ?
+					[ arr ] : arr
+				);
+			} else {
+				push.call( ret, arr );
+			}
+		}
+
+		return ret;
+	},
+
+	inArray: function( elem, arr, i ) {
+		return arr == null ? -1 : indexOf.call( arr, elem, i );
+	},
+
+	merge: function( first, second ) {
+		var len = +second.length,
+			j = 0,
+			i = first.length;
+
+		for ( ; j < len; j++ ) {
+			first[ i++ ] = second[ j ];
+		}
+
+		first.length = i;
+
+		return first;
+	},
+
+	grep: function( elems, callback, invert ) {
+		var callbackInverse,
+			matches = [],
+			i = 0,
+			length = elems.length,
+			callbackExpect = !invert;
+
+		// Go through the array, only saving the items
+		// that pass the validator function
+		for ( ; i < length; i++ ) {
+			callbackInverse = !callback( elems[ i ], i );
+			if ( callbackInverse !== callbackExpect ) {
+				matches.push( elems[ i ] );
+			}
+		}
+
+		return matches;
+	},
+
+	// arg is for internal usage only
+	map: function( elems, callback, arg ) {
+		var value,
+			i = 0,
+			length = elems.length,
+			isArray = isArraylike( elems ),
+			ret = [];
+
+		// Go through the array, translating each of the items to their new values
+		if ( isArray ) {
+			for ( ; i < length; i++ ) {
+				value = callback( elems[ i ], i, arg );
+
+				if ( value != null ) {
+					ret.push( value );
+				}
+			}
+
+		// Go through every key on the object,
+		} else {
+			for ( i in elems ) {
+				value = callback( elems[ i ], i, arg );
+
+				if ( value != null ) {
+					ret.push( value );
+				}
+			}
+		}
+
+		// Flatten any nested arrays
+		return concat.apply( [], ret );
+	},
+
+	// A global GUID counter for objects
+	guid: 1,
+
+	// Bind a function to a context, optionally partially applying any
+	// arguments.
+	proxy: function( fn, context ) {
+		var tmp, args, proxy;
+
+		if ( typeof context === "string" ) {
+			tmp = fn[ context ];
+			context = fn;
+			fn = tmp;
+		}
+
+		// Quick check to determine if target is callable, in the spec
+		// this throws a TypeError, but we will just return undefined.
+		if ( !jQuery.isFunction( fn ) ) {
+			return undefined;
+		}
+
+		// Simulated bind
+		args = slice.call( arguments, 2 );
+		proxy = function() {
+			return fn.apply( context || this, args.concat( slice.call( arguments ) ) );
+		};
+
+		// Set the guid of unique handler to the same of original handler, so it can be removed
+		proxy.guid = fn.guid = fn.guid || jQuery.guid++;
+
+		return proxy;
+	},
+
+	now: Date.now,
+
+	// jQuery.support is not used in Core but other projects attach their
+	// properties to it so it needs to exist.
+	support: support
+});
+
+// Populate the class2type map
+jQuery.each("Boolean Number String Function Array Date RegExp Object Error".split(" "), function(i, name) {
+	class2type[ "[object " + name + "]" ] = name.toLowerCase();
+});
+
+function isArraylike( obj ) {
+	var length = obj.length,
+		type = jQuery.type( obj );
+
+	if ( type === "function" || jQuery.isWindow( obj ) ) {
+		return false;
+	}
+
+	if ( obj.nodeType === 1 && length ) {
+		return true;
+	}
+
+	return type === "array" || length === 0 ||
+		typeof length === "number" && length > 0 && ( length - 1 ) in obj;
+}
+var Sizzle =
+/*!
+ * Sizzle CSS Selector Engine v2.2.0-pre
+ * http://sizzlejs.com/
+ *
+ * Copyright 2008, 2014 jQuery Foundation, Inc. and other contributors
+ * Released under the MIT license
+ * http://jquery.org/license
+ *
+ * Date: 2014-12-16
+ */
+(function( window ) {
+
+var i,
+	support,
+	Expr,
+	getText,
+	isXML,
+	tokenize,
+	compile,
+	select,
+	outermostContext,
+	sortInput,
+	hasDuplicate,
+
+	// Local document vars
+	setDocument,
+	document,
+	docElem,
+	documentIsHTML,
+	rbuggyQSA,
+	rbuggyMatches,
+	matches,
+	contains,
+
+	// Instance-specific data
+	expando = "sizzle" + 1 * new Date(),
+	preferredDoc = window.document,
+	dirruns = 0,
+	done = 0,
+	classCache = createCache(),
+	tokenCache = createCache(),
+	compilerCache = createCache(),
+	sortOrder = function( a, b ) {
+		if ( a === b ) {
+			hasDuplicate = true;
+		}
+		return 0;
+	},
+
+	// General-purpose constants
+	MAX_NEGATIVE = 1 << 31,
+
+	// Instance methods
+	hasOwn = ({}).hasOwnProperty,
+	arr = [],
+	pop = arr.pop,
+	push_native = arr.push,
+	push = arr.push,
+	slice = arr.slice,
+	// Use a stripped-down indexOf as it's faster than native
+	// http://jsperf.com/thor-indexof-vs-for/5
+	indexOf = function( list, elem ) {
+		var i = 0,
+			len = list.length;
+		for ( ; i < len; i++ ) {
+			if ( list[i] === elem ) {
+				return i;
+			}
+		}
+		return -1;
+	},
+
+	booleans = "checked|selected|async|autofocus|autoplay|controls|defer|disabled|hidden|ismap|loop|multiple|open|readonly|required|scoped",
+
+	// Regular expressions
+
+	// Whitespace characters http://www.w3.org/TR/css3-selectors/#whitespace
+	whitespace = "[\\x20\\t\\r\\n\\f]",
+	// http://www.w3.org/TR/css3-syntax/#characters
+	characterEncoding = "(?:\\\\.|[\\w-]|[^\\x00-\\xa0])+",
+
+	// Loosely modeled on CSS identifier characters
+	// An unquoted value should be a CSS identifier http://www.w3.org/TR/css3-selectors/#attribute-selectors
+	// Proper syntax: http://www.w3.org/TR/CSS21/syndata.html#value-def-identifier
+	identifier = characterEncoding.replace( "w", "w#" ),
+
+	// Attribute selectors: http://www.w3.org/TR/selectors/#attribute-selectors
+	attributes = "\\[" + whitespace + "*(" + characterEncoding + ")(?:" + whitespace +
+		// Operator (capture 2)
+		"*([*^$|!~]?=)" + whitespace +
+		// "Attribute values must be CSS identifiers [capture 5] or strings [capture 3 or capture 4]"
+		"*(?:'((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\"|(" + identifier + "))|)" + whitespace +
+		"*\\]",
+
+	pseudos = ":(" + characterEncoding + ")(?:\\((" +
+		// To reduce the number of selectors needing tokenize in the preFilter, prefer arguments:
+		// 1. quoted (capture 3; capture 4 or capture 5)
+		"('((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\")|" +
+		// 2. simple (capture 6)
+		"((?:\\\\.|[^\\\\()[\\]]|" + attributes + ")*)|" +
+		// 3. anything else (capture 2)
+		".*" +
+		")\\)|)",
+
+	// Leading and non-escaped trailing whitespace, capturing some non-whitespace characters preceding the latter
+	rwhitespace = new RegExp( whitespace + "+", "g" ),
+	rtrim = new RegExp( "^" + whitespace + "+|((?:^|[^\\\\])(?:\\\\.)*)" + whitespace + "+$", "g" ),
+
+	rcomma = new RegExp( "^" + whitespace + "*," + whitespace + "*" ),
+	rcombinators = new RegExp( "^" + whitespace + "*([>+~]|" + whitespace + ")" + whitespace + "*" ),
+
+	rattributeQuotes = new RegExp( "=" + whitespace + "*([^\\]'\"]*?)" + whitespace + "*\\]", "g" ),
+
+	rpseudo = new RegExp( pseudos ),
+	ridentifier = new RegExp( "^" + identifier + "$" ),
+
+	matchExpr = {
+		"ID": new RegExp( "^#(" + characterEncoding + ")" ),
+		"CLASS": new RegExp( "^\\.(" + characterEncoding + ")" ),
+		"TAG": new RegExp( "^(" + characterEncoding.replace( "w", "w*" ) + ")" ),
+		"ATTR": new RegExp( "^" + attributes ),
+		"PSEUDO": new RegExp( "^" + pseudos ),
+		"CHILD": new RegExp( "^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\(" + whitespace +
+			"*(even|odd|(([+-]|)(\\d*)n|)" + whitespace + "*(?:([+-]|)" + whitespace +
+			"*(\\d+)|))" + whitespace + "*\\)|)", "i" ),
+		"bool": new RegExp( "^(?:" + booleans + ")$", "i" ),
+		// For use in libraries implementing .is()
+		// We use this for POS matching in `select`
+		"needsContext": new RegExp( "^" + whitespace + "*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\(" +
+			whitespace + "*((?:-\\d)?\\d*)" + whitespace + "*\\)|)(?=[^-]|$)", "i" )
+	},
+
+	rinputs = /^(?:input|select|textarea|button)$/i,
+	rheader = /^h\d$/i,
+
+	rnative = /^[^{]+\{\s*\[native \w/,
+
+	// Easily-parseable/retrievable ID or TAG or CLASS selectors
+	rquickExpr = /^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,
+
+	rsibling = /[+~]/,
+	rescape = /'|\\/g,
+
+	// CSS escapes http://www.w3.org/TR/CSS21/syndata.html#escaped-characters
+	runescape = new RegExp( "\\\\([\\da-f]{1,6}" + whitespace + "?|(" + whitespace + ")|.)", "ig" ),
+	funescape = function( _, escaped, escapedWhitespace ) {
+		var high = "0x" + escaped - 0x10000;
+		// NaN means non-codepoint
+		// Support: Firefox<24
+		// Workaround erroneous numeric interpretation of +"0x"
+		return high !== high || escapedWhitespace ?
+			escaped :
+			high < 0 ?
+				// BMP codepoint
+				String.fromCharCode( high + 0x10000 ) :
+				// Supplemental Plane codepoint (surrogate pair)
+				String.fromCharCode( high >> 10 | 0xD800, high & 0x3FF | 0xDC00 );
+	},
+
+	// Used for iframes
+	// See setDocument()
+	// Removing the function wrapper causes a "Permission Denied"
+	// error in IE
+	unloadHandler = function() {
+		setDocument();
+	};
+
+// Optimize for push.apply( _, NodeList )
+try {
+	push.apply(
+		(arr = slice.call( preferredDoc.childNodes )),
+		preferredDoc.childNodes
+	);
+	// Support: Android<4.0
+	// Detect silently failing push.apply
+	arr[ preferredDoc.childNodes.length ].nodeType;
+} catch ( e ) {
+	push = { apply: arr.length ?
+
+		// Leverage slice if possible
+		function( target, els ) {
+			push_native.apply( target, slice.call(els) );
+		} :
+
+		// Support: IE<9
+		// Otherwise append directly
+		function( target, els ) {
+			var j = target.length,
+				i = 0;
+			// Can't trust NodeList.length
+			while ( (target[j++] = els[i++]) ) {}
+			target.length = j - 1;
+		}
+	};
+}
+
+function Sizzle( selector, context, results, seed ) {
+	var match, elem, m, nodeType,
+		// QSA vars
+		i, groups, old, nid, newContext, newSelector;
+
+	if ( ( context ? context.ownerDocument || context : preferredDoc ) !== document ) {
+		setDocument( context );
+	}
+
+	context = context || document;
+	results = results || [];
+	nodeType = context.nodeType;
+
+	if ( typeof selector !== "string" || !selector ||
+		nodeType !== 1 && nodeType !== 9 && nodeType !== 11 ) {
+
+		return results;
+	}
+
+	if ( !seed && documentIsHTML ) {
+
+		// Try to shortcut find operations when possible (e.g., not under DocumentFragment)
+		if ( nodeType !== 11 && (match = rquickExpr.exec( selector )) ) {
+			// Speed-up: Sizzle("#ID")
+			if ( (m = match[1]) ) {
+				if ( nodeType === 9 ) {
+					elem = context.getElementById( m );
+					// Check parentNode to catch when Blackberry 4.6 returns
+					// nodes that are no longer in the document (jQuery #6963)
+					if ( elem && elem.parentNode ) {
+						// Handle the case where IE, Opera, and Webkit return items
+						// by name instead of ID
+						if ( elem.id === m ) {
+							results.push( elem );
+							return results;
+						}
+					} else {
+						return results;
+					}
+				} else {
+					// Context is not a document
+					if ( context.ownerDocument && (elem = context.ownerDocument.getElementById( m )) &&
+						contains( context, elem ) && elem.id === m ) {
+						results.push( elem );
+						return results;
+					}
+				}
+
+			// Speed-up: Sizzle("TAG")
+			} else if ( match[2] ) {
+				push.apply( results, context.getElementsByTagName( selector ) );
+				return results;
+
+			// Speed-up: Sizzle(".CLASS")
+			} else if ( (m = match[3]) && support.getElementsByClassName ) {
+				push.apply( results, context.getElementsByClassName( m ) );
+				return results;
+			}
+		}
+
+		// QSA path
+		if ( support.qsa && (!rbuggyQSA || !rbuggyQSA.test( selector )) ) {
+			nid = old = expando;
+			newContext = context;
+			newSelector = nodeType !== 1 && selector;
+
+			// qSA works strangely on Element-rooted queries
+			// We can work around this by specifying an extra ID on the root
+			// and working up from there (Thanks to Andrew Dupont for the technique)
+			// IE 8 doesn't work on object elements
+			if ( nodeType === 1 && context.nodeName.toLowerCase() !== "object" ) {
+				groups = tokenize( selector );
+
+				if ( (old = context.getAttribute("id")) ) {
+					nid = old.replace( rescape, "\\$&" );
+				} else {
+					context.setAttribute( "id", nid );
+				}
+				nid = "[id='" + nid + "'] ";
+
+				i = groups.length;
+				while ( i-- ) {
+					groups[i] = nid + toSelector( groups[i] );
+				}
+				newContext = rsibling.test( selector ) && testContext( context.parentNode ) || context;
+				newSelector = groups.join(",");
+			}
+
+			if ( newSelector ) {
+				try {
+					push.apply( results,
+						newContext.querySelectorAll( newSelector )
+					);
+					return results;
+				} catch(qsaError) {
+				} finally {
+					if ( !old ) {
+						context.removeAttribute("id");
+					}
+				}
+			}
+		}
+	}
+
+	// All others
+	return select( selector.replace( rtrim, "$1" ), context, results, seed );
+}
+
+/**
+ * Create key-value caches of limited size
+ * @returns {Function(string, Object)} Returns the Object data after storing it on itself with
+ *	property name the (space-suffixed) string and (if the cache is larger than Expr.cacheLength)
+ *	deleting the oldest entry
+ */
+function createCache() {
+	var keys = [];
+
+	function cache( key, value ) {
+		// Use (key + " ") to avoid collision with native prototype properties (see Issue #157)
+		if ( keys.push( key + " " ) > Expr.cacheLength ) {
+			// Only keep the most recent entries
+			delete cache[ keys.shift() ];
+		}
+		return (cache[ key + " " ] = value);
+	}
+	return cache;
+}
+
+/**
+ * Mark a function for special use by Sizzle
+ * @param {Function} fn The function to mark
+ */
+function markFunction( fn ) {
+	fn[ expando ] = true;
+	return fn;
+}
+
+/**
+ * Support testing using an element
+ * @param {Function} fn Passed the created div and expects a boolean result
+ */
+function assert( fn ) {
+	var div = document.createElement("div");
+
+	try {
+		return !!fn( div );
+	} catch (e) {
+		return false;
+	} finally {
+		// Remove from its parent by default
+		if ( div.parentNode ) {
+			div.parentNode.removeChild( div );
+		}
+		// release memory in IE
+		div = null;
+	}
+}
+
+/**
+ * Adds the same handler for all of the specified attrs
+ * @param {String} attrs Pipe-separated list of attributes
+ * @param {Function} handler The method that will be applied
+ */
+function addHandle( attrs, handler ) {
+	var arr = attrs.split("|"),
+		i = attrs.length;
+
+	while ( i-- ) {
+		Expr.attrHandle[ arr[i] ] = handler;
+	}
+}
+
+/**
+ * Checks document order of two siblings
+ * @param {Element} a
+ * @param {Element} b
+ * @returns {Number} Returns less than 0 if a precedes b, greater than 0 if a follows b
+ */
+function siblingCheck( a, b ) {
+	var cur = b && a,
+		diff = cur && a.nodeType === 1 && b.nodeType === 1 &&
+			( ~b.sourceIndex || MAX_NEGATIVE ) -
+			( ~a.sourceIndex || MAX_NEGATIVE );
+
+	// Use IE sourceIndex if available on both nodes
+	if ( diff ) {
+		return diff;
+	}
+
+	// Check if b follows a
+	if ( cur ) {
+		while ( (cur = cur.nextSibling) ) {
+			if ( cur === b ) {
+				return -1;
+			}
+		}
+	}
+
+	return a ? 1 : -1;
+}
+
+/**
+ * Returns a function to use in pseudos for input types
+ * @param {String} type
+ */
+function createInputPseudo( type ) {
+	return function( elem ) {
+		var name = elem.nodeName.toLowerCase();
+		return name === "input" && elem.type === type;
+	};
+}
+
+/**
+ * Returns a function to use in pseudos for buttons
+ * @param {String} type
+ */
+function createButtonPseudo( type ) {
+	return function( elem ) {
+		var name = elem.nodeName.toLowerCase();
+		return (name === "input" || name === "button") && elem.type === type;
+	};
+}
+
+/**
+ * Returns a function to use in pseudos for positionals
+ * @param {Function} fn
+ */
+function createPositionalPseudo( fn ) {
+	return markFunction(function( argument ) {
+		argument = +argument;
+		return markFunction(function( seed, matches ) {
+			var j,
+				matchIndexes = fn( [], seed.length, argument ),
+				i = matchIndexes.length;
+
+			// Match elements found at the specified indexes
+			while ( i-- ) {
+				if ( seed[ (j = matchIndexes[i]) ] ) {
+					seed[j] = !(matches[j] = seed[j]);
+				}
+			}
+		});
+	});
+}
+
+/**
+ * Checks a node for validity as a Sizzle context
+ * @param {Element|Object=} context
+ * @returns {Element|Object|Boolean} The input node if acceptable, otherwise a falsy value
+ */
+function testContext( context ) {
+	return context && typeof context.getElementsByTagName !== "undefined" && context;
+}
+
+// Expose support vars for convenience
+support = Sizzle.support = {};
+
+/**
+ * Detects XML nodes
+ * @param {Element|Object} elem An element or a document
+ * @returns {Boolean} True iff elem is a non-HTML XML node
+ */
+isXML = Sizzle.isXML = function( elem ) {
+	// documentElement is verified for cases where it doesn't yet exist
+	// (such as loading iframes in IE - #4833)
+	var documentElement = elem && (elem.ownerDocument || elem).documentElement;
+	return documentElement ? documentElement.nodeName !== "HTML" : false;
+};
+
+/**
+ * Sets document-related variables once based on the current document
+ * @param {Element|Object} [doc] An element or document object to use to set the document
+ * @returns {Object} Returns the current document
+ */
+setDocument = Sizzle.setDocument = function( node ) {
+	var hasCompare, parent,
+		doc = node ? node.ownerDocument || node : preferredDoc;
+
+	// If no document and documentElement is available, return
+	if ( doc === document || doc.nodeType !== 9 || !doc.documentElement ) {
+		return document;
+	}
+
+	// Set our document
+	document = doc;
+	docElem = doc.documentElement;
+	parent = doc.defaultView;
+
+	// Support: IE>8
+	// If iframe document is assigned to "document" variable and if iframe has been reloaded,
+	// IE will throw "permission denied" error when accessing "document" variable, see jQuery #13936
+	// IE6-8 do not support the defaultView property so parent will be undefined
+	if ( parent && parent !== parent.top ) {
+		// IE11 does not have attachEvent, so all must suffer
+		if ( parent.addEventListener ) {
+			parent.addEventListener( "unload", unloadHandler, false );
+		} else if ( parent.attachEvent ) {
+			parent.attachEvent( "onunload", unloadHandler );
+		}
+	}
+
+	/* Support tests
+	---------------------------------------------------------------------- */
+	documentIsHTML = !isXML( doc );
+
+	/* Attributes
+	---------------------------------------------------------------------- */
+
+	// Support: IE<8
+	// Verify that getAttribute really returns attributes and not properties
+	// (excepting IE8 booleans)
+	support.attributes = assert(function( div ) {
+		div.className = "i";
+		return !div.getAttribute("className");
+	});
+
+	/* getElement(s)By*
+	---------------------------------------------------------------------- */
+
+	// Check if getElementsByTagName("*") returns only elements
+	support.getElementsByTagName = assert(function( div ) {
+		div.appendChild( doc.createComment("") );
+		return !div.getElementsByTagName("*").length;
+	});
+
+	// Support: IE<9
+	support.getElementsByClassName = rnative.test( doc.getElementsByClassName );
+
+	// Support: IE<10
+	// Check if getElementById returns elements by name
+	// The broken getElementById methods don't pick up programatically-set names,
+	// so use a roundabout getElementsByName test
+	support.getById = assert(function( div ) {
+		docElem.appendChild( div ).id = expando;
+		return !doc.getElementsByName || !doc.getElementsByName( expando ).length;
+	});
+
+	// ID find and filter
+	if ( support.getById ) {
+		Expr.find["ID"] = function( id, context ) {
+			if ( typeof context.getElementById !== "undefined" && documentIsHTML ) {
+				var m = context.getElementById( id );
+				// Check parentNode to catch when Blackberry 4.6 returns
+				// nodes that are no longer in the document #6963
+				return m && m.parentNode ? [ m ] : [];
+			}
+		};
+		Expr.filter["ID"] = function( id ) {
+			var attrId = id.replace( runescape, funescape );
+			return function( elem ) {
+				return elem.getAttribute("id") === attrId;
+			};
+		};
+	} else {
+		// Support: IE6/7
+		// getElementById is not reliable as a find shortcut
+		delete Expr.find["ID"];
+
+		Expr.filter["ID"] =  function( id ) {
+			var attrId = id.replace( runescape, funescape );
+			return function( elem ) {
+				var node = typeof elem.getAttributeNode !== "undefined" && elem.getAttributeNode("id");
+				return node && node.value === attrId;
+			};
+		};
+	}
+
+	// Tag
+	Expr.find["TAG"] = support.getElementsByTagName ?
+		function( tag, context ) {
+			if ( typeof context.getElementsByTagName !== "undefined" ) {
+				return context.getElementsByTagName( tag );
+
+			// DocumentFragment nodes don't have gEBTN
+			} else if ( support.qsa ) {
+				return context.querySelectorAll( tag );
+			}
+		} :
+
+		function( tag, context ) {
+			var elem,
+				tmp = [],
+				i = 0,
+				// By happy coincidence, a (broken) gEBTN appears on DocumentFragment nodes too
+				results = context.getElementsByTagName( tag );
+
+			// Filter out possible comments
+			if ( tag === "*" ) {
+				while ( (elem = results[i++]) ) {
+					if ( elem.nodeType === 1 ) {
+						tmp.push( elem );
+					}
+				}
+
+				return tmp;
+			}
+			return results;
+		};
+
+	// Class
+	Expr.find["CLASS"] = support.getElementsByClassName && function( className, context ) {
+		if ( documentIsHTML ) {
+			return context.getElementsByClassName( className );
+		}
+	};
+
+	/* QSA/matchesSelector
+	---------------------------------------------------------------------- */
+
+	// QSA and matchesSelector support
+
+	// matchesSelector(:active) reports false when true (IE9/Opera 11.5)
+	rbuggyMatches = [];
+
+	// qSa(:focus) reports false when true (Chrome 21)
+	// We allow this because of a bug in IE8/9 that throws an error
+	// whenever `document.activeElement` is accessed on an iframe
+	// So, we allow :focus to pass through QSA all the time to avoid the IE error
+	// See http://bugs.jquery.com/ticket/13378
+	rbuggyQSA = [];
+
+	if ( (support.qsa = rnative.test( doc.querySelectorAll )) ) {
+		// Build QSA regex
+		// Regex strategy adopted from Diego Perini
+		assert(function( div ) {
+			// Select is set to empty string on purpose
+			// This is to test IE's treatment of not explicitly
+			// setting a boolean content attribute,
+			// since its presence should be enough
+			// http://bugs.jquery.com/ticket/12359
+			docElem.appendChild( div ).innerHTML = "<a id='" + expando + "'></a>" +
+				"<select id='" + expando + "-\f]' msallowcapture=''>" +
+				"<option selected=''></option></select>";
+
+			// Support: IE8, Opera 11-12.16
+			// Nothing should be selected when empty strings follow ^= or $= or *=
+			// The test attribute must be unknown in Opera but "safe" for WinRT
+			// http://msdn.microsoft.com/en-us/library/ie/hh465388.aspx#attribute_section
+			if ( div.querySelectorAll("[msallowcapture^='']").length ) {
+				rbuggyQSA.push( "[*^$]=" + whitespace + "*(?:''|\"\")" );
+			}
+
+			// Support: IE8
+			// Boolean attributes and "value" are not treated correctly
+			if ( !div.querySelectorAll("[selected]").length ) {
+				rbuggyQSA.push( "\\[" + whitespace + "*(?:value|" + booleans + ")" );
+			}
+
+			// Support: Chrome<29, Android<4.2+, Safari<7.0+, iOS<7.0+, PhantomJS<1.9.7+
+			if ( !div.querySelectorAll( "[id~=" + expando + "-]" ).length ) {
+				rbuggyQSA.push("~=");
+			}
+
+			// Webkit/Opera - :checked should return selected option elements
+			// http://www.w3.org/TR/2011/REC-css3-selectors-20110929/#checked
+			// IE8 throws error here and will not see later tests
+			if ( !div.querySelectorAll(":checked").length ) {
+				rbuggyQSA.push(":checked");
+			}
+
+			// Support: Safari 8+, iOS 8+
+			// https://bugs.webkit.org/show_bug.cgi?id=136851
+			// In-page `selector#id sibing-combinator selector` fails
+			if ( !div.querySelectorAll( "a#" + expando + "+*" ).length ) {
+				rbuggyQSA.push(".#.+[+~]");
+			}
+		});
+
+		assert(function( div ) {
+			// Support: Windows 8 Native Apps
+			// The type and name attributes are restricted during .innerHTML assignment
+			var input = doc.createElement("input");
+			input.setAttribute( "type", "hidden" );
+			div.appendChild( input ).setAttribute( "name", "D" );
+
+			// Support: IE8
+			// Enforce case-sensitivity of name attribute
+			if ( div.querySelectorAll("[name=d]").length ) {
+				rbuggyQSA.push( "name" + whitespace + "*[*^$|!~]?=" );
+			}
+
+			// FF 3.5 - :enabled/:disabled and hidden elements (hidden elements are still enabled)
+			// IE8 throws error here and will not see later tests
+			if ( !div.querySelectorAll(":enabled").length ) {
+				rbuggyQSA.push( ":enabled", ":disabled" );
+			}
+
+			// Opera 10-11 does not throw on post-comma invalid pseudos
+			div.querySelectorAll("*,:x");
+			rbuggyQSA.push(",.*:");
+		});
+	}
+
+	if ( (support.matchesSelector = rnative.test( (matches = docElem.matches ||
+		docElem.webkitMatchesSelector ||
+		docElem.mozMatchesSelector ||
+		docElem.oMatchesSelector ||
+		docElem.msMatchesSelector) )) ) {
+
+		assert(function( div ) {
+			// Check to see if it's possible to do matchesSelector
+			// on a disconnected node (IE 9)
+			support.disconnectedMatch = matches.call( div, "div" );
+
+			// This should fail with an exception
+			// Gecko does not error, returns false instead
+			matches.call( div, "[s!='']:x" );
+			rbuggyMatches.push( "!=", pseudos );
+		});
+	}
+
+	rbuggyQSA = rbuggyQSA.length && new RegExp( rbuggyQSA.join("|") );
+	rbuggyMatches = rbuggyMatches.length && new RegExp( rbuggyMatches.join("|") );
+
+	/* Contains
+	---------------------------------------------------------------------- */
+	hasCompare = rnative.test( docElem.compareDocumentPosition );
+
+	// Element contains another
+	// Purposefully does not implement inclusive descendent
+	// As in, an element does not contain itself
+	contains = hasCompare || rnative.test( docElem.contains ) ?
+		function( a, b ) {
+			var adown = a.nodeType === 9 ? a.documentElement : a,
+				bup = b && b.parentNode;
+			return a === bup || !!( bup && bup.nodeType === 1 && (
+				adown.contains ?
+					adown.contains( bup ) :
+					a.compareDocumentPosition && a.compareDocumentPosition( bup ) & 16
+			));
+		} :
+		function( a, b ) {
+			if ( b ) {
+				while ( (b = b.parentNode) ) {
+					if ( b === a ) {
+						return true;
+					}
+				}
+			}
+			return false;
+		};
+
+	/* Sorting
+	---------------------------------------------------------------------- */
+
+	// Document order sorting
+	sortOrder = hasCompare ?
+	function( a, b ) {
+
+		// Flag for duplicate removal
+		if ( a === b ) {
+			hasDuplicate = true;
+			return 0;
+		}
+
+		// Sort on method existence if only one input has compareDocumentPosition
+		var compare = !a.compareDocumentPosition - !b.compareDocumentPosition;
+		if ( compare ) {
+			return compare;
+		}
+
+		// Calculate position if both inputs belong to the same document
+		compare = ( a.ownerDocument || a ) === ( b.ownerDocument || b ) ?
+			a.compareDocumentPosition( b ) :
+
+			// Otherwise we know they are disconnected
+			1;
+
+		// Disconnected nodes
+		if ( compare & 1 ||
+			(!support.sortDetached && b.compareDocumentPosition( a ) === compare) ) {
+
+			// Choose the first element that is related to our preferred document
+			if ( a === doc || a.ownerDocument === preferredDoc && contains(preferredDoc, a) ) {
+				return -1;
+			}
+			if ( b === doc || b.ownerDocument === preferredDoc && contains(preferredDoc, b) ) {
+				return 1;
+			}
+
+			// Maintain original order
+			return sortInput ?
+				( indexOf( sortInput, a ) - indexOf( sortInput, b ) ) :
+				0;
+		}
+
+		return compare & 4 ? -1 : 1;
+	} :
+	function( a, b ) {
+		// Exit early if the nodes are identical
+		if ( a === b ) {
+			hasDuplicate = true;
+			return 0;
+		}
+
+		var cur,
+			i = 0,
+			aup = a.parentNode,
+			bup = b.parentNode,
+			ap = [ a ],
+			bp = [ b ];
+
+		// Parentless nodes are either documents or disconnected
+		if ( !aup || !bup ) {
+			return a === doc ? -1 :
+				b === doc ? 1 :
+				aup ? -1 :
+				bup ? 1 :
+				sortInput ?
+				( indexOf( sortInput, a ) - indexOf( sortInput, b ) ) :
+				0;
+
+		// If the nodes are siblings, we can do a quick check
+		} else if ( aup === bup ) {
+			return siblingCheck( a, b );
+		}
+
+		// Otherwise we need full lists of their ancestors for comparison
+		cur = a;
+		while ( (cur = cur.parentNode) ) {
+			ap.unshift( cur );
+		}
+		cur = b;
+		while ( (cur = cur.parentNode) ) {
+			bp.unshift( cur );
+		}
+
+		// Walk down the tree looking for a discrepancy
+		while ( ap[i] === bp[i] ) {
+			i++;
+		}
+
+		return i ?
+			// Do a sibling check if the nodes have a common ancestor
+			siblingCheck( ap[i], bp[i] ) :
+
+			// Otherwise nodes in our document sort first
+			ap[i] === preferredDoc ? -1 :
+			bp[i] === preferredDoc ? 1 :
+			0;
+	};
+
+	return doc;
+};
+
+Sizzle.matches = function( expr, elements ) {
+	return Sizzle( expr, null, null, elements );
+};
+
+Sizzle.matchesSelector = function( elem, expr ) {
+	// Set document vars if needed
+	if ( ( elem.ownerDocument || elem ) !== document ) {
+		setDocument( elem );
+	}
+
+	// Make sure that attribute selectors are quoted
+	expr = expr.replace( rattributeQuotes, "='$1']" );
+
+	if ( support.matchesSelector && documentIsHTML &&
+		( !rbuggyMatches || !rbuggyMatches.test( expr ) ) &&
+		( !rbuggyQSA     || !rbuggyQSA.test( expr ) ) ) {
+
+		try {
+			var ret = matches.call( elem, expr );
+
+			// IE 9's matchesSelector returns false on disconnected nodes
+			if ( ret || support.disconnectedMatch ||
+					// As well, disconnected nodes are said to be in a document
+					// fragment in IE 9
+					elem.document && elem.document.nodeType !== 11 ) {
+				return ret;
+			}
+		} catch (e) {}
+	}
+
+	return Sizzle( expr, document, null, [ elem ] ).length > 0;
+};
+
+Sizzle.contains = function( context, elem ) {
+	// Set document vars if needed
+	if ( ( context.ownerDocument || context ) !== document ) {
+		setDocument( context );
+	}
+	return contains( context, elem );
+};
+
+Sizzle.attr = function( elem, name ) {
+	// Set document vars if needed
+	if ( ( elem.ownerDocument || elem ) !== document ) {
+		setDocument( elem );
+	}
+
+	var fn = Expr.attrHandle[ name.toLowerCase() ],
+		// Don't get fooled by Object.prototype properties (jQuery #13807)
+		val = fn && hasOwn.call( Expr.attrHandle, name.toLowerCase() ) ?
+			fn( elem, name, !documentIsHTML ) :
+			undefined;
+
+	return val !== undefined ?
+		val :
+		support.attributes || !documentIsHTML ?
+			elem.getAttribute( name ) :
+			(val = elem.getAttributeNode(name)) && val.specified ?
+				val.value :
+				null;
+};
+
+Sizzle.error = function( msg ) {
+	throw new Error( "Syntax error, unrecognized expression: " + msg );
+};
+
+/**
+ * Document sorting and removing duplicates
+ * @param {ArrayLike} results
+ */
+Sizzle.uniqueSort = function( results ) {
+	var elem,
+		duplicates = [],
+		j = 0,
+		i = 0;
+
+	// Unless we *know* we can detect duplicates, assume their presence
+	hasDuplicate = !support.detectDuplicates;
+	sortInput = !support.sortStable && results.slice( 0 );
+	results.sort( sortOrder );
+
+	if ( hasDuplicate ) {
+		while ( (elem = results[i++]) ) {
+			if ( elem === results[ i ] ) {
+				j = duplicates.push( i );
+			}
+		}
+		while ( j-- ) {
+			results.splice( duplicates[ j ], 1 );
+		}
+	}
+
+	// Clear input after sorting to release objects
+	// See https://github.com/jquery/sizzle/pull/225
+	sortInput = null;
+
+	return results;
+};
+
+/**
+ * Utility function for retrieving the text value of an array of DOM nodes
+ * @param {Array|Element} elem
+ */
+getText = Sizzle.getText = function( elem ) {
+	var node,
+		ret = "",
+		i = 0,
+		nodeType = elem.nodeType;
+
+	if ( !nodeType ) {
+		// If no nodeType, this is expected to be an array
+		while ( (node = elem[i++]) ) {
+			// Do not traverse comment nodes
+			ret += getText( node );
+		}
+	} else if ( nodeType === 1 || nodeType === 9 || nodeType === 11 ) {
+		// Use textContent for elements
+		// innerText usage removed for consistency of new lines (jQuery #11153)
+		if ( typeof elem.textContent === "string" ) {
+			return elem.textContent;
+		} else {
+			// Traverse its children
+			for ( elem = elem.firstChild; elem; elem = elem.nextSibling ) {
+				ret += getText( elem );
+			}
+		}
+	} else if ( nodeType === 3 || nodeType === 4 ) {
+		return elem.nodeValue;
+	}
+	// Do not include comment or processing instruction nodes
+
+	return ret;
+};
+
+Expr = Sizzle.selectors = {
+
+	// Can be adjusted by the user
+	cacheLength: 50,
+
+	createPseudo: markFunction,
+
+	match: matchExpr,
+
+	attrHandle: {},
+
+	find: {},
+
+	relative: {
+		">": { dir: "parentNode", first: true },
+		" ": { dir: "parentNode" },
+		"+": { dir: "previousSibling", first: true },
+		"~": { dir: "previousSibling" }
+	},
+
+	preFilter: {
+		"ATTR": function( match ) {
+			match[1] = match[1].replace( runescape, funescape );
+
+			// Move the given value to match[3] whether quoted or unquoted
+			match[3] = ( match[3] || match[4] || match[5] || "" ).replace( runescape, funescape );
+
+			if ( match[2] === "~=" ) {
+				match[3] = " " + match[3] + " ";
+			}
+
+			return match.slice( 0, 4 );
+		},
+
+		"CHILD": function( match ) {
+			/* matches from matchExpr["CHILD"]
+				1 type (only|nth|...)
+				2 what (child|of-type)
+				3 argument (even|odd|\d*|\d*n([+-]\d+)?|...)
+				4 xn-component of xn+y argument ([+-]?\d*n|)
+				5 sign of xn-component
+				6 x of xn-component
+				7 sign of y-component
+				8 y of y-component
+			*/
+			match[1] = match[1].toLowerCase();
+
+			if ( match[1].slice( 0, 3 ) === "nth" ) {
+				// nth-* requires argument
+				if ( !match[3] ) {
+					Sizzle.error( match[0] );
+				}
+
+				// numeric x and y parameters for Expr.filter.CHILD
+				// remember that false/true cast respectively to 0/1
+				match[4] = +( match[4] ? match[5] + (match[6] || 1) : 2 * ( match[3] === "even" || match[3] === "odd" ) );
+				match[5] = +( ( match[7] + match[8] ) || match[3] === "odd" );
+
+			// other types prohibit arguments
+			} else if ( match[3] ) {
+				Sizzle.error( match[0] );
+			}
+
+			return match;
+		},
+
+		"PSEUDO": function( match ) {
+			var excess,
+				unquoted = !match[6] && match[2];
+
+			if ( matchExpr["CHILD"].test( match[0] ) ) {
+				return null;
+			}
+
+			// Accept quoted arguments as-is
+			if ( match[3] ) {
+				match[2] = match[4] || match[5] || "";
+
+			// Strip excess characters from unquoted arguments
+			} else if ( unquoted && rpseudo.test( unquoted ) &&
+				// Get excess from tokenize (recursively)
+				(excess = tokenize( unquoted, true )) &&
+				// advance to the next closing parenthesis
+				(excess = unquoted.indexOf( ")", unquoted.length - excess ) - unquoted.length) ) {
+
+				// excess is a negative index
+				match[0] = match[0].slice( 0, excess );
+				match[2] = unquoted.slice( 0, excess );
+			}
+
+			// Return only captures needed by the pseudo filter method (type and argument)
+			return match.slice( 0, 3 );
+		}
+	},
+
+	filter: {
+
+		"TAG": function( nodeNameSelector ) {
+			var nodeName = nodeNameSelector.replace( runescape, funescape ).toLowerCase();
+			return nodeNameSelector === "*" ?
+				function() { return true; } :
+				function( elem ) {
+					return elem.nodeName && elem.nodeName.toLowerCase() === nodeName;
+				};
+		},
+
+		"CLASS": function( className ) {
+			var pattern = classCache[ className + " " ];
+
+			return pattern ||
+				(pattern = new RegExp( "(^|" + whitespace + ")" + className + "(" + whitespace + "|$)" )) &&
+				classCache( className, function( elem ) {
+					return pattern.test( typeof elem.className === "string" && elem.className || typeof elem.getAttribute !== "undefined" && elem.getAttribute("class") || "" );
+				});
+		},
+
+		"ATTR": function( name, operator, check ) {
+			return function( elem ) {
+				var result = Sizzle.attr( elem, name );
+
+				if ( result == null ) {
+					return operator === "!=";
+				}
+				if ( !operator ) {
+					return true;
+				}
+
+				result += "";
+
+				return operator === "=" ? result === check :
+					operator === "!=" ? result !== check :
+					operator === "^=" ? check && result.indexOf( check ) === 0 :
+					operator === "*=" ? check && result.indexOf( check ) > -1 :
+					operator === "$=" ? check && result.slice( -check.length ) === check :
+					operator === "~=" ? ( " " + result.replace( rwhitespace, " " ) + " " ).indexOf( check ) > -1 :
+					operator === "|=" ? result === check || result.slice( 0, check.length + 1 ) === check + "-" :
+					false;
+			};
+		},
+
+		"CHILD": function( type, what, argument, first, last ) {
+			var simple = type.slice( 0, 3 ) !== "nth",
+				forward = type.slice( -4 ) !== "last",
+				ofType = what === "of-type";
+
+			return first === 1 && last === 0 ?
+
+				// Shortcut for :nth-*(n)
+				function( elem ) {
+					return !!elem.parentNode;
+				} :
+
+				function( elem, context, xml ) {
+					var cache, outerCache, node, diff, nodeIndex, start,
+						dir = simple !== forward ? "nextSibling" : "previousSibling",
+						parent = elem.parentNode,
+						name = ofType && elem.nodeName.toLowerCase(),
+						useCache = !xml && !ofType;
+
+					if ( parent ) {
+
+						// :(first|last|only)-(child|of-type)
+						if ( simple ) {
+							while ( dir ) {
+								node = elem;
+								while ( (node = node[ dir ]) ) {
+									if ( ofType ? node.nodeName.toLowerCase() === name : node.nodeType === 1 ) {
+										return false;
+									}
+								}
+								// Reverse direction for :only-* (if we haven't yet done so)
+								start = dir = type === "only" && !start && "nextSibling";
+							}
+							return true;
+						}
+
+						start = [ forward ? parent.firstChild : parent.lastChild ];
+
+						// non-xml :nth-child(...) stores cache data on `parent`
+						if ( forward && useCache ) {
+							// Seek `elem` from a previously-cached index
+							outerCache = parent[ expando ] || (parent[ expando ] = {});
+							cache = outerCache[ type ] || [];
+							nodeIndex = cache[0] === dirruns && cache[1];
+							diff = cache[0] === dirruns && cache[2];
+							node = nodeIndex && parent.childNodes[ nodeIndex ];
+
+							while ( (node = ++nodeIndex && node && node[ dir ] ||
+
+								// Fallback to seeking `elem` from the start
+								(diff = nodeIndex = 0) || start.pop()) ) {
+
+								// When found, cache indexes on `parent` and break
+								if ( node.nodeType === 1 && ++diff && node === elem ) {
+									outerCache[ type ] = [ dirruns, nodeIndex, diff ];
+									break;
+								}
+							}
+
+						// Use previously-cached element index if available
+						} else if ( useCache && (cache = (elem[ expando ] || (elem[ expando ] = {}))[ type ]) && cache[0] === dirruns ) {
+							diff = cache[1];
+
+						// xml :nth-child(...) or :nth-last-child(...) or :nth(-last)?-of-type(...)
+						} else {
+							// Use the same loop as above to seek `elem` from the start
+							while ( (node = ++nodeIndex && node && node[ dir ] ||
+								(diff = nodeIndex = 0) || start.pop()) ) {
+
+								if ( ( ofType ? node.nodeName.toLowerCase() === name : node.nodeType === 1 ) && ++diff ) {
+									// Cache the index of each encountered element
+									if ( useCache ) {
+										(node[ expando ] || (node[ expando ] = {}))[ type ] = [ dirruns, diff ];
+									}
+
+									if ( node === elem ) {
+										break;
+									}
+								}
+							}
+						}
+
+						// Incorporate the offset, then check against cycle size
+						diff -= last;
+						return diff === first || ( diff % first === 0 && diff / first >= 0 );
+					}
+				};
+		},
+
+		"PSEUDO": function( pseudo, argument ) {
+			// pseudo-class names are case-insensitive
+			// http://www.w3.org/TR/selectors/#pseudo-classes
+			// Prioritize by case sensitivity in case custom pseudos are added with uppercase letters
+			// Remember that setFilters inherits from pseudos
+			var args,
+				fn = Expr.pseudos[ pseudo ] || Expr.setFilters[ pseudo.toLowerCase() ] ||
+					Sizzle.error( "unsupported pseudo: " + pseudo );
+
+			// The user may use createPseudo to indicate that
+			// arguments are needed to create the filter function
+			// just as Sizzle does
+			if ( fn[ expando ] ) {
+				return fn( argument );
+			}
+
+			// But maintain support for old signatures
+			if ( fn.length > 1 ) {
+				args = [ pseudo, pseudo, "", argument ];
+				return Expr.setFilters.hasOwnProperty( pseudo.toLowerCase() ) ?
+					markFunction(function( seed, matches ) {
+						var idx,
+							matched = fn( seed, argument ),
+							i = matched.length;
+						while ( i-- ) {
+							idx = indexOf( seed, matched[i] );
+							seed[ idx ] = !( matches[ idx ] = matched[i] );
+						}
+					}) :
+					function( elem ) {
+						return fn( elem, 0, args );
+					};
+			}
+
+			return fn;
+		}
+	},
+
+	pseudos: {
+		// Potentially complex pseudos
+		"not": markFunction(function( selector ) {
+			// Trim the selector passed to compile
+			// to avoid treating leading and trailing
+			// spaces as combinators
+			var input = [],
+				results = [],
+				matcher = compile( selector.replace( rtrim, "$1" ) );
+
+			return matcher[ expando ] ?
+				markFunction(function( seed, matches, context, xml ) {
+					var elem,
+						unmatched = matcher( seed, null, xml, [] ),
+						i = seed.length;
+
+					// Match elements unmatched by `matcher`
+					while ( i-- ) {
+						if ( (elem = unmatched[i]) ) {
+							seed[i] = !(matches[i] = elem);
+						}
+					}
+				}) :
+				function( elem, context, xml ) {
+					input[0] = elem;
+					matcher( input, null, xml, results );
+					// Don't keep the element (issue #299)
+					input[0] = null;
+					return !results.pop();
+				};
+		}),
+
+		"has": markFunction(function( selector ) {
+			return function( elem ) {
+				return Sizzle( selector, elem ).length > 0;
+			};
+		}),
+
+		"contains": markFunction(function( text ) {
+			text = text.replace( runescape, funescape );
+			return function( elem ) {
+				return ( elem.textContent || elem.innerText || getText( elem ) ).indexOf( text ) > -1;
+			};
+		}),
+
+		// "Whether an element is represented by a :lang() selector
+		// is based solely on the element's language value
+		// being equal to the identifier C,
+		// or beginning with the identifier C immediately followed by "-".
+		// The matching of C against the element's language value is performed case-insensitively.
+		// The identifier C does not have to be a valid language name."
+		// http://www.w3.org/TR/selectors/#lang-pseudo
+		"lang": markFunction( function( lang ) {
+			// lang value must be a valid identifier
+			if ( !ridentifier.test(lang || "") ) {
+				Sizzle.error( "unsupported lang: " + lang );
+			}
+			lang = lang.replace( runescape, funescape ).toLowerCase();
+			return function( elem ) {
+				var elemLang;
+				do {
+					if ( (elemLang = documentIsHTML ?
+						elem.lang :
+						elem.getAttribute("xml:lang") || elem.getAttribute("lang")) ) {
+
+						elemLang = elemLang.toLowerCase();
+						return elemLang === lang || elemLang.indexOf( lang + "-" ) === 0;
+					}
+				} while ( (elem = elem.parentNode) && elem.nodeType === 1 );
+				return false;
+			};
+		}),
+
+		// Miscellaneous
+		"target": function( elem ) {
+			var hash = window.location && window.location.hash;
+			return hash && hash.slice( 1 ) === elem.id;
+		},
+
+		"root": function( elem ) {
+			return elem === docElem;
+		},
+
+		"focus": function( elem ) {
+			return elem === document.activeElement && (!document.hasFocus || document.hasFocus()) && !!(elem.type || elem.href || ~elem.tabIndex);
+		},
+
+		// Boolean properties
+		"enabled": function( elem ) {
+			return elem.disabled === false;
+		},
+
+		"disabled": function( elem ) {
+			return elem.disabled === true;
+		},
+
+		"checked": function( elem ) {
+			// In CSS3, :checked should return both checked and selected elements
+			// http://www.w3.org/TR/2011/REC-css3-selectors-20110929/#checked
+			var nodeName = elem.nodeName.toLowerCase();
+			return (nodeName === "input" && !!elem.checked) || (nodeName === "option" && !!elem.selected);
+		},
+
+		"selected": function( elem ) {
+			// Accessing this property makes selected-by-default
+			// options in Safari work properly
+			if ( elem.parentNode ) {
+				elem.parentNode.selectedIndex;
+			}
+
+			return elem.selected === true;
+		},
+
+		// Contents
+		"empty": function( elem ) {
+			// http://www.w3.org/TR/selectors/#empty-pseudo
+			// :empty is negated by element (1) or content nodes (text: 3; cdata: 4; entity ref: 5),
+			//   but not by others (comment: 8; processing instruction: 7; etc.)
+			// nodeType < 6 works because attributes (2) do not appear as children
+			for ( elem = elem.firstChild; elem; elem = elem.nextSibling ) {
+				if ( elem.nodeType < 6 ) {
+					return false;
+				}
+			}
+			return true;
+		},
+
+		"parent": function( elem ) {
+			return !Expr.pseudos["empty"]( elem );
+		},
+
+		// Element/input types
+		"header": function( elem ) {
+			return rheader.test( elem.nodeName );
+		},
+
+		"input": function( elem ) {
+			return rinputs.test( elem.nodeName );
+		},
+
+		"button": function( elem ) {
+			var name = elem.nodeName.toLowerCase();
+			return name === "input" && elem.type === "button" || name === "button";
+		},
+
+		"text": function( elem ) {
+			var attr;
+			return elem.nodeName.toLowerCase() === "input" &&
+				elem.type === "text" &&
+
+				// Support: IE<8
+				// New HTML5 attribute values (e.g., "search") appear with elem.type === "text"
+				( (attr = elem.getAttribute("type")) == null || attr.toLowerCase() === "text" );
+		},
+
+		// Position-in-collection
+		"first": createPositionalPseudo(function() {
+			return [ 0 ];
+		}),
+
+		"last": createPositionalPseudo(function( matchIndexes, length ) {
+			return [ length - 1 ];
+		}),
+
+		"eq": createPositionalPseudo(function( matchIndexes, length, argument ) {
+			return [ argument < 0 ? argument + length : argument ];
+		}),
+
+		"even": createPositionalPseudo(function( matchIndexes, length ) {
+			var i = 0;
+			for ( ; i < length; i += 2 ) {
+				matchIndexes.push( i );
+			}
+			return matchIndexes;
+		}),
+
+		"odd": createPositionalPseudo(function( matchIndexes, length ) {
+			var i = 1;
+			for ( ; i < length; i += 2 ) {
+				matchIndexes.push( i );
+			}
+			return matchIndexes;
+		}),
+
+		"lt": createPositionalPseudo(function( matchIndexes, length, argument ) {
+			var i = argument < 0 ? argument + length : argument;
+			for ( ; --i >= 0; ) {
+				matchIndexes.push( i );
+			}
+			return matchIndexes;
+		}),
+
+		"gt": createPositionalPseudo(function( matchIndexes, length, argument ) {
+			var i = argument < 0 ? argument + length : argument;
+			for ( ; ++i < length; ) {
+				matchIndexes.push( i );
+			}
+			return matchIndexes;
+		})
+	}
+};
+
+Expr.pseudos["nth"] = Expr.pseudos["eq"];
+
+// Add button/input type pseudos
+for ( i in { radio: true, checkbox: true, file: true, password: true, image: true } ) {
+	Expr.pseudos[ i ] = createInputPseudo( i );
+}
+for ( i in { submit: true, reset: true } ) {
+	Expr.pseudos[ i ] = createButtonPseudo( i );
+}
+
+// Easy API for creating new setFilters
+function setFilters() {}
+setFilters.prototype = Expr.filters = Expr.pseudos;
+Expr.setFilters = new setFilters();
+
+tokenize = Sizzle.tokenize = function( selector, parseOnly ) {
+	var matched, match, tokens, type,
+		soFar, groups, preFilters,
+		cached = tokenCache[ selector + " " ];
+
+	if ( cached ) {
+		return parseOnly ? 0 : cached.slice( 0 );
+	}
+
+	soFar = selector;
+	groups = [];
+	preFilters = Expr.preFilter;
+
+	while ( soFar ) {
+
+		// Comma and first run
+		if ( !matched || (match = rcomma.exec( soFar )) ) {
+			if ( match ) {
+				// Don't consume trailing commas as valid
+				soFar = soFar.slice( match[0].length ) || soFar;
+			}
+			groups.push( (tokens = []) );
+		}
+
+		matched = false;
+
+		// Combinators
+		if ( (match = rcombinators.exec( soFar )) ) {
+			matched = match.shift();
+			tokens.push({
+				value: matched,
+				// Cast descendant combinators to space
+				type: match[0].replace( rtrim, " " )
+			});
+			soFar = soFar.slice( matched.length );
+		}
+
+		// Filters
+		for ( type in Expr.filter ) {
+			if ( (match = matchExpr[ type ].exec( soFar )) && (!preFilters[ type ] ||
+				(match = preFilters[ type ]( match ))) ) {
+				matched = match.shift();
+				tokens.push({
+					value: matched,
+					type: type,
+					matches: match
+				});
+				soFar = soFar.slice( matched.length );
+			}
+		}
+
+		if ( !matched ) {
+			break;
+		}
+	}
+
+	// Return the length of the invalid excess
+	// if we're just parsing
+	// Otherwise, throw an error or return tokens
+	return parseOnly ?
+		soFar.length :
+		soFar ?
+			Sizzle.error( selector ) :
+			// Cache the tokens
+			tokenCache( selector, groups ).slice( 0 );
+};
+
+function toSelector( tokens ) {
+	var i = 0,
+		len = tokens.length,
+		selector = "";
+	for ( ; i < len; i++ ) {
+		selector += tokens[i].value;
+	}
+	return selector;
+}
+
+function addCombinator( matcher, combinator, base ) {
+	var dir = combinator.dir,
+		checkNonElements = base && dir === "parentNode",
+		doneName = done++;
+
+	return combinator.first ?
+		// Check against closest ancestor/preceding element
+		function( elem, context, xml ) {
+			while ( (elem = elem[ dir ]) ) {
+				if ( elem.nodeType === 1 || checkNonElements ) {
+					return matcher( elem, context, xml );
+				}
+			}
+		} :
+
+		// Check against all ancestor/preceding elements
+		function( elem, context, xml ) {
+			var oldCache, outerCache,
+				newCache = [ dirruns, doneName ];
+
+			// We can't set arbitrary data on XML nodes, so they don't benefit from dir caching
+			if ( xml ) {
+				while ( (elem = elem[ dir ]) ) {
+					if ( elem.nodeType === 1 || checkNonElements ) {
+						if ( matcher( elem, context, xml ) ) {
+							return true;
+						}
+					}
+				}
+			} else {
+				while ( (elem = elem[ dir ]) ) {
+					if ( elem.nodeType === 1 || checkNonElements ) {
+						outerCache = elem[ expando ] || (elem[ expando ] = {});
+						if ( (oldCache = outerCache[ dir ]) &&
+							oldCache[ 0 ] === dirruns && oldCache[ 1 ] === doneName ) {
+
+							// Assign to newCache so results back-propagate to previous elements
+							return (newCache[ 2 ] = oldCache[ 2 ]);
+						} else {
+							// Reuse newcache so results back-propagate to previous elements
+							outerCache[ dir ] = newCache;
+
+							// A match means we're done; a fail means we have to keep checking
+							if ( (newCache[ 2 ] = matcher( elem, context, xml )) ) {
+								return true;
+							}
+						}
+					}
+				}
+			}
+		};
+}
+
+function elementMatcher( matchers ) {
+	return matchers.length > 1 ?
+		function( elem, context, xml ) {
+			var i = matchers.length;
+			while ( i-- ) {
+				if ( !matchers[i]( elem, context, xml ) ) {
+					return false;
+				}
+			}
+			return true;
+		} :
+		matchers[0];
+}
+
+function multipleContexts( selector, contexts, results ) {
+	var i = 0,
+		len = contexts.length;
+	for ( ; i < len; i++ ) {
+		Sizzle( selector, contexts[i], results );
+	}
+	return results;
+}
+
+function condense( unmatched, map, filter, context, xml ) {
+	var elem,
+		newUnmatched = [],
+		i = 0,
+		len = unmatched.length,
+		mapped = map != null;
+
+	for ( ; i < len; i++ ) {
+		if ( (elem = unmatched[i]) ) {
+			if ( !filter || filter( elem, context, xml ) ) {
+				newUnmatched.push( elem );
+				if ( mapped ) {
+					map.push( i );
+				}
+			}
+		}
+	}
+
+	return newUnmatched;
+}
+
+function setMatcher( preFilter, selector, matcher, postFilter, postFinder, postSelector ) {
+	if ( postFilter && !postFilter[ expando ] ) {
+		postFilter = setMatcher( postFilter );
+	}
+	if ( postFinder && !postFinder[ expando ] ) {
+		postFinder = setMatcher( postFinder, postSelector );
+	}
+	return markFunction(function( seed, results, context, xml ) {
+		var temp, i, elem,
+			preMap = [],
+			postMap = [],
+			preexisting = results.length,
+
+			// Get initial elements from seed or context
+			elems = seed || multipleContexts( selector || "*", context.nodeType ? [ context ] : context, [] ),
+
+			// Prefilter to get matcher input, preserving a map for seed-results synchronization
+			matcherIn = preFilter && ( seed || !selector ) ?
+				condense( elems, preMap, preFilter, context, xml ) :
+				elems,
+
+			matcherOut = matcher ?
+				// If we have a postFinder, or filtered seed, or non-seed postFilter or preexisting results,
+				postFinder || ( seed ? preFilter : preexisting || postFilter ) ?
+
+					// ...intermediate processing is necessary
+					[] :
+
+					// ...otherwise use results directly
+					results :
+				matcherIn;
+
+		// Find primary matches
+		if ( matcher ) {
+			matcher( matcherIn, matcherOut, context, xml );
+		}
+
+		// Apply postFilter
+		if ( postFilter ) {
+			temp = condense( matcherOut, postMap );
+			postFilter( temp, [], context, xml );
+
+			// Un-match failing elements by moving them back to matcherIn
+			i = temp.length;
+			while ( i-- ) {
+				if ( (elem = temp[i]) ) {
+					matcherOut[ postMap[i] ] = !(matcherIn[ postMap[i] ] = elem);
+				}
+			}
+		}
+
+		if ( seed ) {
+			if ( postFinder || preFilter ) {
+				if ( postFinder ) {
+					// Get the final matcherOut by condensing this intermediate into postFinder contexts
+					temp = [];
+					i = matcherOut.length;
+					while ( i-- ) {
+						if ( (elem = matcherOut[i]) ) {
+							// Restore matcherIn since elem is not yet a final match
+							temp.push( (matcherIn[i] = elem) );
+						}
+					}
+					postFinder( null, (matcherOut = []), temp, xml );
+				}
+
+				// Move matched elements from seed to results to keep them synchronized
+				i = matcherOut.length;
+				while ( i-- ) {
+					if ( (elem = matcherOut[i]) &&
+						(temp = postFinder ? indexOf( seed, elem ) : preMap[i]) > -1 ) {
+
+						seed[temp] = !(results[temp] = elem);
+					}
+				}
+			}
+
+		// Add elements to results, through postFinder if defined
+		} else {
+			matcherOut = condense(
+				matcherOut === results ?
+					matcherOut.splice( preexisting, matcherOut.length ) :
+					matcherOut
+			);
+			if ( postFinder ) {
+				postFinder( null, results, matcherOut, xml );
+			} else {
+				push.apply( results, matcherOut );
+			}
+		}
+	});
+}
+
+function matcherFromTokens( tokens ) {
+	var checkContext, matcher, j,
+		len = tokens.length,
+		leadingRelative = Expr.relative[ tokens[0].type ],
+		implicitRelative = leadingRelative || Expr.relative[" "],
+		i = leadingRelative ? 1 : 0,
+
+		// The foundational matcher ensures that elements are reachable from top-level context(s)
+		matchContext = addCombinator( function( elem ) {
+			return elem === checkContext;
+		}, implicitRelative, true ),
+		matchAnyContext = addCombinator( function( elem ) {
+			return indexOf( checkContext, elem ) > -1;
+		}, implicitRelative, true ),
+		matchers = [ function( elem, context, xml ) {
+			var ret = ( !leadingRelative && ( xml || context !== outermostContext ) ) || (
+				(checkContext = context).nodeType ?
+					matchContext( elem, context, xml ) :
+					matchAnyContext( elem, context, xml ) );
+			// Avoid hanging onto element (issue #299)
+			checkContext = null;
+			return ret;
+		} ];
+
+	for ( ; i < len; i++ ) {
+		if ( (matcher = Expr.relative[ tokens[i].type ]) ) {
+			matchers = [ addCombinator(elementMatcher( matchers ), matcher) ];
+		} else {
+			matcher = Expr.filter[ tokens[i].type ].apply( null, tokens[i].matches );
+
+			// Return special upon seeing a positional matcher
+			if ( matcher[ expando ] ) {
+				// Find the next relative operator (if any) for proper handling
+				j = ++i;
+				for ( ; j < len; j++ ) {
+					if ( Expr.relative[ tokens[j].type ] ) {
+						break;
+					}
+				}
+				return setMatcher(
+					i > 1 && elementMatcher( matchers ),
+					i > 1 && toSelector(
+						// If the preceding token was a descendant combinator, insert an implicit any-element `*`
+						tokens.slice( 0, i - 1 ).concat({ value: tokens[ i - 2 ].type === " " ? "*" : "" })
+					).replace( rtrim, "$1" ),
+					matcher,
+					i < j && matcherFromTokens( tokens.slice( i, j ) ),
+					j < len && matcherFromTokens( (tokens = tokens.slice( j )) ),
+					j < len && toSelector( tokens )
+				);
+			}
+			matchers.push( matcher );
+		}
+	}
+
+	return elementMatcher( matchers );
+}
+
+function matcherFromGroupMatchers( elementMatchers, setMatchers ) {
+	var bySet = setMatchers.length > 0,
+		byElement = elementMatchers.length > 0,
+		superMatcher = function( seed, context, xml, results, outermost ) {
+			var elem, j, matcher,
+				matchedCount = 0,
+				i = "0",
+				unmatched = seed && [],
+				setMatched = [],
+				contextBackup = outermostContext,
+				// We must always have either seed elements or outermost context
+				elems = seed || byElement && Expr.find["TAG"]( "*", outermost ),
+				// Use integer dirruns iff this is the outermost matcher
+				dirrunsUnique = (dirruns += contextBackup == null ? 1 : Math.random() || 0.1),
+				len = elems.length;
+
+			if ( outermost ) {
+				outermostContext = context !== document && context;
+			}
+
+			// Add elements passing elementMatchers directly to results
+			// Keep `i` a string if there are no elements so `matchedCount` will be "00" below
+			// Support: IE<9, Safari
+			// Tolerate NodeList properties (IE: "length"; Safari: <number>) matching elements by id
+			for ( ; i !== len && (elem = elems[i]) != null; i++ ) {
+				if ( byElement && elem ) {
+					j = 0;
+					while ( (matcher = elementMatchers[j++]) ) {
+						if ( matcher( elem, context, xml ) ) {
+							results.push( elem );
+							break;
+						}
+					}
+					if ( outermost ) {
+						dirruns = dirrunsUnique;
+					}
+				}
+
+				// Track unmatched elements for set filters
+				if ( bySet ) {
+					// They will have gone through all possible matchers
+					if ( (elem = !matcher && elem) ) {
+						matchedCount--;
+					}
+
+					// Lengthen the array for every element, matched or not
+					if ( seed ) {
+						unmatched.push( elem );
+					}
+				}
+			}
+
+			// Apply set filters to unmatched elements
+			matchedCount += i;
+			if ( bySet && i !== matchedCount ) {
+				j = 0;
+				while ( (matcher = setMatchers[j++]) ) {
+					matcher( unmatched, setMatched, context, xml );
+				}
+
+				if ( seed ) {
+					// Reintegrate element matches to eliminate the need for sorting
+					if ( matchedCount > 0 ) {
+						while ( i-- ) {
+							if ( !(unmatched[i] || setMatched[i]) ) {
+								setMatched[i] = pop.call( results );
+							}
+						}
+					}
+
+					// Discard index placeholder values to get only actual matches
+					setMatched = condense( setMatched );
+				}
+
+				// Add matches to results
+				push.apply( results, setMatched );
+
+				// Seedless set matches succeeding multiple successful matchers stipulate sorting
+				if ( outermost && !seed && setMatched.length > 0 &&
+					( matchedCount + setMatchers.length ) > 1 ) {
+
+					Sizzle.uniqueSort( results );
+				}
+			}
+
+			// Override manipulation of globals by nested matchers
+			if ( outermost ) {
+				dirruns = dirrunsUnique;
+				outermostContext = contextBackup;
+			}
+
+			return unmatched;
+		};
+
+	return bySet ?
+		markFunction( superMatcher ) :
+		superMatcher;
+}
+
+compile = Sizzle.compile = function( selector, match /* Internal Use Only */ ) {
+	var i,
+		setMatchers = [],
+		elementMatchers = [],
+		cached = compilerCache[ selector + " " ];
+
+	if ( !cached ) {
+		// Generate a function of recursive functions that can be used to check each element
+		if ( !match ) {
+			match = tokenize( selector );
+		}
+		i = match.length;
+		while ( i-- ) {
+			cached = matcherFromTokens( match[i] );
+			if ( cached[ expando ] ) {
+				setMatchers.push( cached );
+			} else {
+				elementMatchers.push( cached );
+			}
+		}
+
+		// Cache the compiled function
+		cached = compilerCache( selector, matcherFromGroupMatchers( elementMatchers, setMatchers ) );
+
+		// Save selector and tokenization
+		cached.selector = selector;
+	}
+	return cached;
+};
+
+/**
+ * A low-level selection function that works with Sizzle's compiled
+ *  selector functions
+ * @param {String|Function} selector A selector or a pre-compiled
+ *  selector function built with Sizzle.compile
+ * @param {Element} context
+ * @param {Array} [results]
+ * @param {Array} [seed] A set of elements to match against
+ */
+select = Sizzle.select = function( selector, context, results, seed ) {
+	var i, tokens, token, type, find,
+		compiled = typeof selector === "function" && selector,
+		match = !seed && tokenize( (selector = compiled.selector || selector) );
+
+	results = results || [];
+
+	// Try to minimize operations if there is no seed and only one group
+	if ( match.length === 1 ) {
+
+		// Take a shortcut and set the context if the root selector is an ID
+		tokens = match[0] = match[0].slice( 0 );
+		if ( tokens.length > 2 && (token = tokens[0]).type === "ID" &&
+				support.getById && context.nodeType === 9 && documentIsHTML &&
+				Expr.relative[ tokens[1].type ] ) {
+
+			context = ( Expr.find["ID"]( token.matches[0].replace(runescape, funescape), context ) || [] )[0];
+			if ( !context ) {
+				return results;
+
+			// Precompiled matchers will still verify ancestry, so step up a level
+			} else if ( compiled ) {
+				context = context.parentNode;
+			}
+
+			selector = selector.slice( tokens.shift().value.length );
+		}
+
+		// Fetch a seed set for right-to-left matching
+		i = matchExpr["needsContext"].test( selector ) ? 0 : tokens.length;
+		while ( i-- ) {
+			token = tokens[i];
+
+			// Abort if we hit a combinator
+			if ( Expr.relative[ (type = token.type) ] ) {
+				break;
+			}
+			if ( (find = Expr.find[ type ]) ) {
+				// Search, expanding context for leading sibling combinators
+				if ( (seed = find(
+					token.matches[0].replace( runescape, funescape ),
+					rsibling.test( tokens[0].type ) && testContext( context.parentNode ) || context
+				)) ) {
+
+					// If seed is empty or no tokens remain, we can return early
+					tokens.splice( i, 1 );
+					selector = seed.length && toSelector( tokens );
+					if ( !selector ) {
+						push.apply( results, seed );
+						return results;
+					}
+
+					break;
+				}
+			}
+		}
+	}
+
+	// Compile and execute a filtering function if one is not provided
+	// Provide `match` to avoid retokenization if we modified the selector above
+	( compiled || compile( selector, match ) )(
+		seed,
+		context,
+		!documentIsHTML,
+		results,
+		rsibling.test( selector ) && testContext( context.parentNode ) || context
+	);
+	return results;
+};
+
+// One-time assignments
+
+// Sort stability
+support.sortStable = expando.split("").sort( sortOrder ).join("") === expando;
+
+// Support: Chrome 14-35+
+// Always assume duplicates if they aren't passed to the comparison function
+support.detectDuplicates = !!hasDuplicate;
+
+// Initialize against the default document
+setDocument();
+
+// Support: Webkit<537.32 - Safari 6.0.3/Chrome 25 (fixed in Chrome 27)
+// Detached nodes confoundingly follow *each other*
+support.sortDetached = assert(function( div1 ) {
+	// Should return 1, but returns 4 (following)
+	return div1.compareDocumentPosition( document.createElement("div") ) & 1;
+});
+
+// Support: IE<8
+// Prevent attribute/property "interpolation"
+// http://msdn.microsoft.com/en-us/library/ms536429%28VS.85%29.aspx
+if ( !assert(function( div ) {
+	div.innerHTML = "<a href='#'></a>";
+	return div.firstChild.getAttribute("href") === "#" ;
+}) ) {
+	addHandle( "type|href|height|width", function( elem, name, isXML ) {
+		if ( !isXML ) {
+			return elem.getAttribute( name, name.toLowerCase() === "type" ? 1 : 2 );
+		}
+	});
+}
+
+// Support: IE<9
+// Use defaultValue in place of getAttribute("value")
+if ( !support.attributes || !assert(function( div ) {
+	div.innerHTML = "<input/>";
+	div.firstChild.setAttribute( "value", "" );
+	return div.firstChild.getAttribute( "value" ) === "";
+}) ) {
+	addHandle( "value", function( elem, name, isXML ) {
+		if ( !isXML && elem.nodeName.toLowerCase() === "input" ) {
+			return elem.defaultValue;
+		}
+	});
+}
+
+// Support: IE<9
+// Use getAttributeNode to fetch booleans when getAttribute lies
+if ( !assert(function( div ) {
+	return div.getAttribute("disabled") == null;
+}) ) {
+	addHandle( booleans, function( elem, name, isXML ) {
+		var val;
+		if ( !isXML ) {
+			return elem[ name ] === true ? name.toLowerCase() :
+					(val = elem.getAttributeNode( name )) && val.specified ?
+					val.value :
+				null;
+		}
+	});
+}
+
+return Sizzle;
+
+})( window );
+
+
+
+jQuery.find = Sizzle;
+jQuery.expr = Sizzle.selectors;
+jQuery.expr[":"] = jQuery.expr.pseudos;
+jQuery.unique = Sizzle.uniqueSort;
+jQuery.text = Sizzle.getText;
+jQuery.isXMLDoc = Sizzle.isXML;
+jQuery.contains = Sizzle.contains;
+
+
+
+var rneedsContext = jQuery.expr.match.needsContext;
+
+var rsingleTag = (/^<(\w+)\s*\/?>(?:<\/\1>|)$/);
+
+
+
+var risSimple = /^.[^:#\[\.,]*$/;
+
+// Implement the identical functionality for filter and not
+function winnow( elements, qualifier, not ) {
+	if ( jQuery.isFunction( qualifier ) ) {
+		return jQuery.grep( elements, function( elem, i ) {
+			/* jshint -W018 */
+			return !!qualifier.call( elem, i, elem ) !== not;
+		});
+
+	}
+
+	if ( qualifier.nodeType ) {
+		return jQuery.grep( elements, function( elem ) {
+			return ( elem === qualifier ) !== not;
+		});
+
+	}
+
+	if ( typeof qualifier === "string" ) {
+		if ( risSimple.test( qualifier ) ) {
+			return jQuery.filter( qualifier, elements, not );
+		}
+
+		qualifier = jQuery.filter( qualifier, elements );
+	}
+
+	return jQuery.grep( elements, function( elem ) {
+		return ( indexOf.call( qualifier, elem ) >= 0 ) !== not;
+	});
+}
+
+jQuery.filter = function( expr, elems, not ) {
+	var elem = elems[ 0 ];
+
+	if ( not ) {
+		expr = ":not(" + expr + ")";
+	}
+
+	return elems.length === 1 && elem.nodeType === 1 ?
+		jQuery.find.matchesSelector( elem, expr ) ? [ elem ] : [] :
+		jQuery.find.matches( expr, jQuery.grep( elems, function( elem ) {
+			return elem.nodeType === 1;
+		}));
+};
+
+jQuery.fn.extend({
+	find: function( selector ) {
+		var i,
+			len = this.length,
+			ret = [],
+			self = this;
+
+		if ( typeof selector !== "string" ) {
+			return this.pushStack( jQuery( selector ).filter(function() {
+				for ( i = 0; i < len; i++ ) {
+					if ( jQuery.contains( self[ i ], this ) ) {
+						return true;
+					}
+				}
+			}) );
+		}
+
+		for ( i = 0; i < len; i++ ) {
+			jQuery.find( selector, self[ i ], ret );
+		}
+
+		// Needed because $( selector, context ) becomes $( context ).find( selector )
+		ret = this.pushStack( len > 1 ? jQuery.unique( ret ) : ret );
+		ret.selector = this.selector ? this.selector + " " + selector : selector;
+		return ret;
+	},
+	filter: function( selector ) {
+		return this.pushStack( winnow(this, selector || [], false) );
+	},
+	not: function( selector ) {
+		return this.pushStack( winnow(this, selector || [], true) );
+	},
+	is: function( selector ) {
+		return !!winnow(
+			this,
+
+			// If this is a positional/relative selector, check membership in the returned set
+			// so $("p:first").is("p:last") won't return true for a doc with two "p".
+			typeof selector === "string" && rneedsContext.test( selector ) ?
+				jQuery( selector ) :
+				selector || [],
+			false
+		).length;
+	}
+});
+
+
+// Initialize a jQuery object
+
+
+// A central reference to the root jQuery(document)
+var rootjQuery,
+
+	// A simple way to check for HTML strings
+	// Prioritize #id over <tag> to avoid XSS via location.hash (#9521)
+	// Strict HTML recognition (#11290: must start with <)
+	rquickExpr = /^(?:\s*(<[\w\W]+>)[^>]*|#([\w-]*))$/,
+
+	init = jQuery.fn.init = function( selector, context ) {
+		var match, elem;
+
+		// HANDLE: $(""), $(null), $(undefined), $(false)
+		if ( !selector ) {
+			return this;
+		}
+
+		// Handle HTML strings
+		if ( typeof selector === "string" ) {
+			if ( selector[0] === "<" && selector[ selector.length - 1 ] === ">" && selector.length >= 3 ) {
+				// Assume that strings that start and end with <> are HTML and skip the regex check
+				match = [ null, selector, null ];
+
+			} else {
+				match = rquickExpr.exec( selector );
+			}
+
+			// Match html or make sure no context is specified for #id
+			if ( match && (match[1] || !context) ) {
+
+				// HANDLE: $(html) -> $(array)
+				if ( match[1] ) {
+					context = context instanceof jQuery ? context[0] : context;
+
+					// Option to run scripts is true for back-compat
+					// Intentionally let the error be thrown if parseHTML is not present
+					jQuery.merge( this, jQuery.parseHTML(
+						match[1],
+						context && context.nodeType ? context.ownerDocument || context : document,
+						true
+					) );
+
+					// HANDLE: $(html, props)
+					if ( rsingleTag.test( match[1] ) && jQuery.isPlainObject( context ) ) {
+						for ( match in context ) {
+							// Properties of context are called as methods if possible
+							if ( jQuery.isFunction( this[ match ] ) ) {
+								this[ match ]( context[ match ] );
+
+							// ...and otherwise set as attributes
+							} else {
+								this.attr( match, context[ match ] );
+							}
+						}
+					}
+
+					return this;
+
+				// HANDLE: $(#id)
+				} else {
+					elem = document.getElementById( match[2] );
+
+					// Support: Blackberry 4.6
+					// gEBID returns nodes no longer in the document (#6963)
+					if ( elem && elem.parentNode ) {
+						// Inject the element directly into the jQuery object
+						this.length = 1;
+						this[0] = elem;
+					}
+
+					this.context = document;
+					this.selector = selector;
+					return this;
+				}
+
+			// HANDLE: $(expr, $(...))
+			} else if ( !context || context.jquery ) {
+				return ( context || rootjQuery ).find( selector );
+
+			// HANDLE: $(expr, context)
+			// (which is just equivalent to: $(context).find(expr)
+			} else {
+				return this.constructor( context ).find( selector );
+			}
+
+		// HANDLE: $(DOMElement)
+		} else if ( selector.nodeType ) {
+			this.context = this[0] = selector;
+			this.length = 1;
+			return this;
+
+		// HANDLE: $(function)
+		// Shortcut for document ready
+		} else if ( jQuery.isFunction( selector ) ) {
+			return typeof rootjQuery.ready !== "undefined" ?
+				rootjQuery.ready( selector ) :
+				// Execute immediately if ready is not present
+				selector( jQuery );
+		}
+
+		if ( selector.selector !== undefined ) {
+			this.selector = selector.selector;
+			this.context = selector.context;
+		}
+
+		return jQuery.makeArray( selector, this );
+	};
+
+// Give the init function the jQuery prototype for later instantiation
+init.prototype = jQuery.fn;
+
+// Initialize central reference
+rootjQuery = jQuery( document );
+
+
+var rparentsprev = /^(?:parents|prev(?:Until|All))/,
+	// Methods guaranteed to produce a unique set when starting from a unique set
+	guaranteedUnique = {
+		children: true,
+		contents: true,
+		next: true,
+		prev: true
+	};
+
+jQuery.extend({
+	dir: function( elem, dir, until ) {
+		var matched = [],
+			truncate = until !== undefined;
+
+		while ( (elem = elem[ dir ]) && elem.nodeType !== 9 ) {
+			if ( elem.nodeType === 1 ) {
+				if ( truncate && jQuery( elem ).is( until ) ) {
+					break;
+				}
+				matched.push( elem );
+			}
+		}
+		return matched;
+	},
+
+	sibling: function( n, elem ) {
+		var matched = [];
+
+		for ( ; n; n = n.nextSibling ) {
+			if ( n.nodeType === 1 && n !== elem ) {
+				matched.push( n );
+			}
+		}
+
+		return matched;
+	}
+});
+
+jQuery.fn.extend({
+	has: function( target ) {
+		var targets = jQuery( target, this ),
+			l = targets.length;
+
+		return this.filter(function() {
+			var i = 0;
+			for ( ; i < l; i++ ) {
+				if ( jQuery.contains( this, targets[i] ) ) {
+					return true;
+				}
+			}
+		});
+	},
+
+	closest: function( selectors, context ) {
+		var cur,
+			i = 0,
+			l = this.length,
+			matched = [],
+			pos = rneedsContext.test( selectors ) || typeof selectors !== "string" ?
+				jQuery( selectors, context || this.context ) :
+				0;
+
+		for ( ; i < l; i++ ) {
+			for ( cur = this[i]; cur && cur !== context; cur = cur.parentNode ) {
+				// Always skip document fragments
+				if ( cur.nodeType < 11 && (pos ?
+					pos.index(cur) > -1 :
+
+					// Don't pass non-elements to Sizzle
+					cur.nodeType === 1 &&
+						jQuery.find.matchesSelector(cur, selectors)) ) {
+
+					matched.push( cur );
+					break;
+				}
+			}
+		}
+
+		return this.pushStack( matched.length > 1 ? jQuery.unique( matched ) : matched );
+	},
+
+	// Determine the position of an element within the set
+	index: function( elem ) {
+
+		// No argument, return index in parent
+		if ( !elem ) {
+			return ( this[ 0 ] && this[ 0 ].parentNode ) ? this.first().prevAll().length : -1;
+		}
+
+		// Index in selector
+		if ( typeof elem === "string" ) {
+			return indexOf.call( jQuery( elem ), this[ 0 ] );
+		}
+
+		// Locate the position of the desired element
+		return indexOf.call( this,
+
+			// If it receives a jQuery object, the first element is used
+			elem.jquery ? elem[ 0 ] : elem
+		);
+	},
+
+	add: function( selector, context ) {
+		return this.pushStack(
+			jQuery.unique(
+				jQuery.merge( this.get(), jQuery( selector, context ) )
+			)
+		);
+	},
+
+	addBack: function( selector ) {
+		return this.add( selector == null ?
+			this.prevObject : this.prevObject.filter(selector)
+		);
+	}
+});
+
+function sibling( cur, dir ) {
+	while ( (cur = cur[dir]) && cur.nodeType !== 1 ) {}
+	return cur;
+}
+
+jQuery.each({
+	parent: function( elem ) {
+		var parent = elem.parentNode;
+		return parent && parent.nodeType !== 11 ? parent : null;
+	},
+	parents: function( elem ) {
+		return jQuery.dir( elem, "parentNode" );
+	},
+	parentsUntil: function( elem, i, until ) {
+		return jQuery.dir( elem, "parentNode", until );
+	},
+	next: function( elem ) {
+		return sibling( elem, "nextSibling" );
+	},
+	prev: function( elem ) {
+		return sibling( elem, "previousSibling" );
+	},
+	nextAll: function( elem ) {
+		return jQuery.dir( elem, "nextSibling" );
+	},
+	prevAll: function( elem ) {
+		return jQuery.dir( elem, "previousSibling" );
+	},
+	nextUntil: function( elem, i, until ) {
+		return jQuery.dir( elem, "nextSibling", until );
+	},
+	prevUntil: function( elem, i, until ) {
+		return jQuery.dir( elem, "previousSibling", until );
+	},
+	siblings: function( elem ) {
+		return jQuery.sibling( ( elem.parentNode || {} ).firstChild, elem );
+	},
+	children: function( elem ) {
+		return jQuery.sibling( elem.firstChild );
+	},
+	contents: function( elem ) {
+		return elem.contentDocument || jQuery.merge( [], elem.childNodes );
+	}
+}, function( name, fn ) {
+	jQuery.fn[ name ] = function( until, selector ) {
+		var matched = jQuery.map( this, fn, until );
+
+		if ( name.slice( -5 ) !== "Until" ) {
+			selector = until;
+		}
+
+		if ( selector && typeof selector === "string" ) {
+			matched = jQuery.filter( selector, matched );
+		}
+
+		if ( this.length > 1 ) {
+			// Remove duplicates
+			if ( !guaranteedUnique[ name ] ) {
+				jQuery.unique( matched );
+			}
+
+			// Reverse order for parents* and prev-derivatives
+			if ( rparentsprev.test( name ) ) {
+				matched.reverse();
+			}
+		}
+
+		return this.pushStack( matched );
+	};
+});
+var rnotwhite = (/\S+/g);
+
+
+
+// String to Object options format cache
+var optionsCache = {};
+
+// Convert String-formatted options into Object-formatted ones and store in cache
+function createOptions( options ) {
+	var object = optionsCache[ options ] = {};
+	jQuery.each( options.match( rnotwhite ) || [], function( _, flag ) {
+		object[ flag ] = true;
+	});
+	return object;
+}
+
+/*
+ * Create a callback list using the following parameters:
+ *
+ *	options: an optional list of space-separated options that will change how
+ *			the callback list behaves or a more traditional option object
+ *
+ * By default a callback list will act like an event callback list and can be
+ * "fired" multiple times.
+ *
+ * Possible options:
+ *
+ *	once:			will ensure the callback list can only be fired once (like a Deferred)
+ *
+ *	memory:			will keep track of previous values and will call any callback added
+ *					after the list has been fired right away with the latest "memorized"
+ *					values (like a Deferred)
+ *
+ *	unique:			will ensure a callback can only be added once (no duplicate in the list)
+ *
+ *	stopOnFalse:	interrupt callings when a callback returns false
+ *
+ */
+jQuery.Callbacks = function( options ) {
+
+	// Convert options from String-formatted to Object-formatted if needed
+	// (we check in cache first)
+	options = typeof options === "string" ?
+		( optionsCache[ options ] || createOptions( options ) ) :
+		jQuery.extend( {}, options );
+
+	var // Last fire value (for non-forgettable lists)
+		memory,
+		// Flag to know if list was already fired
+		fired,
+		// Flag to know if list is currently firing
+		firing,
+		// First callback to fire (used internally by add and fireWith)
+		firingStart,
+		// End of the loop when firing
+		firingLength,
+		// Index of currently firing callback (modified by remove if needed)
+		firingIndex,
+		// Actual callback list
+		list = [],
+		// Stack of fire calls for repeatable lists
+		stack = !options.once && [],
+		// Fire callbacks
+		fire = function( data ) {
+			memory = options.memory && data;
+			fired = true;
+			firingIndex = firingStart || 0;
+			firingStart = 0;
+			firingLength = list.length;
+			firing = true;
+			for ( ; list && firingIndex < firingLength; firingIndex++ ) {
+				if ( list[ firingIndex ].apply( data[ 0 ], data[ 1 ] ) === false && options.stopOnFalse ) {
+					memory = false; // To prevent further calls using add
+					break;
+				}
+			}
+			firing = false;
+			if ( list ) {
+				if ( stack ) {
+					if ( stack.length ) {
+						fire( stack.shift() );
+					}
+				} else if ( memory ) {
+					list = [];
+				} else {
+					self.disable();
+				}
+			}
+		},
+		// Actual Callbacks object
+		self = {
+			// Add a callback or a collection of callbacks to the list
+			add: function() {
+				if ( list ) {
+					// First, we save the current length
+					var start = list.length;
+					(function add( args ) {
+						jQuery.each( args, function( _, arg ) {
+							var type = jQuery.type( arg );
+							if ( type === "function" ) {
+								if ( !options.unique || !self.has( arg ) ) {
+									list.push( arg );
+								}
+							} else if ( arg && arg.length && type !== "string" ) {
+								// Inspect recursively
+								add( arg );
+							}
+						});
+					})( arguments );
+					// Do we need to add the callbacks to the
+					// current firing batch?
+					if ( firing ) {
+						firingLength = list.length;
+					// With memory, if we're not firing then
+					// we should call right away
+					} else if ( memory ) {
+						firingStart = start;
+						fire( memory );
+					}
+				}
+				return this;
+			},
+			// Remove a callback from the list
+			remove: function() {
+				if ( list ) {
+					jQuery.each( arguments, function( _, arg ) {
+						var index;
+						while ( ( index = jQuery.inArray( arg, list, index ) ) > -1 ) {
+							list.splice( index, 1 );
+							// Handle firing indexes
+							if ( firing ) {
+								if ( index <= firingLength ) {
+									firingLength--;
+								}
+								if ( index <= firingIndex ) {
+									firingIndex--;
+								}
+							}
+						}
+					});
+				}
+				return this;
+			},
+			// Check if a given callback is in the list.
+			// If no argument is given, return whether or not list has callbacks attached.
+			has: function( fn ) {
+				return fn ? jQuery.inArray( fn, list ) > -1 : !!( list && list.length );
+			},
+			// Remove all callbacks from the list
+			empty: function() {
+				list = [];
+				firingLength = 0;
+				return this;
+			},
+			// Have the list do nothing anymore
+			disable: function() {
+				list = stack = memory = undefined;
+				return this;
+			},
+			// Is it disabled?
+			disabled: function() {
+				return !list;
+			},
+			// Lock the list in its current state
+			lock: function() {
+				stack = undefined;
+				if ( !memory ) {
+					self.disable();
+				}
+				return this;
+			},
+			// Is it locked?
+			locked: function() {
+				return !stack;
+			},
+			// Call all callbacks with the given context and arguments
+			fireWith: function( context, args ) {
+				if ( list && ( !fired || stack ) ) {
+					args = args || [];
+					args = [ context, args.slice ? args.slice() : args ];
+					if ( firing ) {
+						stack.push( args );
+					} else {
+						fire( args );
+					}
+				}
+				return this;
+			},
+			// Call all the callbacks with the given arguments
+			fire: function() {
+				self.fireWith( this, arguments );
+				return this;
+			},
+			// To know if the callbacks have already been called at least once
+			fired: function() {
+				return !!fired;
+			}
+		};
+
+	return self;
+};
+
+
+jQuery.extend({
+
+	Deferred: function( func ) {
+		var tuples = [
+				// action, add listener, listener list, final state
+				[ "resolve", "done", jQuery.Callbacks("once memory"), "resolved" ],
+				[ "reject", "fail", jQuery.Callbacks("once memory"), "rejected" ],
+				[ "notify", "progress", jQuery.Callbacks("memory") ]
+			],
+			state = "pending",
+			promise = {
+				state: function() {
+					return state;
+				},
+				always: function() {
+					deferred.done( arguments ).fail( arguments );
+					return this;
+				},
+				then: function( /* fnDone, fnFail, fnProgress */ ) {
+					var fns = arguments;
+					return jQuery.Deferred(function( newDefer ) {
+						jQuery.each( tuples, function( i, tuple ) {
+							var fn = jQuery.isFunction( fns[ i ] ) && fns[ i ];
+							// deferred[ done | fail | progress ] for forwarding actions to newDefer
+							deferred[ tuple[1] ](function() {
+								var returned = fn && fn.apply( this, arguments );
+								if ( returned && jQuery.isFunction( returned.promise ) ) {
+									returned.promise()
+										.done( newDefer.resolve )
+										.fail( newDefer.reject )
+										.progress( newDefer.notify );
+								} else {
+									newDefer[ tuple[ 0 ] + "With" ]( this === promise ? newDefer.promise() : this, fn ? [ returned ] : arguments );
+								}
+							});
+						});
+						fns = null;
+					}).promise();
+				},
+				// Get a promise for this deferred
+				// If obj is provided, the promise aspect is added to the object
+				promise: function( obj ) {
+					return obj != null ? jQuery.extend( obj, promise ) : promise;
+				}
+			},
+			deferred = {};
+
+		// Keep pipe for back-compat
+		promise.pipe = promise.then;
+
+		// Add list-specific methods
+		jQuery.each( tuples, function( i, tuple ) {
+			var list = tuple[ 2 ],
+				stateString = tuple[ 3 ];
+
+			// promise[ done | fail | progress ] = list.add
+			promise[ tuple[1] ] = list.add;
+
+			// Handle state
+			if ( stateString ) {
+				list.add(function() {
+					// state = [ resolved | rejected ]
+					state = stateString;
+
+				// [ reject_list | resolve_list ].disable; progress_list.lock
+				}, tuples[ i ^ 1 ][ 2 ].disable, tuples[ 2 ][ 2 ].lock );
+			}
+
+			// deferred[ resolve | reject | notify ]
+			deferred[ tuple[0] ] = function() {
+				deferred[ tuple[0] + "With" ]( this === deferred ? promise : this, arguments );
+				return this;
+			};
+			deferred[ tuple[0] + "With" ] = list.fireWith;
+		});
+
+		// Make the deferred a promise
+		promise.promise( deferred );
+
+		// Call given func if any
+		if ( func ) {
+			func.call( deferred, deferred );
+		}
+
+		// All done!
+		return deferred;
+	},
+
+	// Deferred helper
+	when: function( subordinate /* , ..., subordinateN */ ) {
+		var i = 0,
+			resolveValues = slice.call( arguments ),
+			length = resolveValues.length,
+
+			// the count of uncompleted subordinates
+			remaining = length !== 1 || ( subordinate && jQuery.isFunction( subordinate.promise ) ) ? length : 0,
+
+			// the master Deferred. If resolveValues consist of only a single Deferred, just use that.
+			deferred = remaining === 1 ? subordinate : jQuery.Deferred(),
+
+			// Update function for both resolve and progress values
+			updateFunc = function( i, contexts, values ) {
+				return function( value ) {
+					contexts[ i ] = this;
+					values[ i ] = arguments.length > 1 ? slice.call( arguments ) : value;
+					if ( values === progressValues ) {
+						deferred.notifyWith( contexts, values );
+					} else if ( !( --remaining ) ) {
+						deferred.resolveWith( contexts, values );
+					}
+				};
+			},
+
+			progressValues, progressContexts, resolveContexts;
+
+		// Add listeners to Deferred subordinates; treat others as resolved
+		if ( length > 1 ) {
+			progressValues = new Array( length );
+			progressContexts = new Array( length );
+			resolveContexts = new Array( length );
+			for ( ; i < length; i++ ) {
+				if ( resolveValues[ i ] && jQuery.isFunction( resolveValues[ i ].promise ) ) {
+					resolveValues[ i ].promise()
+						.done( updateFunc( i, resolveContexts, resolveValues ) )
+						.fail( deferred.reject )
+						.progress( updateFunc( i, progressContexts, progressValues ) );
+				} else {
+					--remaining;
+				}
+			}
+		}
+
+		// If we're not waiting on anything, resolve the master
+		if ( !remaining ) {
+			deferred.resolveWith( resolveContexts, resolveValues );
+		}
+
+		return deferred.promise();
+	}
+});
+
+
+// The deferred used on DOM ready
+var readyList;
+
+jQuery.fn.ready = function( fn ) {
+	// Add the callback
+	jQuery.ready.promise().done( fn );
+
+	return this;
+};
+
+jQuery.extend({
+	// Is the DOM ready to be used? Set to true once it occurs.
+	isReady: false,
+
+	// A counter to track how many items to wait for before
+	// the ready event fires. See #6781
+	readyWait: 1,
+
+	// Hold (or release) the ready event
+	holdReady: function( hold ) {
+		if ( hold ) {
+			jQuery.readyWait++;
+		} else {
+			jQuery.ready( true );
+		}
+	},
+
+	// Handle when the DOM is ready
+	ready: function( wait ) {
+
+		// Abort if there are pending holds or we're already ready
+		if ( wait === true ? --jQuery.readyWait : jQuery.isReady ) {
+			return;
+		}
+
+		// Remember that the DOM is ready
+		jQuery.isReady = true;
+
+		// If a normal DOM Ready event fired, decrement, and wait if need be
+		if ( wait !== true && --jQuery.readyWait > 0 ) {
+			return;
+		}
+
+		// If there are functions bound, to execute
+		readyList.resolveWith( document, [ jQuery ] );
+
+		// Trigger any bound ready events
+		if ( jQuery.fn.triggerHandler ) {
+			jQuery( document ).triggerHandler( "ready" );
+			jQuery( document ).off( "ready" );
+		}
+	}
+});
+
+/**
+ * The ready event handler and self cleanup method
+ */
+function completed() {
+	document.removeEventListener( "DOMContentLoaded", completed, false );
+	window.removeEventListener( "load", completed, false );
+	jQuery.ready();
+}
+
+jQuery.ready.promise = function( obj ) {
+	if ( !readyList ) {
+
+		readyList = jQuery.Deferred();
+
+		// Catch cases where $(document).ready() is called after the browser event has already occurred.
+		// We once tried to use readyState "interactive" here, but it caused issues like the one
+		// discovered by ChrisS here: http://bugs.jquery.com/ticket/12282#comment:15
+		if ( document.readyState === "complete" ) {
+			// Handle it asynchronously to allow scripts the opportunity to delay ready
+			setTimeout( jQuery.ready );
+
+		} else {
+
+			// Use the handy event callback
+			document.addEventListener( "DOMContentLoaded", completed, false );
+
+			// A fallback to window.onload, that will always work
+			window.addEventListener( "load", completed, false );
+		}
+	}
+	return readyList.promise( obj );
+};
+
+// Kick off the DOM ready check even if the user does not
+jQuery.ready.promise();
+
+
+
+
+// Multifunctional method to get and set values of a collection
+// The value/s can optionally be executed if it's a function
+var access = jQuery.access = function( elems, fn, key, value, chainable, emptyGet, raw ) {
+	var i = 0,
+		len = elems.length,
+		bulk = key == null;
+
+	// Sets many values
+	if ( jQuery.type( key ) === "object" ) {
+		chainable = true;
+		for ( i in key ) {
+			jQuery.access( elems, fn, i, key[i], true, emptyGet, raw );
+		}
+
+	// Sets one value
+	} else if ( value !== undefined ) {
+		chainable = true;
+
+		if ( !jQuery.isFunction( value ) ) {
+			raw = true;
+		}
+
+		if ( bulk ) {
+			// Bulk operations run against the entire set
+			if ( raw ) {
+				fn.call( elems, value );
+				fn = null;
+
+			// ...except when executing function values
+			} else {
+				bulk = fn;
+				fn = function( elem, key, value ) {
+					return bulk.call( jQuery( elem ), value );
+				};
+			}
+		}
+
+		if ( fn ) {
+			for ( ; i < len; i++ ) {
+				fn( elems[i], key, raw ? value : value.call( elems[i], i, fn( elems[i], key ) ) );
+			}
+		}
+	}
+
+	return chainable ?
+		elems :
+
+		// Gets
+		bulk ?
+			fn.call( elems ) :
+			len ? fn( elems[0], key ) : emptyGet;
+};
+
+
+/**
+ * Determines whether an object can have data
+ */
+jQuery.acceptData = function( owner ) {
+	// Accepts only:
+	//  - Node
+	//    - Node.ELEMENT_NODE
+	//    - Node.DOCUMENT_NODE
+	//  - Object
+	//    - Any
+	/* jshint -W018 */
+	return owner.nodeType === 1 || owner.nodeType === 9 || !( +owner.nodeType );
+};
+
+
+function Data() {
+	// Support: Android<4,
+	// Old WebKit does not have Object.preventExtensions/freeze method,
+	// return new empty object instead with no [[set]] accessor
+	Object.defineProperty( this.cache = {}, 0, {
+		get: function() {
+			return {};
+		}
+	});
+
+	this.expando = jQuery.expando + Data.uid++;
+}
+
+Data.uid = 1;
+Data.accepts = jQuery.acceptData;
+
+Data.prototype = {
+	key: function( owner ) {
+		// We can accept data for non-element nodes in modern browsers,
+		// but we should not, see #8335.
+		// Always return the key for a frozen object.
+		if ( !Data.accepts( owner ) ) {
+			return 0;
+		}
+
+		var descriptor = {},
+			// Check if the owner object already has a cache key
+			unlock = owner[ this.expando ];
+
+		// If not, create one
+		if ( !unlock ) {
+			unlock = Data.uid++;
+
+			// Secure it in a non-enumerable, non-writable property
+			try {
+				descriptor[ this.expando ] = { value: unlock };
+				Object.defineProperties( owner, descriptor );
+
+			// Support: Android<4
+			// Fallback to a less secure definition
+			} catch ( e ) {
+				descriptor[ this.expando ] = unlock;
+				jQuery.extend( owner, descriptor );
+			}
+		}
+
+		// Ensure the cache object
+		if ( !this.cache[ unlock ] ) {
+			this.cache[ unlock ] = {};
+		}
+
+		return unlock;
+	},
+	set: function( owner, data, value ) {
+		var prop,
+			// There may be an unlock assigned to this node,
+			// if there is no entry for this "owner", create one inline
+			// and set the unlock as though an owner entry had always existed
+			unlock = this.key( owner ),
+			cache = this.cache[ unlock ];
+
+		// Handle: [ owner, key, value ] args
+		if ( typeof data === "string" ) {
+			cache[ data ] = value;
+
+		// Handle: [ owner, { properties } ] args
+		} else {
+			// Fresh assignments by object are shallow copied
+			if ( jQuery.isEmptyObject( cache ) ) {
+				jQuery.extend( this.cache[ unlock ], data );
+			// Otherwise, copy the properties one-by-one to the cache object
+			} else {
+				for ( prop in data ) {
+					cache[ prop ] = data[ prop ];
+				}
+			}
+		}
+		return cache;
+	},
+	get: function( owner, key ) {
+		// Either a valid cache is found, or will be created.
+		// New caches will be created and the unlock returned,
+		// allowing direct access to the newly created
+		// empty data object. A valid owner object must be provided.
+		var cache = this.cache[ this.key( owner ) ];
+
+		return key === undefined ?
+			cache : cache[ key ];
+	},
+	access: function( owner, key, value ) {
+		var stored;
+		// In cases where either:
+		//
+		//   1. No key was specified
+		//   2. A string key was specified, but no value provided
+		//
+		// Take the "read" path and allow the get method to determine
+		// which value to return, respectively either:
+		//
+		//   1. The entire cache object
+		//   2. The data stored at the key
+		//
+		if ( key === undefined ||
+				((key && typeof key === "string") && value === undefined) ) {
+
+			stored = this.get( owner, key );
+
+			return stored !== undefined ?
+				stored : this.get( owner, jQuery.camelCase(key) );
+		}
+
+		// [*]When the key is not a string, or both a key and value
+		// are specified, set or extend (existing objects) with either:
+		//
+		//   1. An object of properties
+		//   2. A key and value
+		//
+		this.set( owner, key, value );
+
+		// Since the "set" path can have two possible entry points
+		// return the expected data based on which path was taken[*]
+		return value !== undefined ? value : key;
+	},
+	remove: function( owner, key ) {
+		var i, name, camel,
+			unlock = this.key( owner ),
+			cache = this.cache[ unlock ];
+
+		if ( key === undefined ) {
+			this.cache[ unlock ] = {};
+
+		} else {
+			// Support array or space separated string of keys
+			if ( jQuery.isArray( key ) ) {
+				// If "name" is an array of keys...
+				// When data is initially created, via ("key", "val") signature,
+				// keys will be converted to camelCase.
+				// Since there is no way to tell _how_ a key was added, remove
+				// both plain key and camelCase key. #12786
+				// This will only penalize the array argument path.
+				name = key.concat( key.map( jQuery.camelCase ) );
+			} else {
+				camel = jQuery.camelCase( key );
+				// Try the string as a key before any manipulation
+				if ( key in cache ) {
+					name = [ key, camel ];
+				} else {
+					// If a key with the spaces exists, use it.
+					// Otherwise, create an array by matching non-whitespace
+					name = camel;
+					name = name in cache ?
+						[ name ] : ( name.match( rnotwhite ) || [] );
+				}
+			}
+
+			i = name.length;
+			while ( i-- ) {
+				delete cache[ name[ i ] ];
+			}
+		}
+	},
+	hasData: function( owner ) {
+		return !jQuery.isEmptyObject(
+			this.cache[ owner[ this.expando ] ] || {}
+		);
+	},
+	discard: function( owner ) {
+		if ( owner[ this.expando ] ) {
+			delete this.cache[ owner[ this.expando ] ];
+		}
+	}
+};
+var data_priv = new Data();
+
+var data_user = new Data();
+
+
+
+//	Implementation Summary
+//
+//	1. Enforce API surface and semantic compatibility with 1.9.x branch
+//	2. Improve the module's maintainability by reducing the storage
+//		paths to a single mechanism.
+//	3. Use the same single mechanism to support "private" and "user" data.
+//	4. _Never_ expose "private" data to user code (TODO: Drop _data, _removeData)
+//	5. Avoid exposing implementation details on user objects (eg. expando properties)
+//	6. Provide a clear path for implementation upgrade to WeakMap in 2014
+
+var rbrace = /^(?:\{[\w\W]*\}|\[[\w\W]*\])$/,
+	rmultiDash = /([A-Z])/g;
+
+function dataAttr( elem, key, data ) {
+	var name;
+
+	// If nothing was found internally, try to fetch any
+	// data from the HTML5 data-* attribute
+	if ( data === undefined && elem.nodeType === 1 ) {
+		name = "data-" + key.replace( rmultiDash, "-$1" ).toLowerCase();
+		data = elem.getAttribute( name );
+
+		if ( typeof data === "string" ) {
+			try {
+				data = data === "true" ? true :
+					data === "false" ? false :
+					data === "null" ? null :
+					// Only convert to a number if it doesn't change the string
+					+data + "" === data ? +data :
+					rbrace.test( data ) ? jQuery.parseJSON( data ) :
+					data;
+			} catch( e ) {}
+
+			// Make sure we set the data so it isn't changed later
+			data_user.set( elem, key, data );
+		} else {
+			data = undefined;
+		}
+	}
+	return data;
+}
+
+jQuery.extend({
+	hasData: function( elem ) {
+		return data_user.hasData( elem ) || data_priv.hasData( elem );
+	},
+
+	data: function( elem, name, data ) {
+		return data_user.access( elem, name, data );
+	},
+
+	removeData: function( elem, name ) {
+		data_user.remove( elem, name );
+	},
+
+	// TODO: Now that all calls to _data and _removeData have been replaced
+	// with direct calls to data_priv methods, these can be deprecated.
+	_data: function( elem, name, data ) {
+		return data_priv.access( elem, name, data );
+	},
+
+	_removeData: function( elem, name ) {
+		data_priv.remove( elem, name );
+	}
+});
+
+jQuery.fn.extend({
+	data: function( key, value ) {
+		var i, name, data,
+			elem = this[ 0 ],
+			attrs = elem && elem.attributes;
+
+		// Gets all values
+		if ( key === undefined ) {
+			if ( this.length ) {
+				data = data_user.get( elem );
+
+				if ( elem.nodeType === 1 && !data_priv.get( elem, "hasDataAttrs" ) ) {
+					i = attrs.length;
+					while ( i-- ) {
+
+						// Support: IE11+
+						// The attrs elements can be null (#14894)
+						if ( attrs[ i ] ) {
+							name = attrs[ i ].name;
+							if ( name.indexOf( "data-" ) === 0 ) {
+								name = jQuery.camelCase( name.slice(5) );
+								dataAttr( elem, name, data[ name ] );
+							}
+						}
+					}
+					data_priv.set( elem, "hasDataAttrs", true );
+				}
+			}
+
+			return data;
+		}
+
+		// Sets multiple values
+		if ( typeof key === "object" ) {
+			return this.each(function() {
+				data_user.set( this, key );
+			});
+		}
+
+		return access( this, function( value ) {
+			var data,
+				camelKey = jQuery.camelCase( key );
+
+			// The calling jQuery object (element matches) is not empty
+			// (and therefore has an element appears at this[ 0 ]) and the
+			// `value` parameter was not undefined. An empty jQuery object
+			// will result in `undefined` for elem = this[ 0 ] which will
+			// throw an exception if an attempt to read a data cache is made.
+			if ( elem && value === undefined ) {
+				// Attempt to get data from the cache
+				// with the key as-is
+				data = data_user.get( elem, key );
+				if ( data !== undefined ) {
+					return data;
+				}
+
+				// Attempt to get data from the cache
+				// with the key camelized
+				data = data_user.get( elem, camelKey );
+				if ( data !== undefined ) {
+					return data;
+				}
+
+				// Attempt to "discover" the data in
+				// HTML5 custom data-* attrs
+				data = dataAttr( elem, camelKey, undefined );
+				if ( data !== undefined ) {
+					return data;
+				}
+
+				// We tried really hard, but the data doesn't exist.
+				return;
+			}
+
+			// Set the data...
+			this.each(function() {
+				// First, attempt to store a copy or reference of any
+				// data that might've been store with a camelCased key.
+				var data = data_user.get( this, camelKey );
+
+				// For HTML5 data-* attribute interop, we have to
+				// store property names with dashes in a camelCase form.
+				// This might not apply to all properties...*
+				data_user.set( this, camelKey, value );
+
+				// *... In the case of properties that might _actually_
+				// have dashes, we need to also store a copy of that
+				// unchanged property.
+				if ( key.indexOf("-") !== -1 && data !== undefined ) {
+					data_user.set( this, key, value );
+				}
+			});
+		}, null, value, arguments.length > 1, null, true );
+	},
+
+	removeData: function( key ) {
+		return this.each(function() {
+			data_user.remove( this, key );
+		});
+	}
+});
+
+
+jQuery.extend({
+	queue: function( elem, type, data ) {
+		var queue;
+
+		if ( elem ) {
+			type = ( type || "fx" ) + "queue";
+			queue = data_priv.get( elem, type );
+
+			// Speed up dequeue by getting out quickly if this is just a lookup
+			if ( data ) {
+				if ( !queue || jQuery.isArray( data ) ) {
+					queue = data_priv.access( elem, type, jQuery.makeArray(data) );
+				} else {
+					queue.push( data );
+				}
+			}
+			return queue || [];
+		}
+	},
+
+	dequeue: function( elem, type ) {
+		type = type || "fx";
+
+		var queue = jQuery.queue( elem, type ),
+			startLength = queue.length,
+			fn = queue.shift(),
+			hooks = jQuery._queueHooks( elem, type ),
+			next = function() {
+				jQuery.dequeue( elem, type );
+			};
+
+		// If the fx queue is dequeued, always remove the progress sentinel
+		if ( fn === "inprogress" ) {
+			fn = queue.shift();
+			startLength--;
+		}
+
+		if ( fn ) {
+
+			// Add a progress sentinel to prevent the fx queue from being
+			// automatically dequeued
+			if ( type === "fx" ) {
+				queue.unshift( "inprogress" );
+			}
+
+			// Clear up the last queue stop function
+			delete hooks.stop;
+			fn.call( elem, next, hooks );
+		}
+
+		if ( !startLength && hooks ) {
+			hooks.empty.fire();
+		}
+	},
+
+	// Not public - generate a queueHooks object, or return the current one
+	_queueHooks: function( elem, type ) {
+		var key = type + "queueHooks";
+		return data_priv.get( elem, key ) || data_priv.access( elem, key, {
+			empty: jQuery.Callbacks("once memory").add(function() {
+				data_priv.remove( elem, [ type + "queue", key ] );
+			})
+		});
+	}
+});
+
+jQuery.fn.extend({
+	queue: function( type, data ) {
+		var setter = 2;
+
+		if ( typeof type !== "string" ) {
+			data = type;
+			type = "fx";
+			setter--;
+		}
+
+		if ( arguments.length < setter ) {
+			return jQuery.queue( this[0], type );
+		}
+
+		return data === undefined ?
+			this :
+			this.each(function() {
+				var queue = jQuery.queue( this, type, data );
+
+				// Ensure a hooks for this queue
+				jQuery._queueHooks( this, type );
+
+				if ( type === "fx" && queue[0] !== "inprogress" ) {
+					jQuery.dequeue( this, type );
+				}
+			});
+	},
+	dequeue: function( type ) {
+		return this.each(function() {
+			jQuery.dequeue( this, type );
+		});
+	},
+	clearQueue: function( type ) {
+		return this.queue( type || "fx", [] );
+	},
+	// Get a promise resolved when queues of a certain type
+	// are emptied (fx is the type by default)
+	promise: function( type, obj ) {
+		var tmp,
+			count = 1,
+			defer = jQuery.Deferred(),
+			elements = this,
+			i = this.length,
+			resolve = function() {
+				if ( !( --count ) ) {
+					defer.resolveWith( elements, [ elements ] );
+				}
+			};
+
+		if ( typeof type !== "string" ) {
+			obj = type;
+			type = undefined;
+		}
+		type = type || "fx";
+
+		while ( i-- ) {
+			tmp = data_priv.get( elements[ i ], type + "queueHooks" );
+			if ( tmp && tmp.empty ) {
+				count++;
+				tmp.empty.add( resolve );
+			}
+		}
+		resolve();
+		return defer.promise( obj );
+	}
+});
+var pnum = (/[+-]?(?:\d*\.|)\d+(?:[eE][+-]?\d+|)/).source;
+
+var cssExpand = [ "Top", "Right", "Bottom", "Left" ];
+
+var isHidden = function( elem, el ) {
+		// isHidden might be called from jQuery#filter function;
+		// in that case, element will be second argument
+		elem = el || elem;
+		return jQuery.css( elem, "display" ) === "none" || !jQuery.contains( elem.ownerDocument, elem );
+	};
+
+var rcheckableType = (/^(?:checkbox|radio)$/i);
+
+
+
+(function() {
+	var fragment = document.createDocumentFragment(),
+		div = fragment.appendChild( document.createElement( "div" ) ),
+		input = document.createElement( "input" );
+
+	// Support: Safari<=5.1
+	// Check state lost if the name is set (#11217)
+	// Support: Windows Web Apps (WWA)
+	// `name` and `type` must use .setAttribute for WWA (#14901)
+	input.setAttribute( "type", "radio" );
+	input.setAttribute( "checked", "checked" );
+	input.setAttribute( "name", "t" );
+
+	div.appendChild( input );
+
+	// Support: Safari<=5.1, Android<4.2
+	// Older WebKit doesn't clone checked state correctly in fragments
+	support.checkClone = div.cloneNode( true ).cloneNode( true ).lastChild.checked;
+
+	// Support: IE<=11+
+	// Make sure textarea (and checkbox) defaultValue is properly cloned
+	div.innerHTML = "<textarea>x</textarea>";
+	support.noCloneChecked = !!div.cloneNode( true ).lastChild.defaultValue;
+})();
+var strundefined = typeof undefined;
+
+
+
+support.focusinBubbles = "onfocusin" in window;
+
+
+var
+	rkeyEvent = /^key/,
+	rmouseEvent = /^(?:mouse|pointer|contextmenu)|click/,
+	rfocusMorph = /^(?:focusinfocus|focusoutblur)$/,
+	rtypenamespace = /^([^.]*)(?:\.(.+)|)$/;
+
+function returnTrue() {
+	return true;
+}
+
+function returnFalse() {
+	return false;
+}
+
+function safeActiveElement() {
+	try {
+		return document.activeElement;
+	} catch ( err ) { }
+}
+
+/*
+ * Helper functions for managing events -- not part of the public interface.
+ * Props to Dean Edwards' addEvent library for many of the ideas.
+ */
+jQuery.event = {
+
+	global: {},
+
+	add: function( elem, types, handler, data, selector ) {
+
+		var handleObjIn, eventHandle, tmp,
+			events, t, handleObj,
+			special, handlers, type, namespaces, origType,
+			elemData = data_priv.get( elem );
+
+		// Don't attach events to noData or text/comment nodes (but allow plain objects)
+		if ( !elemData ) {
+			return;
+		}
+
+		// Caller can pass in an object of custom data in lieu of the handler
+		if ( handler.handler ) {
+			handleObjIn = handler;
+			handler = handleObjIn.handler;
+			selector = handleObjIn.selector;
+		}
+
+		// Make sure that the handler has a unique ID, used to find/remove it later
+		if ( !handler.guid ) {
+			handler.guid = jQuery.guid++;
+		}
+
+		// Init the element's event structure and main handler, if this is the first
+		if ( !(events = elemData.events) ) {
+			events = elemData.events = {};
+		}
+		if ( !(eventHandle = elemData.handle) ) {
+			eventHandle = elemData.handle = function( e ) {
+				// Discard the second event of a jQuery.event.trigger() and
+				// when an event is called after a page has unloaded
+				return typeof jQuery !== strundefined && jQuery.event.triggered !== e.type ?
+					jQuery.event.dispatch.apply( elem, arguments ) : undefined;
+			};
+		}
+
+		// Handle multiple events separated by a space
+		types = ( types || "" ).match( rnotwhite ) || [ "" ];
+		t = types.length;
+		while ( t-- ) {
+			tmp = rtypenamespace.exec( types[t] ) || [];
+			type = origType = tmp[1];
+			namespaces = ( tmp[2] || "" ).split( "." ).sort();
+
+			// There *must* be a type, no attaching namespace-only handlers
+			if ( !type ) {
+				continue;
+			}
+
+			// If event changes its type, use the special event handlers for the changed type
+			special = jQuery.event.special[ type ] || {};
+
+			// If selector defined, determine special event api type, otherwise given type
+			type = ( selector ? special.delegateType : special.bindType ) || type;
+
+			// Update special based on newly reset type
+			special = jQuery.event.special[ type ] || {};
+
+			// handleObj is passed to all event handlers
+			handleObj = jQuery.extend({
+				type: type,
+				origType: origType,
+				data: data,
+				handler: handler,
+				guid: handler.guid,
+				selector: selector,
+				needsContext: selector && jQuery.expr.match.needsContext.test( selector ),
+				namespace: namespaces.join(".")
+			}, handleObjIn );
+
+			// Init the event handler queue if we're the first
+			if ( !(handlers = events[ type ]) ) {
+				handlers = events[ type ] = [];
+				handlers.delegateCount = 0;
+
+				// Only use addEventListener if the special events handler returns false
+				if ( !special.setup || special.setup.call( elem, data, namespaces, eventHandle ) === false ) {
+					if ( elem.addEventListener ) {
+						elem.addEventListener( type, eventHandle, false );
+					}
+				}
+			}
+
+			if ( special.add ) {
+				special.add.call( elem, handleObj );
+
+				if ( !handleObj.handler.guid ) {
+					handleObj.handler.guid = handler.guid;
+				}
+			}
+
+			// Add to the element's handler list, delegates in front
+			if ( selector ) {
+				handlers.splice( handlers.delegateCount++, 0, handleObj );
+			} else {
+				handlers.push( handleObj );
+			}
+
+			// Keep track of which events have ever been used, for event optimization
+			jQuery.event.global[ type ] = true;
+		}
+
+	},
+
+	// Detach an event or set of events from an element
+	remove: function( elem, types, handler, selector, mappedTypes ) {
+
+		var j, origCount, tmp,
+			events, t, handleObj,
+			special, handlers, type, namespaces, origType,
+			elemData = data_priv.hasData( elem ) && data_priv.get( elem );
+
+		if ( !elemData || !(events = elemData.events) ) {
+			return;
+		}
+
+		// Once for each type.namespace in types; type may be omitted
+		types = ( types || "" ).match( rnotwhite ) || [ "" ];
+		t = types.length;
+		while ( t-- ) {
+			tmp = rtypenamespace.exec( types[t] ) || [];
+			type = origType = tmp[1];
+			namespaces = ( tmp[2] || "" ).split( "." ).sort();
+
+			// Unbind all events (on this namespace, if provided) for the element
+			if ( !type ) {
+				for ( type in events ) {
+					jQuery.event.remove( elem, type + types[ t ], handler, selector, true );
+				}
+				continue;
+			}
+
+			special = jQuery.event.special[ type ] || {};
+			type = ( selector ? special.delegateType : special.bindType ) || type;
+			handlers = events[ type ] || [];
+			tmp = tmp[2] && new RegExp( "(^|\\.)" + namespaces.join("\\.(?:.*\\.|)") + "(\\.|$)" );
+
+			// Remove matching events
+			origCount = j = handlers.length;
+			while ( j-- ) {
+				handleObj = handlers[ j ];
+
+				if ( ( mappedTypes || origType === handleObj.origType ) &&
+					( !handler || handler.guid === handleObj.guid ) &&
+					( !tmp || tmp.test( handleObj.namespace ) ) &&
+					( !selector || selector === handleObj.selector || selector === "**" && handleObj.selector ) ) {
+					handlers.splice( j, 1 );
+
+					if ( handleObj.selector ) {
+						handlers.delegateCount--;
+					}
+					if ( special.remove ) {
+						special.remove.call( elem, handleObj );
+					}
+				}
+			}
+
+			// Remove generic event handler if we removed something and no more handlers exist
+			// (avoids potential for endless recursion during removal of special event handlers)
+			if ( origCount && !handlers.length ) {
+				if ( !special.teardown || special.teardown.call( elem, namespaces, elemData.handle ) === false ) {
+					jQuery.removeEvent( elem, type, elemData.handle );
+				}
+
+				delete events[ type ];
+			}
+		}
+
+		// Remove the expando if it's no longer used
+		if ( jQuery.isEmptyObject( events ) ) {
+			delete elemData.handle;
+			data_priv.remove( elem, "events" );
+		}
+	},
+
+	trigger: function( event, data, elem, onlyHandlers ) {
+
+		var i, cur, tmp, bubbleType, ontype, handle, special,
+			eventPath = [ elem || document ],
+			type = hasOwn.call( event, "type" ) ? event.type : event,
+			namespaces = hasOwn.call( event, "namespace" ) ? event.namespace.split(".") : [];
+
+		cur = tmp = elem = elem || document;
+
+		// Don't do events on text and comment nodes
+		if ( elem.nodeType === 3 || elem.nodeType === 8 ) {
+			return;
+		}
+
+		// focus/blur morphs to focusin/out; ensure we're not firing them right now
+		if ( rfocusMorph.test( type + jQuery.event.triggered ) ) {
+			return;
+		}
+
+		if ( type.indexOf(".") >= 0 ) {
+			// Namespaced trigger; create a regexp to match event type in handle()
+			namespaces = type.split(".");
+			type = namespaces.shift();
+			namespaces.sort();
+		}
+		ontype = type.indexOf(":") < 0 && "on" + type;
+
+		// Caller can pass in a jQuery.Event object, Object, or just an event type string
+		event = event[ jQuery.expando ] ?
+			event :
+			new jQuery.Event( type, typeof event === "object" && event );
+
+		// Trigger bitmask: & 1 for native handlers; & 2 for jQuery (always true)
+		event.isTrigger = onlyHandlers ? 2 : 3;
+		event.namespace = namespaces.join(".");
+		event.namespace_re = event.namespace ?
+			new RegExp( "(^|\\.)" + namespaces.join("\\.(?:.*\\.|)") + "(\\.|$)" ) :
+			null;
+
+		// Clean up the event in case it is being reused
+		event.result = undefined;
+		if ( !event.target ) {
+			event.target = elem;
+		}
+
+		// Clone any incoming data and prepend the event, creating the handler arg list
+		data = data == null ?
+			[ event ] :
+			jQuery.makeArray( data, [ event ] );
+
+		// Allow special events to draw outside the lines
+		special = jQuery.event.special[ type ] || {};
+		if ( !onlyHandlers && special.trigger && special.trigger.apply( elem, data ) === false ) {
+			return;
+		}
+
+		// Determine event propagation path in advance, per W3C events spec (#9951)
+		// Bubble up to document, then to window; watch for a global ownerDocument var (#9724)
+		if ( !onlyHandlers && !special.noBubble && !jQuery.isWindow( elem ) ) {
+
+			bubbleType = special.delegateType || type;
+			if ( !rfocusMorph.test( bubbleType + type ) ) {
+				cur = cur.parentNode;
+			}
+			for ( ; cur; cur = cur.parentNode ) {
+				eventPath.push( cur );
+				tmp = cur;
+			}
+
+			// Only add window if we got to document (e.g., not plain obj or detached DOM)
+			if ( tmp === (elem.ownerDocument || document) ) {
+				eventPath.push( tmp.defaultView || tmp.parentWindow || window );
+			}
+		}
+
+		// Fire handlers on the event path
+		i = 0;
+		while ( (cur = eventPath[i++]) && !event.isPropagationStopped() ) {
+
+			event.type = i > 1 ?
+				bubbleType :
+				special.bindType || type;
+
+			// jQuery handler
+			handle = ( data_priv.get( cur, "events" ) || {} )[ event.type ] && data_priv.get( cur, "handle" );
+			if ( handle ) {
+				handle.apply( cur, data );
+			}
+
+			// Native handler
+			handle = ontype && cur[ ontype ];
+			if ( handle && handle.apply && jQuery.acceptData( cur ) ) {
+				event.result = handle.apply( cur, data );
+				if ( event.result === false ) {
+					event.preventDefault();
+				}
+			}
+		}
+		event.type = type;
+
+		// If nobody prevented the default action, do it now
+		if ( !onlyHandlers && !event.isDefaultPrevented() ) {
+
+			if ( (!special._default || special._default.apply( eventPath.pop(), data ) === false) &&
+				jQuery.acceptData( elem ) ) {
+
+				// Call a native DOM method on the target with the same name name as the event.
+				// Don't do default actions on window, that's where global variables be (#6170)
+				if ( ontype && jQuery.isFunction( elem[ type ] ) && !jQuery.isWindow( elem ) ) {
+
+					// Don't re-trigger an onFOO event when we call its FOO() method
+					tmp = elem[ ontype ];
+
+					if ( tmp ) {
+						elem[ ontype ] = null;
+					}
+
+					// Prevent re-triggering of the same event, since we already bubbled it above
+					jQuery.event.triggered = type;
+					elem[ type ]();
+					jQuery.event.triggered = undefined;
+
+					if ( tmp ) {
+						elem[ ontype ] = tmp;
+					}
+				}
+			}
+		}
+
+		return event.result;
+	},
+
+	dispatch: function( event ) {
+
+		// Make a writable jQuery.Event from the native event object
+		event = jQuery.event.fix( event );
+
+		var i, j, ret, matched, handleObj,
+			handlerQueue = [],
+			args = slice.call( arguments ),
+			handlers = ( data_priv.get( this, "events" ) || {} )[ event.type ] || [],
+			special = jQuery.event.special[ event.type ] || {};
+
+		// Use the fix-ed jQuery.Event rather than the (read-only) native event
+		args[0] = event;
+		event.delegateTarget = this;
+
+		// Call the preDispatch hook for the mapped type, and let it bail if desired
+		if ( special.preDispatch && special.preDispatch.call( this, event ) === false ) {
+			return;
+		}
+
+		// Determine handlers
+		handlerQueue = jQuery.event.handlers.call( this, event, handlers );
+
+		// Run delegates first; they may want to stop propagation beneath us
+		i = 0;
+		while ( (matched = handlerQueue[ i++ ]) && !event.isPropagationStopped() ) {
+			event.currentTarget = matched.elem;
+
+			j = 0;
+			while ( (handleObj = matched.handlers[ j++ ]) && !event.isImmediatePropagationStopped() ) {
+
+				// Triggered event must either 1) have no namespace, or 2) have namespace(s)
+				// a subset or equal to those in the bound event (both can have no namespace).
+				if ( !event.namespace_re || event.namespace_re.test( handleObj.namespace ) ) {
+
+					event.handleObj = handleObj;
+					event.data = handleObj.data;
+
+					ret = ( (jQuery.event.special[ handleObj.origType ] || {}).handle || handleObj.handler )
+							.apply( matched.elem, args );
+
+					if ( ret !== undefined ) {
+						if ( (event.result = ret) === false ) {
+							event.preventDefault();
+							event.stopPropagation();
+						}
+					}
+				}
+			}
+		}
+
+		// Call the postDispatch hook for the mapped type
+		if ( special.postDispatch ) {
+			special.postDispatch.call( this, event );
+		}
+
+		return event.result;
+	},
+
+	handlers: function( event, handlers ) {
+		var i, matches, sel, handleObj,
+			handlerQueue = [],
+			delegateCount = handlers.delegateCount,
+			cur = event.target;
+
+		// Find delegate handlers
+		// Black-hole SVG <use> instance trees (#13180)
+		// Avoid non-left-click bubbling in Firefox (#3861)
+		if ( delegateCount && cur.nodeType && (!event.button || event.type !== "click") ) {
+
+			for ( ; cur !== this; cur = cur.parentNode || this ) {
+
+				// Don't process clicks on disabled elements (#6911, #8165, #11382, #11764)
+				if ( cur.disabled !== true || event.type !== "click" ) {
+					matches = [];
+					for ( i = 0; i < delegateCount; i++ ) {
+						handleObj = handlers[ i ];
+
+						// Don't conflict with Object.prototype properties (#13203)
+						sel = handleObj.selector + " ";
+
+						if ( matches[ sel ] === undefined ) {
+							matches[ sel ] = handleObj.needsContext ?
+								jQuery( sel, this ).index( cur ) >= 0 :
+								jQuery.find( sel, this, null, [ cur ] ).length;
+						}
+						if ( matches[ sel ] ) {
+							matches.push( handleObj );
+						}
+					}
+					if ( matches.length ) {
+						handlerQueue.push({ elem: cur, handlers: matches });
+					}
+				}
+			}
+		}
+
+		// Add the remaining (directly-bound) handlers
+		if ( delegateCount < handlers.length ) {
+			handlerQueue.push({ elem: this, handlers: handlers.slice( delegateCount ) });
+		}
+
+		return handlerQueue;
+	},
+
+	// Includes some event props shared by KeyEvent and MouseEvent
+	props: "altKey bubbles cancelable ctrlKey currentTarget eventPhase metaKey relatedTarget shiftKey target timeStamp view which".split(" "),
+
+	fixHooks: {},
+
+	keyHooks: {
+		props: "char charCode key keyCode".split(" "),
+		filter: function( event, original ) {
+
+			// Add which for key events
+			if ( event.which == null ) {
+				event.which = original.charCode != null ? original.charCode : original.keyCode;
+			}
+
+			return event;
+		}
+	},
+
+	mouseHooks: {
+		props: "button buttons clientX clientY offsetX offsetY pageX pageY screenX screenY toElement".split(" "),
+		filter: function( event, original ) {
+			var eventDoc, doc, body,
+				button = original.button;
+
+			// Calculate pageX/Y if missing and clientX/Y available
+			if ( event.pageX == null && original.clientX != null ) {
+				eventDoc = event.target.ownerDocument || document;
+				doc = eventDoc.documentElement;
+				body = eventDoc.body;
+
+				event.pageX = original.clientX + ( doc && doc.scrollLeft || body && body.scrollLeft || 0 ) - ( doc && doc.clientLeft || body && body.clientLeft || 0 );
+				event.pageY = original.clientY + ( doc && doc.scrollTop  || body && body.scrollTop  || 0 ) - ( doc && doc.clientTop  || body && body.clientTop  || 0 );
+			}
+
+			// Add which for click: 1 === left; 2 === middle; 3 === right
+			// Note: button is not normalized, so don't use it
+			if ( !event.which && button !== undefined ) {
+				event.which = ( button & 1 ? 1 : ( button & 2 ? 3 : ( button & 4 ? 2 : 0 ) ) );
+			}
+
+			return event;
+		}
+	},
+
+	fix: function( event ) {
+		if ( event[ jQuery.expando ] ) {
+			return event;
+		}
+
+		// Create a writable copy of the event object and normalize some properties
+		var i, prop, copy,
+			type = event.type,
+			originalEvent = event,
+			fixHook = this.fixHooks[ type ];
+
+		if ( !fixHook ) {
+			this.fixHooks[ type ] = fixHook =
+				rmouseEvent.test( type ) ? this.mouseHooks :
+				rkeyEvent.test( type ) ? this.keyHooks :
+				{};
+		}
+		copy = fixHook.props ? this.props.concat( fixHook.props ) : this.props;
+
+		event = new jQuery.Event( originalEvent );
+
+		i = copy.length;
+		while ( i-- ) {
+			prop = copy[ i ];
+			event[ prop ] = originalEvent[ prop ];
+		}
+
+		// Support: Cordova 2.5 (WebKit) (#13255)
+		// All events should have a target; Cordova deviceready doesn't
+		if ( !event.target ) {
+			event.target = document;
+		}
+
+		// Support: Safari 6.0+, Chrome<28
+		// Target should not be a text node (#504, #13143)
+		if ( event.target.nodeType === 3 ) {
+			event.target = event.target.parentNode;
+		}
+
+		return fixHook.filter ? fixHook.filter( event, originalEvent ) : event;
+	},
+
+	special: {
+		load: {
+			// Prevent triggered image.load events from bubbling to window.load
+			noBubble: true
+		},
+		focus: {
+			// Fire native event if possible so blur/focus sequence is correct
+			trigger: function() {
+				if ( this !== safeActiveElement() && this.focus ) {
+					this.focus();
+					return false;
+				}
+			},
+			delegateType: "focusin"
+		},
+		blur: {
+			trigger: function() {
+				if ( this === safeActiveElement() && this.blur ) {
+					this.blur();
+					return false;
+				}
+			},
+			delegateType: "focusout"
+		},
+		click: {
+			// For checkbox, fire native event so checked state will be right
+			trigger: function() {
+				if ( this.type === "checkbox" && this.click && jQuery.nodeName( this, "input" ) ) {
+					this.click();
+					return false;
+				}
+			},
+
+			// For cross-browser consistency, don't fire native .click() on links
+			_default: function( event ) {
+				return jQuery.nodeName( event.target, "a" );
+			}
+		},
+
+		beforeunload: {
+			postDispatch: function( event ) {
+
+				// Support: Firefox 20+
+				// Firefox doesn't alert if the returnValue field is not set.
+				if ( event.result !== undefined && event.originalEvent ) {
+					event.originalEvent.returnValue = event.result;
+				}
+			}
+		}
+	},
+
+	simulate: function( type, elem, event, bubble ) {
+		// Piggyback on a donor event to simulate a different one.
+		// Fake originalEvent to avoid donor's stopPropagation, but if the
+		// simulated event prevents default then we do the same on the donor.
+		var e = jQuery.extend(
+			new jQuery.Event(),
+			event,
+			{
+				type: type,
+				isSimulated: true,
+				originalEvent: {}
+			}
+		);
+		if ( bubble ) {
+			jQuery.event.trigger( e, null, elem );
+		} else {
+			jQuery.event.dispatch.call( elem, e );
+		}
+		if ( e.isDefaultPrevented() ) {
+			event.preventDefault();
+		}
+	}
+};
+
+jQuery.removeEvent = function( elem, type, handle ) {
+	if ( elem.removeEventListener ) {
+		elem.removeEventListener( type, handle, false );
+	}
+};
+
+jQuery.Event = function( src, props ) {
+	// Allow instantiation without the 'new' keyword
+	if ( !(this instanceof jQuery.Event) ) {
+		return new jQuery.Event( src, props );
+	}
+
+	// Event object
+	if ( src && src.type ) {
+		this.originalEvent = src;
+		this.type = src.type;
+
+		// Events bubbling up the document may have been marked as prevented
+		// by a handler lower down the tree; reflect the correct value.
+		this.isDefaultPrevented = src.defaultPrevented ||
+				src.defaultPrevented === undefined &&
+				// Support: Android<4.0
+				src.returnValue === false ?
+			returnTrue :
+			returnFalse;
+
+	// Event type
+	} else {
+		this.type = src;
+	}
+
+	// Put explicitly provided properties onto the event object
+	if ( props ) {
+		jQuery.extend( this, props );
+	}
+
+	// Create a timestamp if incoming event doesn't have one
+	this.timeStamp = src && src.timeStamp || jQuery.now();
+
+	// Mark it as fixed
+	this[ jQuery.expando ] = true;
+};
+
+// jQuery.Event is based on DOM3 Events as specified by the ECMAScript Language Binding
+// http://www.w3.org/TR/2003/WD-DOM-Level-3-Events-20030331/ecma-script-binding.html
+jQuery.Event.prototype = {
+	isDefaultPrevented: returnFalse,
+	isPropagationStopped: returnFalse,
+	isImmediatePropagationStopped: returnFalse,
+
+	preventDefault: function() {
+		var e = this.originalEvent;
+
+		this.isDefaultPrevented = returnTrue;
+
+		if ( e && e.preventDefault ) {
+			e.preventDefault();
+		}
+	},
+	stopPropagation: function() {
+		var e = this.originalEvent;
+
+		this.isPropagationStopped = returnTrue;
+
+		if ( e && e.stopPropagation ) {
+			e.stopPropagation();
+		}
+	},
+	stopImmediatePropagation: function() {
+		var e = this.originalEvent;
+
+		this.isImmediatePropagationStopped = returnTrue;
+
+		if ( e && e.stopImmediatePropagation ) {
+			e.stopImmediatePropagation();
+		}
+
+		this.stopPropagation();
+	}
+};
+
+// Create mouseenter/leave events using mouseover/out and event-time checks
+// Support: Chrome 15+
+jQuery.each({
+	mouseenter: "mouseover",
+	mouseleave: "mouseout",
+	pointerenter: "pointerover",
+	pointerleave: "pointerout"
+}, function( orig, fix ) {
+	jQuery.event.special[ orig ] = {
+		delegateType: fix,
+		bindType: fix,
+
+		handle: function( event ) {
+			var ret,
+				target = this,
+				related = event.relatedTarget,
+				handleObj = event.handleObj;
+
+			// For mousenter/leave call the handler if related is outside the target.
+			// NB: No relatedTarget if the mouse left/entered the browser window
+			if ( !related || (related !== target && !jQuery.contains( target, related )) ) {
+				event.type = handleObj.origType;
+				ret = handleObj.handler.apply( this, arguments );
+				event.type = fix;
+			}
+			return ret;
+		}
+	};
+});
+
+// Support: Firefox, Chrome, Safari
+// Create "bubbling" focus and blur events
+if ( !support.focusinBubbles ) {
+	jQuery.each({ focus: "focusin", blur: "focusout" }, function( orig, fix ) {
+
+		// Attach a single capturing handler on the document while someone wants focusin/focusout
+		var handler = function( event ) {
+				jQuery.event.simulate( fix, event.target, jQuery.event.fix( event ), true );
+			};
+
+		jQuery.event.special[ fix ] = {
+			setup: function() {
+				var doc = this.ownerDocument || this,
+					attaches = data_priv.access( doc, fix );
+
+				if ( !attaches ) {
+					doc.addEventListener( orig, handler, true );
+				}
+				data_priv.access( doc, fix, ( attaches || 0 ) + 1 );
+			},
+			teardown: function() {
+				var doc = this.ownerDocument || this,
+					attaches = data_priv.access( doc, fix ) - 1;
+
+				if ( !attaches ) {
+					doc.removeEventListener( orig, handler, true );
+					data_priv.remove( doc, fix );
+
+				} else {
+					data_priv.access( doc, fix, attaches );
+				}
+			}
+		};
+	});
+}
+
+jQuery.fn.extend({
+
+	on: function( types, selector, data, fn, /*INTERNAL*/ one ) {
+		var origFn, type;
+
+		// Types can be a map of types/handlers
+		if ( typeof types === "object" ) {
+			// ( types-Object, selector, data )
+			if ( typeof selector !== "string" ) {
+				// ( types-Object, data )
+				data = data || selector;
+				selector = undefined;
+			}
+			for ( type in types ) {
+				this.on( type, selector, data, types[ type ], one );
+			}
+			return this;
+		}
+
+		if ( data == null && fn == null ) {
+			// ( types, fn )
+			fn = selector;
+			data = selector = undefined;
+		} else if ( fn == null ) {
+			if ( typeof selector === "string" ) {
+				// ( types, selector, fn )
+				fn = data;
+				data = undefined;
+			} else {
+				// ( types, data, fn )
+				fn = data;
+				data = selector;
+				selector = undefined;
+			}
+		}
+		if ( fn === false ) {
+			fn = returnFalse;
+		} else if ( !fn ) {
+			return this;
+		}
+
+		if ( one === 1 ) {
+			origFn = fn;
+			fn = function( event ) {
+				// Can use an empty set, since event contains the info
+				jQuery().off( event );
+				return origFn.apply( this, arguments );
+			};
+			// Use same guid so caller can remove using origFn
+			fn.guid = origFn.guid || ( origFn.guid = jQuery.guid++ );
+		}
+		return this.each( function() {
+			jQuery.event.add( this, types, fn, data, selector );
+		});
+	},
+	one: function( types, selector, data, fn ) {
+		return this.on( types, selector, data, fn, 1 );
+	},
+	off: function( types, selector, fn ) {
+		var handleObj, type;
+		if ( types && types.preventDefault && types.handleObj ) {
+			// ( event )  dispatched jQuery.Event
+			handleObj = types.handleObj;
+			jQuery( types.delegateTarget ).off(
+				handleObj.namespace ? handleObj.origType + "." + handleObj.namespace : handleObj.origType,
+				handleObj.selector,
+				handleObj.handler
+			);
+			return this;
+		}
+		if ( typeof types === "object" ) {
+			// ( types-object [, selector] )
+			for ( type in types ) {
+				this.off( type, selector, types[ type ] );
+			}
+			return this;
+		}
+		if ( selector === false || typeof selector === "function" ) {
+			// ( types [, fn] )
+			fn = selector;
+			selector = undefined;
+		}
+		if ( fn === false ) {
+			fn = returnFalse;
+		}
+		return this.each(function() {
+			jQuery.event.remove( this, types, fn, selector );
+		});
+	},
+
+	trigger: function( type, data ) {
+		return this.each(function() {
+			jQuery.event.trigger( type, data, this );
+		});
+	},
+	triggerHandler: function( type, data ) {
+		var elem = this[0];
+		if ( elem ) {
+			return jQuery.event.trigger( type, data, elem, true );
+		}
+	}
+});
+
+
+var
+	rxhtmlTag = /<(?!area|br|col|embed|hr|img|input|link|meta|param)(([\w:]+)[^>]*)\/>/gi,
+	rtagName = /<([\w:]+)/,
+	rhtml = /<|&#?\w+;/,
+	rnoInnerhtml = /<(?:script|style|link)/i,
+	// checked="checked" or checked
+	rchecked = /checked\s*(?:[^=]|=\s*.checked.)/i,
+	rscriptType = /^$|\/(?:java|ecma)script/i,
+	rscriptTypeMasked = /^true\/(.*)/,
+	rcleanScript = /^\s*<!(?:\[CDATA\[|--)|(?:\]\]|--)>\s*$/g,
+
+	// We have to close these tags to support XHTML (#13200)
+	wrapMap = {
+
+		// Support: IE9
+		option: [ 1, "<select multiple='multiple'>", "</select>" ],
+
+		thead: [ 1, "<table>", "</table>" ],
+		col: [ 2, "<table><colgroup>", "</colgroup></table>" ],
+		tr: [ 2, "<table><tbody>", "</tbody></table>" ],
+		td: [ 3, "<table><tbody><tr>", "</tr></tbody></table>" ],
+
+		_default: [ 0, "", "" ]
+	};
+
+// Support: IE9
+wrapMap.optgroup = wrapMap.option;
+
+wrapMap.tbody = wrapMap.tfoot = wrapMap.colgroup = wrapMap.caption = wrapMap.thead;
+wrapMap.th = wrapMap.td;
+
+// Support: 1.x compatibility
+// Manipulating tables requires a tbody
+function manipulationTarget( elem, content ) {
+	return jQuery.nodeName( elem, "table" ) &&
+		jQuery.nodeName( content.nodeType !== 11 ? content : content.firstChild, "tr" ) ?
+
+		elem.getElementsByTagName("tbody")[0] ||
+			elem.appendChild( elem.ownerDocument.createElement("tbody") ) :
+		elem;
+}
+
+// Replace/restore the type attribute of script elements for safe DOM manipulation
+function disableScript( elem ) {
+	elem.type = (elem.getAttribute("type") !== null) + "/" + elem.type;
+	return elem;
+}
+function restoreScript( elem ) {
+	var match = rscriptTypeMasked.exec( elem.type );
+
+	if ( match ) {
+		elem.type = match[ 1 ];
+	} else {
+		elem.removeAttribute("type");
+	}
+
+	return elem;
+}
+
+// Mark scripts as having already been evaluated
+function setGlobalEval( elems, refElements ) {
+	var i = 0,
+		l = elems.length;
+
+	for ( ; i < l; i++ ) {
+		data_priv.set(
+			elems[ i ], "globalEval", !refElements || data_priv.get( refElements[ i ], "globalEval" )
+		);
+	}
+}
+
+function cloneCopyEvent( src, dest ) {
+	var i, l, type, pdataOld, pdataCur, udataOld, udataCur, events;
+
+	if ( dest.nodeType !== 1 ) {
+		return;
+	}
+
+	// 1. Copy private data: events, handlers, etc.
+	if ( data_priv.hasData( src ) ) {
+		pdataOld = data_priv.access( src );
+		pdataCur = data_priv.set( dest, pdataOld );
+		events = pdataOld.events;
+
+		if ( events ) {
+			delete pdataCur.handle;
+			pdataCur.events = {};
+
+			for ( type in events ) {
+				for ( i = 0, l = events[ type ].length; i < l; i++ ) {
+					jQuery.event.add( dest, type, events[ type ][ i ] );
+				}
+			}
+		}
+	}
+
+	// 2. Copy user data
+	if ( data_user.hasData( src ) ) {
+		udataOld = data_user.access( src );
+		udataCur = jQuery.extend( {}, udataOld );
+
+		data_user.set( dest, udataCur );
+	}
+}
+
+function getAll( context, tag ) {
+	var ret = context.getElementsByTagName ? context.getElementsByTagName( tag || "*" ) :
+			context.querySelectorAll ? context.querySelectorAll( tag || "*" ) :
+			[];
+
+	return tag === undefined || tag && jQuery.nodeName( context, tag ) ?
+		jQuery.merge( [ context ], ret ) :
+		ret;
+}
+
+// Fix IE bugs, see support tests
+function fixInput( src, dest ) {
+	var nodeName = dest.nodeName.toLowerCase();
+
+	// Fails to persist the checked state of a cloned checkbox or radio button.
+	if ( nodeName === "input" && rcheckableType.test( src.type ) ) {
+		dest.checked = src.checked;
+
+	// Fails to return the selected option to the default selected state when cloning options
+	} else if ( nodeName === "input" || nodeName === "textarea" ) {
+		dest.defaultValue = src.defaultValue;
+	}
+}
+
+jQuery.extend({
+	clone: function( elem, dataAndEvents, deepDataAndEvents ) {
+		var i, l, srcElements, destElements,
+			clone = elem.cloneNode( true ),
+			inPage = jQuery.contains( elem.ownerDocument, elem );
+
+		// Fix IE cloning issues
+		if ( !support.noCloneChecked && ( elem.nodeType === 1 || elem.nodeType === 11 ) &&
+				!jQuery.isXMLDoc( elem ) ) {
+
+			// We eschew Sizzle here for performance reasons: http://jsperf.com/getall-vs-sizzle/2
+			destElements = getAll( clone );
+			srcElements = getAll( elem );
+
+			for ( i = 0, l = srcElements.length; i < l; i++ ) {
+				fixInput( srcElements[ i ], destElements[ i ] );
+			}
+		}
+
+		// Copy the events from the original to the clone
+		if ( dataAndEvents ) {
+			if ( deepDataAndEvents ) {
+				srcElements = srcElements || getAll( elem );
+				destElements = destElements || getAll( clone );
+
+				for ( i = 0, l = srcElements.length; i < l; i++ ) {
+					cloneCopyEvent( srcElements[ i ], destElements[ i ] );
+				}
+			} else {
+				cloneCopyEvent( elem, clone );
+			}
+		}
+
+		// Preserve script evaluation history
+		destElements = getAll( clone, "script" );
+		if ( destElements.length > 0 ) {
+			setGlobalEval( destElements, !inPage && getAll( elem, "script" ) );
+		}
+
+		// Return the cloned set
+		return clone;
+	},
+
+	buildFragment: function( elems, context, scripts, selection ) {
+		var elem, tmp, tag, wrap, contains, j,
+			fragment = context.createDocumentFragment(),
+			nodes = [],
+			i = 0,
+			l = elems.length;
+
+		for ( ; i < l; i++ ) {
+			elem = elems[ i ];
+
+			if ( elem || elem === 0 ) {
+
+				// Add nodes directly
+				if ( jQuery.type( elem ) === "object" ) {
+					// Support: QtWebKit, PhantomJS
+					// push.apply(_, arraylike) throws on ancient WebKit
+					jQuery.merge( nodes, elem.nodeType ? [ elem ] : elem );
+
+				// Convert non-html into a text node
+				} else if ( !rhtml.test( elem ) ) {
+					nodes.push( context.createTextNode( elem ) );
+
+				// Convert html into DOM nodes
+				} else {
+					tmp = tmp || fragment.appendChild( context.createElement("div") );
+
+					// Deserialize a standard representation
+					tag = ( rtagName.exec( elem ) || [ "", "" ] )[ 1 ].toLowerCase();
+					wrap = wrapMap[ tag ] || wrapMap._default;
+					tmp.innerHTML = wrap[ 1 ] + elem.replace( rxhtmlTag, "<$1></$2>" ) + wrap[ 2 ];
+
+					// Descend through wrappers to the right content
+					j = wrap[ 0 ];
+					while ( j-- ) {
+						tmp = tmp.lastChild;
+					}
+
+					// Support: QtWebKit, PhantomJS
+					// push.apply(_, arraylike) throws on ancient WebKit
+					jQuery.merge( nodes, tmp.childNodes );
+
+					// Remember the top-level container
+					tmp = fragment.firstChild;
+
+					// Ensure the created nodes are orphaned (#12392)
+					tmp.textContent = "";
+				}
+			}
+		}
+
+		// Remove wrapper from fragment
+		fragment.textContent = "";
+
+		i = 0;
+		while ( (elem = nodes[ i++ ]) ) {
+
+			// #4087 - If origin and destination elements are the same, and this is
+			// that element, do not do anything
+			if ( selection && jQuery.inArray( elem, selection ) !== -1 ) {
+				continue;
+			}
+
+			contains = jQuery.contains( elem.ownerDocument, elem );
+
+			// Append to fragment
+			tmp = getAll( fragment.appendChild( elem ), "script" );
+
+			// Preserve script evaluation history
+			if ( contains ) {
+				setGlobalEval( tmp );
+			}
+
+			// Capture executables
+			if ( scripts ) {
+				j = 0;
+				while ( (elem = tmp[ j++ ]) ) {
+					if ( rscriptType.test( elem.type || "" ) ) {
+						scripts.push( elem );
+					}
+				}
+			}
+		}
+
+		return fragment;
+	},
+
+	cleanData: function( elems ) {
+		var data, elem, type, key,
+			special = jQuery.event.special,
+			i = 0;
+
+		for ( ; (elem = elems[ i ]) !== undefined; i++ ) {
+			if ( jQuery.acceptData( elem ) ) {
+				key = elem[ data_priv.expando ];
+
+				if ( key && (data = data_priv.cache[ key ]) ) {
+					if ( data.events ) {
+						for ( type in data.events ) {
+							if ( special[ type ] ) {
+								jQuery.event.remove( elem, type );
+
+							// This is a shortcut to avoid jQuery.event.remove's overhead
+							} else {
+								jQuery.removeEvent( elem, type, data.handle );
+							}
+						}
+					}
+					if ( data_priv.cache[ key ] ) {
+						// Discard any remaining `private` data
+						delete data_priv.cache[ key ];
+					}
+				}
+			}
+			// Discard any remaining `user` data
+			delete data_user.cache[ elem[ data_user.expando ] ];
+		}
+	}
+});
+
+jQuery.fn.extend({
+	text: function( value ) {
+		return access( this, function( value ) {
+			return value === undefined ?
+				jQuery.text( this ) :
+				this.empty().each(function() {
+					if ( this.nodeType === 1 || this.nodeType === 11 || this.nodeType === 9 ) {
+						this.textContent = value;
+					}
+				});
+		}, null, value, arguments.length );
+	},
+
+	append: function() {
+		return this.domManip( arguments, function( elem ) {
+			if ( this.nodeType === 1 || this.nodeType === 11 || this.nodeType === 9 ) {
+				var target = manipulationTarget( this, elem );
+				target.appendChild( elem );
+			}
+		});
+	},
+
+	prepend: function() {
+		return this.domManip( arguments, function( elem ) {
+			if ( this.nodeType === 1 || this.nodeType === 11 || this.nodeType === 9 ) {
+				var target = manipulationTarget( this, elem );
+				target.insertBefore( elem, target.firstChild );
+			}
+		});
+	},
+
+	before: function() {
+		return this.domManip( arguments, function( elem ) {
+			if ( this.parentNode ) {
+				this.parentNode.insertBefore( elem, this );
+			}
+		});
+	},
+
+	after: function() {
+		return this.domManip( arguments, function( elem ) {
+			if ( this.parentNode ) {
+				this.parentNode.insertBefore( elem, this.nextSibling );
+			}
+		});
+	},
+
+	remove: function( selector, keepData /* Internal Use Only */ ) {
+		var elem,
+			elems = selector ? jQuery.filter( selector, this ) : this,
+			i = 0;
+
+		for ( ; (elem = elems[i]) != null; i++ ) {
+			if ( !keepData && elem.nodeType === 1 ) {
+				jQuery.cleanData( getAll( elem ) );
+			}
+
+			if ( elem.parentNode ) {
+				if ( keepData && jQuery.contains( elem.ownerDocument, elem ) ) {
+					setGlobalEval( getAll( elem, "script" ) );
+				}
+				elem.parentNode.removeChild( elem );
+			}
+		}
+
+		return this;
+	},
+
+	empty: function() {
+		var elem,
+			i = 0;
+
+		for ( ; (elem = this[i]) != null; i++ ) {
+			if ( elem.nodeType === 1 ) {
+
+				// Prevent memory leaks
+				jQuery.cleanData( getAll( elem, false ) );
+
+				// Remove any remaining nodes
+				elem.textContent = "";
+			}
+		}
+
+		return this;
+	},
+
+	clone: function( dataAndEvents, deepDataAndEvents ) {
+		dataAndEvents = dataAndEvents == null ? false : dataAndEvents;
+		deepDataAndEvents = deepDataAndEvents == null ? dataAndEvents : deepDataAndEvents;
+
+		return this.map(function() {
+			return jQuery.clone( this, dataAndEvents, deepDataAndEvents );
+		});
+	},
+
+	html: function( value ) {
+		return access( this, function( value ) {
+			var elem = this[ 0 ] || {},
+				i = 0,
+				l = this.length;
+
+			if ( value === undefined && elem.nodeType === 1 ) {
+				return elem.innerHTML;
+			}
+
+			// See if we can take a shortcut and just use innerHTML
+			if ( typeof value === "string" && !rnoInnerhtml.test( value ) &&
+				!wrapMap[ ( rtagName.exec( value ) || [ "", "" ] )[ 1 ].toLowerCase() ] ) {
+
+				value = value.replace( rxhtmlTag, "<$1></$2>" );
+
+				try {
+					for ( ; i < l; i++ ) {
+						elem = this[ i ] || {};
+
+						// Remove element nodes and prevent memory leaks
+						if ( elem.nodeType === 1 ) {
+							jQuery.cleanData( getAll( elem, false ) );
+							elem.innerHTML = value;
+						}
+					}
+
+					elem = 0;
+
+				// If using innerHTML throws an exception, use the fallback method
+				} catch( e ) {}
+			}
+
+			if ( elem ) {
+				this.empty().append( value );
+			}
+		}, null, value, arguments.length );
+	},
+
+	replaceWith: function() {
+		var arg = arguments[ 0 ];
+
+		// Make the changes, replacing each context element with the new content
+		this.domManip( arguments, function( elem ) {
+			arg = this.parentNode;
+
+			jQuery.cleanData( getAll( this ) );
+
+			if ( arg ) {
+				arg.replaceChild( elem, this );
+			}
+		});
+
+		// Force removal if there was no new content (e.g., from empty arguments)
+		return arg && (arg.length || arg.nodeType) ? this : this.remove();
+	},
+
+	detach: function( selector ) {
+		return this.remove( selector, true );
+	},
+
+	domManip: function( args, callback ) {
+
+		// Flatten any nested arrays
+		args = concat.apply( [], args );
+
+		var fragment, first, scripts, hasScripts, node, doc,
+			i = 0,
+			l = this.length,
+			set = this,
+			iNoClone = l - 1,
+			value = args[ 0 ],
+			isFunction = jQuery.isFunction( value );
+
+		// We can't cloneNode fragments that contain checked, in WebKit
+		if ( isFunction ||
+				( l > 1 && typeof value === "string" &&
+					!support.checkClone && rchecked.test( value ) ) ) {
+			return this.each(function( index ) {
+				var self = set.eq( index );
+				if ( isFunction ) {
+					args[ 0 ] = value.call( this, index, self.html() );
+				}
+				self.domManip( args, callback );
+			});
+		}
+
+		if ( l ) {
+			fragment = jQuery.buildFragment( args, this[ 0 ].ownerDocument, false, this );
+			first = fragment.firstChild;
+
+			if ( fragment.childNodes.length === 1 ) {
+				fragment = first;
+			}
+
+			if ( first ) {
+				scripts = jQuery.map( getAll( fragment, "script" ), disableScript );
+				hasScripts = scripts.length;
+
+				// Use the original fragment for the last item instead of the first because it can end up
+				// being emptied incorrectly in certain situations (#8070).
+				for ( ; i < l; i++ ) {
+					node = fragment;
+
+					if ( i !== iNoClone ) {
+						node = jQuery.clone( node, true, true );
+
+						// Keep references to cloned scripts for later restoration
+						if ( hasScripts ) {
+							// Support: QtWebKit
+							// jQuery.merge because push.apply(_, arraylike) throws
+							jQuery.merge( scripts, getAll( node, "script" ) );
+						}
+					}
+
+					callback.call( this[ i ], node, i );
+				}
+
+				if ( hasScripts ) {
+					doc = scripts[ scripts.length - 1 ].ownerDocument;
+
+					// Reenable scripts
+					jQuery.map( scripts, restoreScript );
+
+					// Evaluate executable scripts on first document insertion
+					for ( i = 0; i < hasScripts; i++ ) {
+						node = scripts[ i ];
+						if ( rscriptType.test( node.type || "" ) &&
+							!data_priv.access( node, "globalEval" ) && jQuery.contains( doc, node ) ) {
+
+							if ( node.src ) {
+								// Optional AJAX dependency, but won't run scripts if not present
+								if ( jQuery._evalUrl ) {
+									jQuery._evalUrl( node.src );
+								}
+							} else {
+								jQuery.globalEval( node.textContent.replace( rcleanScript, "" ) );
+							}
+						}
+					}
+				}
+			}
+		}
+
+		return this;
+	}
+});
+
+jQuery.each({
+	appendTo: "append",
+	prependTo: "prepend",
+	insertBefore: "before",
+	insertAfter: "after",
+	replaceAll: "replaceWith"
+}, function( name, original ) {
+	jQuery.fn[ name ] = function( selector ) {
+		var elems,
+			ret = [],
+			insert = jQuery( selector ),
+			last = insert.length - 1,
+			i = 0;
+
+		for ( ; i <= last; i++ ) {
+			elems = i === last ? this : this.clone( true );
+			jQuery( insert[ i ] )[ original ]( elems );
+
+			// Support: QtWebKit
+			// .get() because push.apply(_, arraylike) throws
+			push.apply( ret, elems.get() );
+		}
+
+		return this.pushStack( ret );
+	};
+});
+
+
+var iframe,
+	elemdisplay = {};
+
+/**
+ * Retrieve the actual display of a element
+ * @param {String} name nodeName of the element
+ * @param {Object} doc Document object
+ */
+// Called only from within defaultDisplay
+function actualDisplay( name, doc ) {
+	var style,
+		elem = jQuery( doc.createElement( name ) ).appendTo( doc.body ),
+
+		// getDefaultComputedStyle might be reliably used only on attached element
+		display = window.getDefaultComputedStyle && ( style = window.getDefaultComputedStyle( elem[ 0 ] ) ) ?
+
+			// Use of this method is a temporary fix (more like optimization) until something better comes along,
+			// since it was removed from specification and supported only in FF
+			style.display : jQuery.css( elem[ 0 ], "display" );
+
+	// We don't have any data stored on the element,
+	// so use "detach" method as fast way to get rid of the element
+	elem.detach();
+
+	return display;
+}
+
+/**
+ * Try to determine the default display value of an element
+ * @param {String} nodeName
+ */
+function defaultDisplay( nodeName ) {
+	var doc = document,
+		display = elemdisplay[ nodeName ];
+
+	if ( !display ) {
+		display = actualDisplay( nodeName, doc );
+
+		// If the simple way fails, read from inside an iframe
+		if ( display === "none" || !display ) {
+
+			// Use the already-created iframe if possible
+			iframe = (iframe || jQuery( "<iframe frameborder='0' width='0' height='0'/>" )).appendTo( doc.documentElement );
+
+			// Always write a new HTML skeleton so Webkit and Firefox don't choke on reuse
+			doc = iframe[ 0 ].contentDocument;
+
+			// Support: IE
+			doc.write();
+			doc.close();
+
+			display = actualDisplay( nodeName, doc );
+			iframe.detach();
+		}
+
+		// Store the correct default display
+		elemdisplay[ nodeName ] = display;
+	}
+
+	return display;
+}
+var rmargin = (/^margin/);
+
+var rnumnonpx = new RegExp( "^(" + pnum + ")(?!px)[a-z%]+$", "i" );
+
+var getStyles = function( elem ) {
+		// Support: IE<=11+, Firefox<=30+ (#15098, #14150)
+		// IE throws on elements created in popups
+		// FF meanwhile throws on frame elements through "defaultView.getComputedStyle"
+		if ( elem.ownerDocument.defaultView.opener ) {
+			return elem.ownerDocument.defaultView.getComputedStyle( elem, null );
+		}
+
+		return window.getComputedStyle( elem, null );
+	};
+
+
+
+function curCSS( elem, name, computed ) {
+	var width, minWidth, maxWidth, ret,
+		style = elem.style;
+
+	computed = computed || getStyles( elem );
+
+	// Support: IE9
+	// getPropertyValue is only needed for .css('filter') (#12537)
+	if ( computed ) {
+		ret = computed.getPropertyValue( name ) || computed[ name ];
+	}
+
+	if ( computed ) {
+
+		if ( ret === "" && !jQuery.contains( elem.ownerDocument, elem ) ) {
+			ret = jQuery.style( elem, name );
+		}
+
+		// Support: iOS < 6
+		// A tribute to the "awesome hack by Dean Edwards"
+		// iOS < 6 (at least) returns percentage for a larger set of values, but width seems to be reliably pixels
+		// this is against the CSSOM draft spec: http://dev.w3.org/csswg/cssom/#resolved-values
+		if ( rnumnonpx.test( ret ) && rmargin.test( name ) ) {
+
+			// Remember the original values
+			width = style.width;
+			minWidth = style.minWidth;
+			maxWidth = style.maxWidth;
+
+			// Put in the new values to get a computed value out
+			style.minWidth = style.maxWidth = style.width = ret;
+			ret = computed.width;
+
+			// Revert the changed values
+			style.width = width;
+			style.minWidth = minWidth;
+			style.maxWidth = maxWidth;
+		}
+	}
+
+	return ret !== undefined ?
+		// Support: IE
+		// IE returns zIndex value as an integer.
+		ret + "" :
+		ret;
+}
+
+
+function addGetHookIf( conditionFn, hookFn ) {
+	// Define the hook, we'll check on the first run if it's really needed.
+	return {
+		get: function() {
+			if ( conditionFn() ) {
+				// Hook not needed (or it's not possible to use it due
+				// to missing dependency), remove it.
+				delete this.get;
+				return;
+			}
+
+			// Hook needed; redefine it so that the support test is not executed again.
+			return (this.get = hookFn).apply( this, arguments );
+		}
+	};
+}
+
+
+(function() {
+	var pixelPositionVal, boxSizingReliableVal,
+		docElem = document.documentElement,
+		container = document.createElement( "div" ),
+		div = document.createElement( "div" );
+
+	if ( !div.style ) {
+		return;
+	}
+
+	// Support: IE9-11+
+	// Style of cloned element affects source element cloned (#8908)
+	div.style.backgroundClip = "content-box";
+	div.cloneNode( true ).style.backgroundClip = "";
+	support.clearCloneStyle = div.style.backgroundClip === "content-box";
+
+	container.style.cssText = "border:0;width:0;height:0;top:0;left:-9999px;margin-top:1px;" +
+		"position:absolute";
+	container.appendChild( div );
+
+	// Executing both pixelPosition & boxSizingReliable tests require only one layout
+	// so they're executed at the same time to save the second computation.
+	function computePixelPositionAndBoxSizingReliable() {
+		div.style.cssText =
+			// Support: Firefox<29, Android 2.3
+			// Vendor-prefix box-sizing
+			"-webkit-box-sizing:border-box;-moz-box-sizing:border-box;" +
+			"box-sizing:border-box;display:block;margin-top:1%;top:1%;" +
+			"border:1px;padding:1px;width:4px;position:absolute";
+		div.innerHTML = "";
+		docElem.appendChild( container );
+
+		var divStyle = window.getComputedStyle( div, null );
+		pixelPositionVal = divStyle.top !== "1%";
+		boxSizingReliableVal = divStyle.width === "4px";
+
+		docElem.removeChild( container );
+	}
+
+	// Support: node.js jsdom
+	// Don't assume that getComputedStyle is a property of the global object
+	if ( window.getComputedStyle ) {
+		jQuery.extend( support, {
+			pixelPosition: function() {
+
+				// This test is executed only once but we still do memoizing
+				// since we can use the boxSizingReliable pre-computing.
+				// No need to check if the test was already performed, though.
+				computePixelPositionAndBoxSizingReliable();
+				return pixelPositionVal;
+			},
+			boxSizingReliable: function() {
+				if ( boxSizingReliableVal == null ) {
+					computePixelPositionAndBoxSizingReliable();
+				}
+				return boxSizingReliableVal;
+			},
+			reliableMarginRight: function() {
+
+				// Support: Android 2.3
+				// Check if div with explicit width and no margin-right incorrectly
+				// gets computed margin-right based on width of container. (#3333)
+				// WebKit Bug 13343 - getComputedStyle returns wrong value for margin-right
+				// This support function is only executed once so no memoizing is needed.
+				var ret,
+					marginDiv = div.appendChild( document.createElement( "div" ) );
+
+				// Reset CSS: box-sizing; display; margin; border; padding
+				marginDiv.style.cssText = div.style.cssText =
+					// Support: Firefox<29, Android 2.3
+					// Vendor-prefix box-sizing
+					"-webkit-box-sizing:content-box;-moz-box-sizing:content-box;" +
+					"box-sizing:content-box;display:block;margin:0;border:0;padding:0";
+				marginDiv.style.marginRight = marginDiv.style.width = "0";
+				div.style.width = "1px";
+				docElem.appendChild( container );
+
+				ret = !parseFloat( window.getComputedStyle( marginDiv, null ).marginRight );
+
+				docElem.removeChild( container );
+				div.removeChild( marginDiv );
+
+				return ret;
+			}
+		});
+	}
+})();
+
+
+// A method for quickly swapping in/out CSS properties to get correct calculations.
+jQuery.swap = function( elem, options, callback, args ) {
+	var ret, name,
+		old = {};
+
+	// Remember the old values, and insert the new ones
+	for ( name in options ) {
+		old[ name ] = elem.style[ name ];
+		elem.style[ name ] = options[ name ];
+	}
+
+	ret = callback.apply( elem, args || [] );
+
+	// Revert the old values
+	for ( name in options ) {
+		elem.style[ name ] = old[ name ];
+	}
+
+	return ret;
+};
+
+
+var
+	// Swappable if display is none or starts with table except "table", "table-cell", or "table-caption"
+	// See here for display values: https://developer.mozilla.org/en-US/docs/CSS/display
+	rdisplayswap = /^(none|table(?!-c[ea]).+)/,
+	rnumsplit = new RegExp( "^(" + pnum + ")(.*)$", "i" ),
+	rrelNum = new RegExp( "^([+-])=(" + pnum + ")", "i" ),
+
+	cssShow = { position: "absolute", visibility: "hidden", display: "block" },
+	cssNormalTransform = {
+		letterSpacing: "0",
+		fontWeight: "400"
+	},
+
+	cssPrefixes = [ "Webkit", "O", "Moz", "ms" ];
+
+// Return a css property mapped to a potentially vendor prefixed property
+function vendorPropName( style, name ) {
+
+	// Shortcut for names that are not vendor prefixed
+	if ( name in style ) {
+		return name;
+	}
+
+	// Check for vendor prefixed names
+	var capName = name[0].toUpperCase() + name.slice(1),
+		origName = name,
+		i = cssPrefixes.length;
+
+	while ( i-- ) {
+		name = cssPrefixes[ i ] + capName;
+		if ( name in style ) {
+			return name;
+		}
+	}
+
+	return origName;
+}
+
+function setPositiveNumber( elem, value, subtract ) {
+	var matches = rnumsplit.exec( value );
+	return matches ?
+		// Guard against undefined "subtract", e.g., when used as in cssHooks
+		Math.max( 0, matches[ 1 ] - ( subtract || 0 ) ) + ( matches[ 2 ] || "px" ) :
+		value;
+}
+
+function augmentWidthOrHeight( elem, name, extra, isBorderBox, styles ) {
+	var i = extra === ( isBorderBox ? "border" : "content" ) ?
+		// If we already have the right measurement, avoid augmentation
+		4 :
+		// Otherwise initialize for horizontal or vertical properties
+		name === "width" ? 1 : 0,
+
+		val = 0;
+
+	for ( ; i < 4; i += 2 ) {
+		// Both box models exclude margin, so add it if we want it
+		if ( extra === "margin" ) {
+			val += jQuery.css( elem, extra + cssExpand[ i ], true, styles );
+		}
+
+		if ( isBorderBox ) {
+			// border-box includes padding, so remove it if we want content
+			if ( extra === "content" ) {
+				val -= jQuery.css( elem, "padding" + cssExpand[ i ], true, styles );
+			}
+
+			// At this point, extra isn't border nor margin, so remove border
+			if ( extra !== "margin" ) {
+				val -= jQuery.css( elem, "border" + cssExpand[ i ] + "Width", true, styles );
+			}
+		} else {
+			// At this point, extra isn't content, so add padding
+			val += jQuery.css( elem, "padding" + cssExpand[ i ], true, styles );
+
+			// At this point, extra isn't content nor padding, so add border
+			if ( extra !== "padding" ) {
+				val += jQuery.css( elem, "border" + cssExpand[ i ] + "Width", true, styles );
+			}
+		}
+	}
+
+	return val;
+}
+
+function getWidthOrHeight( elem, name, extra ) {
+
+	// Start with offset property, which is equivalent to the border-box value
+	var valueIsBorderBox = true,
+		val = name === "width" ? elem.offsetWidth : elem.offsetHeight,
+		styles = getStyles( elem ),
+		isBorderBox = jQuery.css( elem, "boxSizing", false, styles ) === "border-box";
+
+	// Some non-html elements return undefined for offsetWidth, so check for null/undefined
+	// svg - https://bugzilla.mozilla.org/show_bug.cgi?id=649285
+	// MathML - https://bugzilla.mozilla.org/show_bug.cgi?id=491668
+	if ( val <= 0 || val == null ) {
+		// Fall back to computed then uncomputed css if necessary
+		val = curCSS( elem, name, styles );
+		if ( val < 0 || val == null ) {
+			val = elem.style[ name ];
+		}
+
+		// Computed unit is not pixels. Stop here and return.
+		if ( rnumnonpx.test(val) ) {
+			return val;
+		}
+
+		// Check for style in case a browser which returns unreliable values
+		// for getComputedStyle silently falls back to the reliable elem.style
+		valueIsBorderBox = isBorderBox &&
+			( support.boxSizingReliable() || val === elem.style[ name ] );
+
+		// Normalize "", auto, and prepare for extra
+		val = parseFloat( val ) || 0;
+	}
+
+	// Use the active box-sizing model to add/subtract irrelevant styles
+	return ( val +
+		augmentWidthOrHeight(
+			elem,
+			name,
+			extra || ( isBorderBox ? "border" : "content" ),
+			valueIsBorderBox,
+			styles
+		)
+	) + "px";
+}
+
+function showHide( elements, show ) {
+	var display, elem, hidden,
+		values = [],
+		index = 0,
+		length = elements.length;
+
+	for ( ; index < length; index++ ) {
+		elem = elements[ index ];
+		if ( !elem.style ) {
+			continue;
+		}
+
+		values[ index ] = data_priv.get( elem, "olddisplay" );
+		display = elem.style.display;
+		if ( show ) {
+			// Reset the inline display of this element to learn if it is
+			// being hidden by cascaded rules or not
+			if ( !values[ index ] && display === "none" ) {
+				elem.style.display = "";
+			}
+
+			// Set elements which have been overridden with display: none
+			// in a stylesheet to whatever the default browser style is
+			// for such an element
+			if ( elem.style.display === "" && isHidden( elem ) ) {
+				values[ index ] = data_priv.access( elem, "olddisplay", defaultDisplay(elem.nodeName) );
+			}
+		} else {
+			hidden = isHidden( elem );
+
+			if ( display !== "none" || !hidden ) {
+				data_priv.set( elem, "olddisplay", hidden ? display : jQuery.css( elem, "display" ) );
+			}
+		}
+	}
+
+	// Set the display of most of the elements in a second loop
+	// to avoid the constant reflow
+	for ( index = 0; index < length; index++ ) {
+		elem = elements[ index ];
+		if ( !elem.style ) {
+			continue;
+		}
+		if ( !show || elem.style.display === "none" || elem.style.display === "" ) {
+			elem.style.display = show ? values[ index ] || "" : "none";
+		}
+	}
+
+	return elements;
+}
+
+jQuery.extend({
+
+	// Add in style property hooks for overriding the default
+	// behavior of getting and setting a style property
+	cssHooks: {
+		opacity: {
+			get: function( elem, computed ) {
+				if ( computed ) {
+
+					// We should always get a number back from opacity
+					var ret = curCSS( elem, "opacity" );
+					return ret === "" ? "1" : ret;
+				}
+			}
+		}
+	},
+
+	// Don't automatically add "px" to these possibly-unitless properties
+	cssNumber: {
+		"columnCount": true,
+		"fillOpacity": true,
+		"flexGrow": true,
+		"flexShrink": true,
+		"fontWeight": true,
+		"lineHeight": true,
+		"opacity": true,
+		"order": true,
+		"orphans": true,
+		"widows": true,
+		"zIndex": true,
+		"zoom": true
+	},
+
+	// Add in properties whose names you wish to fix before
+	// setting or getting the value
+	cssProps: {
+		"float": "cssFloat"
+	},
+
+	// Get and set the style property on a DOM Node
+	style: function( elem, name, value, extra ) {
+
+		// Don't set styles on text and comment nodes
+		if ( !elem || elem.nodeType === 3 || elem.nodeType === 8 || !elem.style ) {
+			return;
+		}
+
+		// Make sure that we're working with the right name
+		var ret, type, hooks,
+			origName = jQuery.camelCase( name ),
+			style = elem.style;
+
+		name = jQuery.cssProps[ origName ] || ( jQuery.cssProps[ origName ] = vendorPropName( style, origName ) );
+
+		// Gets hook for the prefixed version, then unprefixed version
+		hooks = jQuery.cssHooks[ name ] || jQuery.cssHooks[ origName ];
+
+		// Check if we're setting a value
+		if ( value !== undefined ) {
+			type = typeof value;
+
+			// Convert "+=" or "-=" to relative numbers (#7345)
+			if ( type === "string" && (ret = rrelNum.exec( value )) ) {
+				value = ( ret[1] + 1 ) * ret[2] + parseFloat( jQuery.css( elem, name ) );
+				// Fixes bug #9237
+				type = "number";
+			}
+
+			// Make sure that null and NaN values aren't set (#7116)
+			if ( value == null || value !== value ) {
+				return;
+			}
+
+			// If a number, add 'px' to the (except for certain CSS properties)
+			if ( type === "number" && !jQuery.cssNumber[ origName ] ) {
+				value += "px";
+			}
+
+			// Support: IE9-11+
+			// background-* props affect original clone's values
+			if ( !support.clearCloneStyle && value === "" && name.indexOf( "background" ) === 0 ) {
+				style[ name ] = "inherit";
+			}
+
+			// If a hook was provided, use that value, otherwise just set the specified value
+			if ( !hooks || !("set" in hooks) || (value = hooks.set( elem, value, extra )) !== undefined ) {
+				style[ name ] = value;
+			}
+
+		} else {
+			// If a hook was provided get the non-computed value from there
+			if ( hooks && "get" in hooks && (ret = hooks.get( elem, false, extra )) !== undefined ) {
+				return ret;
+			}
+
+			// Otherwise just get the value from the style object
+			return style[ name ];
+		}
+	},
+
+	css: function( elem, name, extra, styles ) {
+		var val, num, hooks,
+			origName = jQuery.camelCase( name );
+
+		// Make sure that we're working with the right name
+		name = jQuery.cssProps[ origName ] || ( jQuery.cssProps[ origName ] = vendorPropName( elem.style, origName ) );
+
+		// Try prefixed name followed by the unprefixed name
+		hooks = jQuery.cssHooks[ name ] || jQuery.cssHooks[ origName ];
+
+		// If a hook was provided get the computed value from there
+		if ( hooks && "get" in hooks ) {
+			val = hooks.get( elem, true, extra );
+		}
+
+		// Otherwise, if a way to get the computed value exists, use that
+		if ( val === undefined ) {
+			val = curCSS( elem, name, styles );
+		}
+
+		// Convert "normal" to computed value
+		if ( val === "normal" && name in cssNormalTransform ) {
+			val = cssNormalTransform[ name ];
+		}
+
+		// Make numeric if forced or a qualifier was provided and val looks numeric
+		if ( extra === "" || extra ) {
+			num = parseFloat( val );
+			return extra === true || jQuery.isNumeric( num ) ? num || 0 : val;
+		}
+		return val;
+	}
+});
+
+jQuery.each([ "height", "width" ], function( i, name ) {
+	jQuery.cssHooks[ name ] = {
+		get: function( elem, computed, extra ) {
+			if ( computed ) {
+
+				// Certain elements can have dimension info if we invisibly show them
+				// but it must have a current display style that would benefit
+				return rdisplayswap.test( jQuery.css( elem, "display" ) ) && elem.offsetWidth === 0 ?
+					jQuery.swap( elem, cssShow, function() {
+						return getWidthOrHeight( elem, name, extra );
+					}) :
+					getWidthOrHeight( elem, name, extra );
+			}
+		},
+
+		set: function( elem, value, extra ) {
+			var styles = extra && getStyles( elem );
+			return setPositiveNumber( elem, value, extra ?
+				augmentWidthOrHeight(
+					elem,
+					name,
+					extra,
+					jQuery.css( elem, "boxSizing", false, styles ) === "border-box",
+					styles
+				) : 0
+			);
+		}
+	};
+});
+
+// Support: Android 2.3
+jQuery.cssHooks.marginRight = addGetHookIf( support.reliableMarginRight,
+	function( elem, computed ) {
+		if ( computed ) {
+			return jQuery.swap( elem, { "display": "inline-block" },
+				curCSS, [ elem, "marginRight" ] );
+		}
+	}
+);
+
+// These hooks are used by animate to expand properties
+jQuery.each({
+	margin: "",
+	padding: "",
+	border: "Width"
+}, function( prefix, suffix ) {
+	jQuery.cssHooks[ prefix + suffix ] = {
+		expand: function( value ) {
+			var i = 0,
+				expanded = {},
+
+				// Assumes a single number if not a string
+				parts = typeof value === "string" ? value.split(" ") : [ value ];
+
+			for ( ; i < 4; i++ ) {
+				expanded[ prefix + cssExpand[ i ] + suffix ] =
+					parts[ i ] || parts[ i - 2 ] || parts[ 0 ];
+			}
+
+			return expanded;
+		}
+	};
+
+	if ( !rmargin.test( prefix ) ) {
+		jQuery.cssHooks[ prefix + suffix ].set = setPositiveNumber;
+	}
+});
+
+jQuery.fn.extend({
+	css: function( name, value ) {
+		return access( this, function( elem, name, value ) {
+			var styles, len,
+				map = {},
+				i = 0;
+
+			if ( jQuery.isArray( name ) ) {
+				styles = getStyles( elem );
+				len = name.length;
+
+				for ( ; i < len; i++ ) {
+					map[ name[ i ] ] = jQuery.css( elem, name[ i ], false, styles );
+				}
+
+				return map;
+			}
+
+			return value !== undefined ?
+				jQuery.style( elem, name, value ) :
+				jQuery.css( elem, name );
+		}, name, value, arguments.length > 1 );
+	},
+	show: function() {
+		return showHide( this, true );
+	},
+	hide: function() {
+		return showHide( this );
+	},
+	toggle: function( state ) {
+		if ( typeof state === "boolean" ) {
+			return state ? this.show() : this.hide();
+		}
+
+		return this.each(function() {
+			if ( isHidden( this ) ) {
+				jQuery( this ).show();
+			} else {
+				jQuery( this ).hide();
+			}
+		});
+	}
+});
+
+
+function Tween( elem, options, prop, end, easing ) {
+	return new Tween.prototype.init( elem, options, prop, end, easing );
+}
+jQuery.Tween = Tween;
+
+Tween.prototype = {
+	constructor: Tween,
+	init: function( elem, options, prop, end, easing, unit ) {
+		this.elem = elem;
+		this.prop = prop;
+		this.easing = easing || "swing";
+		this.options = options;
+		this.start = this.now = this.cur();
+		this.end = end;
+		this.unit = unit || ( jQuery.cssNumber[ prop ] ? "" : "px" );
+	},
+	cur: function() {
+		var hooks = Tween.propHooks[ this.prop ];
+
+		return hooks && hooks.get ?
+			hooks.get( this ) :
+			Tween.propHooks._default.get( this );
+	},
+	run: function( percent ) {
+		var eased,
+			hooks = Tween.propHooks[ this.prop ];
+
+		if ( this.options.duration ) {
+			this.pos = eased = jQuery.easing[ this.easing ](
+				percent, this.options.duration * percent, 0, 1, this.options.duration
+			);
+		} else {
+			this.pos = eased = percent;
+		}
+		this.now = ( this.end - this.start ) * eased + this.start;
+
+		if ( this.options.step ) {
+			this.options.step.call( this.elem, this.now, this );
+		}
+
+		if ( hooks && hooks.set ) {
+			hooks.set( this );
+		} else {
+			Tween.propHooks._default.set( this );
+		}
+		return this;
+	}
+};
+
+Tween.prototype.init.prototype = Tween.prototype;
+
+Tween.propHooks = {
+	_default: {
+		get: function( tween ) {
+			var result;
+
+			if ( tween.elem[ tween.prop ] != null &&
+				(!tween.elem.style || tween.elem.style[ tween.prop ] == null) ) {
+				return tween.elem[ tween.prop ];
+			}
+
+			// Passing an empty string as a 3rd parameter to .css will automatically
+			// attempt a parseFloat and fallback to a string if the parse fails.
+			// Simple values such as "10px" are parsed to Float;
+			// complex values such as "rotate(1rad)" are returned as-is.
+			result = jQuery.css( tween.elem, tween.prop, "" );
+			// Empty strings, null, undefined and "auto" are converted to 0.
+			return !result || result === "auto" ? 0 : result;
+		},
+		set: function( tween ) {
+			// Use step hook for back compat.
+			// Use cssHook if its there.
+			// Use .style if available and use plain properties where available.
+			if ( jQuery.fx.step[ tween.prop ] ) {
+				jQuery.fx.step[ tween.prop ]( tween );
+			} else if ( tween.elem.style && ( tween.elem.style[ jQuery.cssProps[ tween.prop ] ] != null || jQuery.cssHooks[ tween.prop ] ) ) {
+				jQuery.style( tween.elem, tween.prop, tween.now + tween.unit );
+			} else {
+				tween.elem[ tween.prop ] = tween.now;
+			}
+		}
+	}
+};
+
+// Support: IE9
+// Panic based approach to setting things on disconnected nodes
+Tween.propHooks.scrollTop = Tween.propHooks.scrollLeft = {
+	set: function( tween ) {
+		if ( tween.elem.nodeType && tween.elem.parentNode ) {
+			tween.elem[ tween.prop ] = tween.now;
+		}
+	}
+};
+
+jQuery.easing = {
+	linear: function( p ) {
+		return p;
+	},
+	swing: function( p ) {
+		return 0.5 - Math.cos( p * Math.PI ) / 2;
+	}
+};
+
+jQuery.fx = Tween.prototype.init;
+
+// Back Compat <1.8 extension point
+jQuery.fx.step = {};
+
+
+
+
+var
+	fxNow, timerId,
+	rfxtypes = /^(?:toggle|show|hide)$/,
+	rfxnum = new RegExp( "^(?:([+-])=|)(" + pnum + ")([a-z%]*)$", "i" ),
+	rrun = /queueHooks$/,
+	animationPrefilters = [ defaultPrefilter ],
+	tweeners = {
+		"*": [ function( prop, value ) {
+			var tween = this.createTween( prop, value ),
+				target = tween.cur(),
+				parts = rfxnum.exec( value ),
+				unit = parts && parts[ 3 ] || ( jQuery.cssNumber[ prop ] ? "" : "px" ),
+
+				// Starting value computation is required for potential unit mismatches
+				start = ( jQuery.cssNumber[ prop ] || unit !== "px" && +target ) &&
+					rfxnum.exec( jQuery.css( tween.elem, prop ) ),
+				scale = 1,
+				maxIterations = 20;
+
+			if ( start && start[ 3 ] !== unit ) {
+				// Trust units reported by jQuery.css
+				unit = unit || start[ 3 ];
+
+				// Make sure we update the tween properties later on
+				parts = parts || [];
+
+				// Iteratively approximate from a nonzero starting point
+				start = +target || 1;
+
+				do {
+					// If previous iteration zeroed out, double until we get *something*.
+					// Use string for doubling so we don't accidentally see scale as unchanged below
+					scale = scale || ".5";
+
+					// Adjust and apply
+					start = start / scale;
+					jQuery.style( tween.elem, prop, start + unit );
+
+				// Update scale, tolerating zero or NaN from tween.cur(),
+				// break the loop if scale is unchanged or perfect, or if we've just had enough
+				} while ( scale !== (scale = tween.cur() / target) && scale !== 1 && --maxIterations );
+			}
+
+			// Update tween properties
+			if ( parts ) {
+				start = tween.start = +start || +target || 0;
+				tween.unit = unit;
+				// If a +=/-= token was provided, we're doing a relative animation
+				tween.end = parts[ 1 ] ?
+					start + ( parts[ 1 ] + 1 ) * parts[ 2 ] :
+					+parts[ 2 ];
+			}
+
+			return tween;
+		} ]
+	};
+
+// Animations created synchronously will run synchronously
+function createFxNow() {
+	setTimeout(function() {
+		fxNow = undefined;
+	});
+	return ( fxNow = jQuery.now() );
+}
+
+// Generate parameters to create a standard animation
+function genFx( type, includeWidth ) {
+	var which,
+		i = 0,
+		attrs = { height: type };
+
+	// If we include width, step value is 1 to do all cssExpand values,
+	// otherwise step value is 2 to skip over Left and Right
+	includeWidth = includeWidth ? 1 : 0;
+	for ( ; i < 4 ; i += 2 - includeWidth ) {
+		which = cssExpand[ i ];
+		attrs[ "margin" + which ] = attrs[ "padding" + which ] = type;
+	}
+
+	if ( includeWidth ) {
+		attrs.opacity = attrs.width = type;
+	}
+
+	return attrs;
+}
+
+function createTween( value, prop, animation ) {
+	var tween,
+		collection = ( tweeners[ prop ] || [] ).concat( tweeners[ "*" ] ),
+		index = 0,
+		length = collection.length;
+	for ( ; index < length; index++ ) {
+		if ( (tween = collection[ index ].call( animation, prop, value )) ) {
+
+			// We're done with this property
+			return tween;
+		}
+	}
+}
+
+function defaultPrefilter( elem, props, opts ) {
+	/* jshint validthis: true */
+	var prop, value, toggle, tween, hooks, oldfire, display, checkDisplay,
+		anim = this,
+		orig = {},
+		style = elem.style,
+		hidden = elem.nodeType && isHidden( elem ),
+		dataShow = data_priv.get( elem, "fxshow" );
+
+	// Handle queue: false promises
+	if ( !opts.queue ) {
+		hooks = jQuery._queueHooks( elem, "fx" );
+		if ( hooks.unqueued == null ) {
+			hooks.unqueued = 0;
+			oldfire = hooks.empty.fire;
+			hooks.empty.fire = function() {
+				if ( !hooks.unqueued ) {
+					oldfire();
+				}
+			};
+		}
+		hooks.unqueued++;
+
+		anim.always(function() {
+			// Ensure the complete handler is called before this completes
+			anim.always(function() {
+				hooks.unqueued--;
+				if ( !jQuery.queue( elem, "fx" ).length ) {
+					hooks.empty.fire();
+				}
+			});
+		});
+	}
+
+	// Height/width overflow pass
+	if ( elem.nodeType === 1 && ( "height" in props || "width" in props ) ) {
+		// Make sure that nothing sneaks out
+		// Record all 3 overflow attributes because IE9-10 do not
+		// change the overflow attribute when overflowX and
+		// overflowY are set to the same value
+		opts.overflow = [ style.overflow, style.overflowX, style.overflowY ];
+
+		// Set display property to inline-block for height/width
+		// animations on inline elements that are having width/height animated
+		display = jQuery.css( elem, "display" );
+
+		// Test default display if display is currently "none"
+		checkDisplay = display === "none" ?
+			data_priv.get( elem, "olddisplay" ) || defaultDisplay( elem.nodeName ) : display;
+
+		if ( checkDisplay === "inline" && jQuery.css( elem, "float" ) === "none" ) {
+			style.display = "inline-block";
+		}
+	}
+
+	if ( opts.overflow ) {
+		style.overflow = "hidden";
+		anim.always(function() {
+			style.overflow = opts.overflow[ 0 ];
+			style.overflowX = opts.overflow[ 1 ];
+			style.overflowY = opts.overflow[ 2 ];
+		});
+	}
+
+	// show/hide pass
+	for ( prop in props ) {
+		value = props[ prop ];
+		if ( rfxtypes.exec( value ) ) {
+			delete props[ prop ];
+			toggle = toggle || value === "toggle";
+			if ( value === ( hidden ? "hide" : "show" ) ) {
+
+				// If there is dataShow left over from a stopped hide or show and we are going to proceed with show, we should pretend to be hidden
+				if ( value === "show" && dataShow && dataShow[ prop ] !== undefined ) {
+					hidden = true;
+				} else {
+					continue;
+				}
+			}
+			orig[ prop ] = dataShow && dataShow[ prop ] || jQuery.style( elem, prop );
+
+		// Any non-fx value stops us from restoring the original display value
+		} else {
+			display = undefined;
+		}
+	}
+
+	if ( !jQuery.isEmptyObject( orig ) ) {
+		if ( dataShow ) {
+			if ( "hidden" in dataShow ) {
+				hidden = dataShow.hidden;
+			}
+		} else {
+			dataShow = data_priv.access( elem, "fxshow", {} );
+		}
+
+		// Store state if its toggle - enables .stop().toggle() to "reverse"
+		if ( toggle ) {
+			dataShow.hidden = !hidden;
+		}
+		if ( hidden ) {
+			jQuery( elem ).show();
+		} else {
+			anim.done(function() {
+				jQuery( elem ).hide();
+			});
+		}
+		anim.done(function() {
+			var prop;
+
+			data_priv.remove( elem, "fxshow" );
+			for ( prop in orig ) {
+				jQuery.style( elem, prop, orig[ prop ] );
+			}
+		});
+		for ( prop in orig ) {
+			tween = createTween( hidden ? dataShow[ prop ] : 0, prop, anim );
+
+			if ( !( prop in dataShow ) ) {
+				dataShow[ prop ] = tween.start;
+				if ( hidden ) {
+					tween.end = tween.start;
+					tween.start = prop === "width" || prop === "height" ? 1 : 0;
+				}
+			}
+		}
+
+	// If this is a noop like .hide().hide(), restore an overwritten display value
+	} else if ( (display === "none" ? defaultDisplay( elem.nodeName ) : display) === "inline" ) {
+		style.display = display;
+	}
+}
+
+function propFilter( props, specialEasing ) {
+	var index, name, easing, value, hooks;
+
+	// camelCase, specialEasing and expand cssHook pass
+	for ( index in props ) {
+		name = jQuery.camelCase( index );
+		easing = specialEasing[ name ];
+		value = props[ index ];
+		if ( jQuery.isArray( value ) ) {
+			easing = value[ 1 ];
+			value = props[ index ] = value[ 0 ];
+		}
+
+		if ( index !== name ) {
+			props[ name ] = value;
+			delete props[ index ];
+		}
+
+		hooks = jQuery.cssHooks[ name ];
+		if ( hooks && "expand" in hooks ) {
+			value = hooks.expand( value );
+			delete props[ name ];
+
+			// Not quite $.extend, this won't overwrite existing keys.
+			// Reusing 'index' because we have the correct "name"
+			for ( index in value ) {
+				if ( !( index in props ) ) {
+					props[ index ] = value[ index ];
+					specialEasing[ index ] = easing;
+				}
+			}
+		} else {
+			specialEasing[ name ] = easing;
+		}
+	}
+}
+
+function Animation( elem, properties, options ) {
+	var result,
+		stopped,
+		index = 0,
+		length = animationPrefilters.length,
+		deferred = jQuery.Deferred().always( function() {
+			// Don't match elem in the :animated selector
+			delete tick.elem;
+		}),
+		tick = function() {
+			if ( stopped ) {
+				return false;
+			}
+			var currentTime = fxNow || createFxNow(),
+				remaining = Math.max( 0, animation.startTime + animation.duration - currentTime ),
+				// Support: Android 2.3
+				// Archaic crash bug won't allow us to use `1 - ( 0.5 || 0 )` (#12497)
+				temp = remaining / animation.duration || 0,
+				percent = 1 - temp,
+				index = 0,
+				length = animation.tweens.length;
+
+			for ( ; index < length ; index++ ) {
+				animation.tweens[ index ].run( percent );
+			}
+
+			deferred.notifyWith( elem, [ animation, percent, remaining ]);
+
+			if ( percent < 1 && length ) {
+				return remaining;
+			} else {
+				deferred.resolveWith( elem, [ animation ] );
+				return false;
+			}
+		},
+		animation = deferred.promise({
+			elem: elem,
+			props: jQuery.extend( {}, properties ),
+			opts: jQuery.extend( true, { specialEasing: {} }, options ),
+			originalProperties: properties,
+			originalOptions: options,
+			startTime: fxNow || createFxNow(),
+			duration: options.duration,
+			tweens: [],
+			createTween: function( prop, end ) {
+				var tween = jQuery.Tween( elem, animation.opts, prop, end,
+						animation.opts.specialEasing[ prop ] || animation.opts.easing );
+				animation.tweens.push( tween );
+				return tween;
+			},
+			stop: function( gotoEnd ) {
+				var index = 0,
+					// If we are going to the end, we want to run all the tweens
+					// otherwise we skip this part
+					length = gotoEnd ? animation.tweens.length : 0;
+				if ( stopped ) {
+					return this;
+				}
+				stopped = true;
+				for ( ; index < length ; index++ ) {
+					animation.tweens[ index ].run( 1 );
+				}
+
+				// Resolve when we played the last frame; otherwise, reject
+				if ( gotoEnd ) {
+					deferred.resolveWith( elem, [ animation, gotoEnd ] );
+				} else {
+					deferred.rejectWith( elem, [ animation, gotoEnd ] );
+				}
+				return this;
+			}
+		}),
+		props = animation.props;
+
+	propFilter( props, animation.opts.specialEasing );
+
+	for ( ; index < length ; index++ ) {
+		result = animationPrefilters[ index ].call( animation, elem, props, animation.opts );
+		if ( result ) {
+			return result;
+		}
+	}
+
+	jQuery.map( props, createTween, animation );
+
+	if ( jQuery.isFunction( animation.opts.start ) ) {
+		animation.opts.start.call( elem, animation );
+	}
+
+	jQuery.fx.timer(
+		jQuery.extend( tick, {
+			elem: elem,
+			anim: animation,
+			queue: animation.opts.queue
+		})
+	);
+
+	// attach callbacks from options
+	return animation.progress( animation.opts.progress )
+		.done( animation.opts.done, animation.opts.complete )
+		.fail( animation.opts.fail )
+		.always( animation.opts.always );
+}
+
+jQuery.Animation = jQuery.extend( Animation, {
+
+	tweener: function( props, callback ) {
+		if ( jQuery.isFunction( props ) ) {
+			callback = props;
+			props = [ "*" ];
+		} else {
+			props = props.split(" ");
+		}
+
+		var prop,
+			index = 0,
+			length = props.length;
+
+		for ( ; index < length ; index++ ) {
+			prop = props[ index ];
+			tweeners[ prop ] = tweeners[ prop ] || [];
+			tweeners[ prop ].unshift( callback );
+		}
+	},
+
+	prefilter: function( callback, prepend ) {
+		if ( prepend ) {
+			animationPrefilters.unshift( callback );
+		} else {
+			animationPrefilters.push( callback );
+		}
+	}
+});
+
+jQuery.speed = function( speed, easing, fn ) {
+	var opt = speed && typeof speed === "object" ? jQuery.extend( {}, speed ) : {
+		complete: fn || !fn && easing ||
+			jQuery.isFunction( speed ) && speed,
+		duration: speed,
+		easing: fn && easing || easing && !jQuery.isFunction( easing ) && easing
+	};
+
+	opt.duration = jQuery.fx.off ? 0 : typeof opt.duration === "number" ? opt.duration :
+		opt.duration in jQuery.fx.speeds ? jQuery.fx.speeds[ opt.duration ] : jQuery.fx.speeds._default;
+
+	// Normalize opt.queue - true/undefined/null -> "fx"
+	if ( opt.queue == null || opt.queue === true ) {
+		opt.queue = "fx";
+	}
+
+	// Queueing
+	opt.old = opt.complete;
+
+	opt.complete = function() {
+		if ( jQuery.isFunction( opt.old ) ) {
+			opt.old.call( this );
+		}
+
+		if ( opt.queue ) {
+			jQuery.dequeue( this, opt.queue );
+		}
+	};
+
+	return opt;
+};
+
+jQuery.fn.extend({
+	fadeTo: function( speed, to, easing, callback ) {
+
+		// Show any hidden elements after setting opacity to 0
+		return this.filter( isHidden ).css( "opacity", 0 ).show()
+
+			// Animate to the value specified
+			.end().animate({ opacity: to }, speed, easing, callback );
+	},
+	animate: function( prop, speed, easing, callback ) {
+		var empty = jQuery.isEmptyObject( prop ),
+			optall = jQuery.speed( speed, easing, callback ),
+			doAnimation = function() {
+				// Operate on a copy of prop so per-property easing won't be lost
+				var anim = Animation( this, jQuery.extend( {}, prop ), optall );
+
+				// Empty animations, or finishing resolves immediately
+				if ( empty || data_priv.get( this, "finish" ) ) {
+					anim.stop( true );
+				}
+			};
+			doAnimation.finish = doAnimation;
+
+		return empty || optall.queue === false ?
+			this.each( doAnimation ) :
+			this.queue( optall.queue, doAnimation );
+	},
+	stop: function( type, clearQueue, gotoEnd ) {
+		var stopQueue = function( hooks ) {
+			var stop = hooks.stop;
+			delete hooks.stop;
+			stop( gotoEnd );
+		};
+
+		if ( typeof type !== "string" ) {
+			gotoEnd = clearQueue;
+			clearQueue = type;
+			type = undefined;
+		}
+		if ( clearQueue && type !== false ) {
+			this.queue( type || "fx", [] );
+		}
+
+		return this.each(function() {
+			var dequeue = true,
+				index = type != null && type + "queueHooks",
+				timers = jQuery.timers,
+				data = data_priv.get( this );
+
+			if ( index ) {
+				if ( data[ index ] && data[ index ].stop ) {
+					stopQueue( data[ index ] );
+				}
+			} else {
+				for ( index in data ) {
+					if ( data[ index ] && data[ index ].stop && rrun.test( index ) ) {
+						stopQueue( data[ index ] );
+					}
+				}
+			}
+
+			for ( index = timers.length; index--; ) {
+				if ( timers[ index ].elem === this && (type == null || timers[ index ].queue === type) ) {
+					timers[ index ].anim.stop( gotoEnd );
+					dequeue = false;
+					timers.splice( index, 1 );
+				}
+			}
+
+			// Start the next in the queue if the last step wasn't forced.
+			// Timers currently will call their complete callbacks, which
+			// will dequeue but only if they were gotoEnd.
+			if ( dequeue || !gotoEnd ) {
+				jQuery.dequeue( this, type );
+			}
+		});
+	},
+	finish: function( type ) {
+		if ( type !== false ) {
+			type = type || "fx";
+		}
+		return this.each(function() {
+			var index,
+				data = data_priv.get( this ),
+				queue = data[ type + "queue" ],
+				hooks = data[ type + "queueHooks" ],
+				timers = jQuery.timers,
+				length = queue ? queue.length : 0;
+
+			// Enable finishing flag on private data
+			data.finish = true;
+
+			// Empty the queue first
+			jQuery.queue( this, type, [] );
+
+			if ( hooks && hooks.stop ) {
+				hooks.stop.call( this, true );
+			}
+
+			// Look for any active animations, and finish them
+			for ( index = timers.length; index--; ) {
+				if ( timers[ index ].elem === this && timers[ index ].queue === type ) {
+					timers[ index ].anim.stop( true );
+					timers.splice( index, 1 );
+				}
+			}
+
+			// Look for any animations in the old queue and finish them
+			for ( index = 0; index < length; index++ ) {
+				if ( queue[ index ] && queue[ index ].finish ) {
+					queue[ index ].finish.call( this );
+				}
+			}
+
+			// Turn off finishing flag
+			delete data.finish;
+		});
+	}
+});
+
+jQuery.each([ "toggle", "show", "hide" ], function( i, name ) {
+	var cssFn = jQuery.fn[ name ];
+	jQuery.fn[ name ] = function( speed, easing, callback ) {
+		return speed == null || typeof speed === "boolean" ?
+			cssFn.apply( this, arguments ) :
+			this.animate( genFx( name, true ), speed, easing, callback );
+	};
+});
+
+// Generate shortcuts for custom animations
+jQuery.each({
+	slideDown: genFx("show"),
+	slideUp: genFx("hide"),
+	slideToggle: genFx("toggle"),
+	fadeIn: { opacity: "show" },
+	fadeOut: { opacity: "hide" },
+	fadeToggle: { opacity: "toggle" }
+}, function( name, props ) {
+	jQuery.fn[ name ] = function( speed, easing, callback ) {
+		return this.animate( props, speed, easing, callback );
+	};
+});
+
+jQuery.timers = [];
+jQuery.fx.tick = function() {
+	var timer,
+		i = 0,
+		timers = jQuery.timers;
+
+	fxNow = jQuery.now();
+
+	for ( ; i < timers.length; i++ ) {
+		timer = timers[ i ];
+		// Checks the timer has not already been removed
+		if ( !timer() && timers[ i ] === timer ) {
+			timers.splice( i--, 1 );
+		}
+	}
+
+	if ( !timers.length ) {
+		jQuery.fx.stop();
+	}
+	fxNow = undefined;
+};
+
+jQuery.fx.timer = function( timer ) {
+	jQuery.timers.push( timer );
+	if ( timer() ) {
+		jQuery.fx.start();
+	} else {
+		jQuery.timers.pop();
+	}
+};
+
+jQuery.fx.interval = 13;
+
+jQuery.fx.start = function() {
+	if ( !timerId ) {
+		timerId = setInterval( jQuery.fx.tick, jQuery.fx.interval );
+	}
+};
+
+jQuery.fx.stop = function() {
+	clearInterval( timerId );
+	timerId = null;
+};
+
+jQuery.fx.speeds = {
+	slow: 600,
+	fast: 200,
+	// Default speed
+	_default: 400
+};
+
+
+// Based off of the plugin by Clint Helfers, with permission.
+// http://blindsignals.com/index.php/2009/07/jquery-delay/
+jQuery.fn.delay = function( time, type ) {
+	time = jQuery.fx ? jQuery.fx.speeds[ time ] || time : time;
+	type = type || "fx";
+
+	return this.queue( type, function( next, hooks ) {
+		var timeout = setTimeout( next, time );
+		hooks.stop = function() {
+			clearTimeout( timeout );
+		};
+	});
+};
+
+
+(function() {
+	var input = document.createElement( "input" ),
+		select = document.createElement( "select" ),
+		opt = select.appendChild( document.createElement( "option" ) );
+
+	input.type = "checkbox";
+
+	// Support: iOS<=5.1, Android<=4.2+
+	// Default value for a checkbox should be "on"
+	support.checkOn = input.value !== "";
+
+	// Support: IE<=11+
+	// Must access selectedIndex to make default options select
+	support.optSelected = opt.selected;
+
+	// Support: Android<=2.3
+	// Options inside disabled selects are incorrectly marked as disabled
+	select.disabled = true;
+	support.optDisabled = !opt.disabled;
+
+	// Support: IE<=11+
+	// An input loses its value after becoming a radio
+	input = document.createElement( "input" );
+	input.value = "t";
+	input.type = "radio";
+	support.radioValue = input.value === "t";
+})();
+
+
+var nodeHook, boolHook,
+	attrHandle = jQuery.expr.attrHandle;
+
+jQuery.fn.extend({
+	attr: function( name, value ) {
+		return access( this, jQuery.attr, name, value, arguments.length > 1 );
+	},
+
+	removeAttr: function( name ) {
+		return this.each(function() {
+			jQuery.removeAttr( this, name );
+		});
+	}
+});
+
+jQuery.extend({
+	attr: function( elem, name, value ) {
+		var hooks, ret,
+			nType = elem.nodeType;
+
+		// don't get/set attributes on text, comment and attribute nodes
+		if ( !elem || nType === 3 || nType === 8 || nType === 2 ) {
+			return;
+		}
+
+		// Fallback to prop when attributes are not supported
+		if ( typeof elem.getAttribute === strundefined ) {
+			return jQuery.prop( elem, name, value );
+		}
+
+		// All attributes are lowercase
+		// Grab necessary hook if one is defined
+		if ( nType !== 1 || !jQuery.isXMLDoc( elem ) ) {
+			name = name.toLowerCase();
+			hooks = jQuery.attrHooks[ name ] ||
+				( jQuery.expr.match.bool.test( name ) ? boolHook : nodeHook );
+		}
+
+		if ( value !== undefined ) {
+
+			if ( value === null ) {
+				jQuery.removeAttr( elem, name );
+
+			} else if ( hooks && "set" in hooks && (ret = hooks.set( elem, value, name )) !== undefined ) {
+				return ret;
+
+			} else {
+				elem.setAttribute( name, value + "" );
+				return value;
+			}
+
+		} else if ( hooks && "get" in hooks && (ret = hooks.get( elem, name )) !== null ) {
+			return ret;
+
+		} else {
+			ret = jQuery.find.attr( elem, name );
+
+			// Non-existent attributes return null, we normalize to undefined
+			return ret == null ?
+				undefined :
+				ret;
+		}
+	},
+
+	removeAttr: function( elem, value ) {
+		var name, propName,
+			i = 0,
+			attrNames = value && value.match( rnotwhite );
+
+		if ( attrNames && elem.nodeType === 1 ) {
+			while ( (name = attrNames[i++]) ) {
+				propName = jQuery.propFix[ name ] || name;
+
+				// Boolean attributes get special treatment (#10870)
+				if ( jQuery.expr.match.bool.test( name ) ) {
+					// Set corresponding property to false
+					elem[ propName ] = false;
+				}
+
+				elem.removeAttribute( name );
+			}
+		}
+	},
+
+	attrHooks: {
+		type: {
+			set: function( elem, value ) {
+				if ( !support.radioValue && value === "radio" &&
+					jQuery.nodeName( elem, "input" ) ) {
+					var val = elem.value;
+					elem.setAttribute( "type", value );
+					if ( val ) {
+						elem.value = val;
+					}
+					return value;
+				}
+			}
+		}
+	}
+});
+
+// Hooks for boolean attributes
+boolHook = {
+	set: function( elem, value, name ) {
+		if ( value === false ) {
+			// Remove boolean attributes when set to false
+			jQuery.removeAttr( elem, name );
+		} else {
+			elem.setAttribute( name, name );
+		}
+		return name;
+	}
+};
+jQuery.each( jQuery.expr.match.bool.source.match( /\w+/g ), function( i, name ) {
+	var getter = attrHandle[ name ] || jQuery.find.attr;
+
+	attrHandle[ name ] = function( elem, name, isXML ) {
+		var ret, handle;
+		if ( !isXML ) {
+			// Avoid an infinite loop by temporarily removing this function from the getter
+			handle = attrHandle[ name ];
+			attrHandle[ name ] = ret;
+			ret = getter( elem, name, isXML ) != null ?
+				name.toLowerCase() :
+				null;
+			attrHandle[ name ] = handle;
+		}
+		return ret;
+	};
+});
+
+
+
+
+var rfocusable = /^(?:input|select|textarea|button)$/i;
+
+jQuery.fn.extend({
+	prop: function( name, value ) {
+		return access( this, jQuery.prop, name, value, arguments.length > 1 );
+	},
+
+	removeProp: function( name ) {
+		return this.each(function() {
+			delete this[ jQuery.propFix[ name ] || name ];
+		});
+	}
+});
+
+jQuery.extend({
+	propFix: {
+		"for": "htmlFor",
+		"class": "className"
+	},
+
+	prop: function( elem, name, value ) {
+		var ret, hooks, notxml,
+			nType = elem.nodeType;
+
+		// Don't get/set properties on text, comment and attribute nodes
+		if ( !elem || nType === 3 || nType === 8 || nType === 2 ) {
+			return;
+		}
+
+		notxml = nType !== 1 || !jQuery.isXMLDoc( elem );
+
+		if ( notxml ) {
+			// Fix name and attach hooks
+			name = jQuery.propFix[ name ] || name;
+			hooks = jQuery.propHooks[ name ];
+		}
+
+		if ( value !== undefined ) {
+			return hooks && "set" in hooks && (ret = hooks.set( elem, value, name )) !== undefined ?
+				ret :
+				( elem[ name ] = value );
+
+		} else {
+			return hooks && "get" in hooks && (ret = hooks.get( elem, name )) !== null ?
+				ret :
+				elem[ name ];
+		}
+	},
+
+	propHooks: {
+		tabIndex: {
+			get: function( elem ) {
+				return elem.hasAttribute( "tabindex" ) || rfocusable.test( elem.nodeName ) || elem.href ?
+					elem.tabIndex :
+					-1;
+			}
+		}
+	}
+});
+
+if ( !support.optSelected ) {
+	jQuery.propHooks.selected = {
+		get: function( elem ) {
+			var parent = elem.parentNode;
+			if ( parent && parent.parentNode ) {
+				parent.parentNode.selectedIndex;
+			}
+			return null;
+		}
+	};
+}
+
+jQuery.each([
+	"tabIndex",
+	"readOnly",
+	"maxLength",
+	"cellSpacing",
+	"cellPadding",
+	"rowSpan",
+	"colSpan",
+	"useMap",
+	"frameBorder",
+	"contentEditable"
+], function() {
+	jQuery.propFix[ this.toLowerCase() ] = this;
+});
+
+
+
+
+var rclass = /[\t\r\n\f]/g;
+
+jQuery.fn.extend({
+	addClass: function( value ) {
+		var classes, elem, cur, clazz, j, finalValue,
+			proceed = typeof value === "string" && value,
+			i = 0,
+			len = this.length;
+
+		if ( jQuery.isFunction( value ) ) {
+			return this.each(function( j ) {
+				jQuery( this ).addClass( value.call( this, j, this.className ) );
+			});
+		}
+
+		if ( proceed ) {
+			// The disjunction here is for better compressibility (see removeClass)
+			classes = ( value || "" ).match( rnotwhite ) || [];
+
+			for ( ; i < len; i++ ) {
+				elem = this[ i ];
+				cur = elem.nodeType === 1 && ( elem.className ?
+					( " " + elem.className + " " ).replace( rclass, " " ) :
+					" "
+				);
+
+				if ( cur ) {
+					j = 0;
+					while ( (clazz = classes[j++]) ) {
+						if ( cur.indexOf( " " + clazz + " " ) < 0 ) {
+							cur += clazz + " ";
+						}
+					}
+
+					// only assign if different to avoid unneeded rendering.
+					finalValue = jQuery.trim( cur );
+					if ( elem.className !== finalValue ) {
+						elem.className = finalValue;
+					}
+				}
+			}
+		}
+
+		return this;
+	},
+
+	removeClass: function( value ) {
+		var classes, elem, cur, clazz, j, finalValue,
+			proceed = arguments.length === 0 || typeof value === "string" && value,
+			i = 0,
+			len = this.length;
+
+		if ( jQuery.isFunction( value ) ) {
+			return this.each(function( j ) {
+				jQuery( this ).removeClass( value.call( this, j, this.className ) );
+			});
+		}
+		if ( proceed ) {
+			classes = ( value || "" ).match( rnotwhite ) || [];
+
+			for ( ; i < len; i++ ) {
+				elem = this[ i ];
+				// This expression is here for better compressibility (see addClass)
+				cur = elem.nodeType === 1 && ( elem.className ?
+					( " " + elem.className + " " ).replace( rclass, " " ) :
+					""
+				);
+
+				if ( cur ) {
+					j = 0;
+					while ( (clazz = classes[j++]) ) {
+						// Remove *all* instances
+						while ( cur.indexOf( " " + clazz + " " ) >= 0 ) {
+							cur = cur.replace( " " + clazz + " ", " " );
+						}
+					}
+
+					// Only assign if different to avoid unneeded rendering.
+					finalValue = value ? jQuery.trim( cur ) : "";
+					if ( elem.className !== finalValue ) {
+						elem.className = finalValue;
+					}
+				}
+			}
+		}
+
+		return this;
+	},
+
+	toggleClass: function( value, stateVal ) {
+		var type = typeof value;
+
+		if ( typeof stateVal === "boolean" && type === "string" ) {
+			return stateVal ? this.addClass( value ) : this.removeClass( value );
+		}
+
+		if ( jQuery.isFunction( value ) ) {
+			return this.each(function( i ) {
+				jQuery( this ).toggleClass( value.call(this, i, this.className, stateVal), stateVal );
+			});
+		}
+
+		return this.each(function() {
+			if ( type === "string" ) {
+				// Toggle individual class names
+				var className,
+					i = 0,
+					self = jQuery( this ),
+					classNames = value.match( rnotwhite ) || [];
+
+				while ( (className = classNames[ i++ ]) ) {
+					// Check each className given, space separated list
+					if ( self.hasClass( className ) ) {
+						self.removeClass( className );
+					} else {
+						self.addClass( className );
+					}
+				}
+
+			// Toggle whole class name
+			} else if ( type === strundefined || type === "boolean" ) {
+				if ( this.className ) {
+					// store className if set
+					data_priv.set( this, "__className__", this.className );
+				}
+
+				// If the element has a class name or if we're passed `false`,
+				// then remove the whole classname (if there was one, the above saved it).
+				// Otherwise bring back whatever was previously saved (if anything),
+				// falling back to the empty string if nothing was stored.
+				this.className = this.className || value === false ? "" : data_priv.get( this, "__className__" ) || "";
+			}
+		});
+	},
+
+	hasClass: function( selector ) {
+		var className = " " + selector + " ",
+			i = 0,
+			l = this.length;
+		for ( ; i < l; i++ ) {
+			if ( this[i].nodeType === 1 && (" " + this[i].className + " ").replace(rclass, " ").indexOf( className ) >= 0 ) {
+				return true;
+			}
+		}
+
+		return false;
+	}
+});
+
+
+
+
+var rreturn = /\r/g;
+
+jQuery.fn.extend({
+	val: function( value ) {
+		var hooks, ret, isFunction,
+			elem = this[0];
+
+		if ( !arguments.length ) {
+			if ( elem ) {
+				hooks = jQuery.valHooks[ elem.type ] || jQuery.valHooks[ elem.nodeName.toLowerCase() ];
+
+				if ( hooks && "get" in hooks && (ret = hooks.get( elem, "value" )) !== undefined ) {
+					return ret;
+				}
+
+				ret = elem.value;
+
+				return typeof ret === "string" ?
+					// Handle most common string cases
+					ret.replace(rreturn, "") :
+					// Handle cases where value is null/undef or number
+					ret == null ? "" : ret;
+			}
+
+			return;
+		}
+
+		isFunction = jQuery.isFunction( value );
+
+		return this.each(function( i ) {
+			var val;
+
+			if ( this.nodeType !== 1 ) {
+				return;
+			}
+
+			if ( isFunction ) {
+				val = value.call( this, i, jQuery( this ).val() );
+			} else {
+				val = value;
+			}
+
+			// Treat null/undefined as ""; convert numbers to string
+			if ( val == null ) {
+				val = "";
+
+			} else if ( typeof val === "number" ) {
+				val += "";
+
+			} else if ( jQuery.isArray( val ) ) {
+				val = jQuery.map( val, function( value ) {
+					return value == null ? "" : value + "";
+				});
+			}
+
+			hooks = jQuery.valHooks[ this.type ] || jQuery.valHooks[ this.nodeName.toLowerCase() ];
+
+			// If set returns undefined, fall back to normal setting
+			if ( !hooks || !("set" in hooks) || hooks.set( this, val, "value" ) === undefined ) {
+				this.value = val;
+			}
+		});
+	}
+});
+
+jQuery.extend({
+	valHooks: {
+		option: {
+			get: function( elem ) {
+				var val = jQuery.find.attr( elem, "value" );
+				return val != null ?
+					val :
+					// Support: IE10-11+
+					// option.text throws exceptions (#14686, #14858)
+					jQuery.trim( jQuery.text( elem ) );
+			}
+		},
+		select: {
+			get: function( elem ) {
+				var value, option,
+					options = elem.options,
+					index = elem.selectedIndex,
+					one = elem.type === "select-one" || index < 0,
+					values = one ? null : [],
+					max = one ? index + 1 : options.length,
+					i = index < 0 ?
+						max :
+						one ? index : 0;
+
+				// Loop through all the selected options
+				for ( ; i < max; i++ ) {
+					option = options[ i ];
+
+					// IE6-9 doesn't update selected after form reset (#2551)
+					if ( ( option.selected || i === index ) &&
+							// Don't return options that are disabled or in a disabled optgroup
+							( support.optDisabled ? !option.disabled : option.getAttribute( "disabled" ) === null ) &&
+							( !option.parentNode.disabled || !jQuery.nodeName( option.parentNode, "optgroup" ) ) ) {
+
+						// Get the specific value for the option
+						value = jQuery( option ).val();
+
+						// We don't need an array for one selects
+						if ( one ) {
+							return value;
+						}
+
+						// Multi-Selects return an array
+						values.push( value );
+					}
+				}
+
+				return values;
+			},
+
+			set: function( elem, value ) {
+				var optionSet, option,
+					options = elem.options,
+					values = jQuery.makeArray( value ),
+					i = options.length;
+
+				while ( i-- ) {
+					option = options[ i ];
+					if ( (option.selected = jQuery.inArray( option.value, values ) >= 0) ) {
+						optionSet = true;
+					}
+				}
+
+				// Force browsers to behave consistently when non-matching value is set
+				if ( !optionSet ) {
+					elem.selectedIndex = -1;
+				}
+				return values;
+			}
+		}
+	}
+});
+
+// Radios and checkboxes getter/setter
+jQuery.each([ "radio", "checkbox" ], function() {
+	jQuery.valHooks[ this ] = {
+		set: function( elem, value ) {
+			if ( jQuery.isArray( value ) ) {
+				return ( elem.checked = jQuery.inArray( jQuery(elem).val(), value ) >= 0 );
+			}
+		}
+	};
+	if ( !support.checkOn ) {
+		jQuery.valHooks[ this ].get = function( elem ) {
+			return elem.getAttribute("value") === null ? "on" : elem.value;
+		};
+	}
+});
+
+
+
+
+// Return jQuery for attributes-only inclusion
+
+
+jQuery.each( ("blur focus focusin focusout load resize scroll unload click dblclick " +
+	"mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave " +
+	"change select submit keydown keypress keyup error contextmenu").split(" "), function( i, name ) {
+
+	// Handle event binding
+	jQuery.fn[ name ] = function( data, fn ) {
+		return arguments.length > 0 ?
+			this.on( name, null, data, fn ) :
+			this.trigger( name );
+	};
+});
+
+jQuery.fn.extend({
+	hover: function( fnOver, fnOut ) {
+		return this.mouseenter( fnOver ).mouseleave( fnOut || fnOver );
+	},
+
+	bind: function( types, data, fn ) {
+		return this.on( types, null, data, fn );
+	},
+	unbind: function( types, fn ) {
+		return this.off( types, null, fn );
+	},
+
+	delegate: function( selector, types, data, fn ) {
+		return this.on( types, selector, data, fn );
+	},
+	undelegate: function( selector, types, fn ) {
+		// ( namespace ) or ( selector, types [, fn] )
+		return arguments.length === 1 ? this.off( selector, "**" ) : this.off( types, selector || "**", fn );
+	}
+});
+
+
+var nonce = jQuery.now();
+
+var rquery = (/\?/);
+
+
+
+// Support: Android 2.3
+// Workaround failure to string-cast null input
+jQuery.parseJSON = function( data ) {
+	return JSON.parse( data + "" );
+};
+
+
+// Cross-browser xml parsing
+jQuery.parseXML = function( data ) {
+	var xml, tmp;
+	if ( !data || typeof data !== "string" ) {
+		return null;
+	}
+
+	// Support: IE9
+	try {
+		tmp = new DOMParser();
+		xml = tmp.parseFromString( data, "text/xml" );
+	} catch ( e ) {
+		xml = undefined;
+	}
+
+	if ( !xml || xml.getElementsByTagName( "parsererror" ).length ) {
+		jQuery.error( "Invalid XML: " + data );
+	}
+	return xml;
+};
+
+
+var
+	rhash = /#.*$/,
+	rts = /([?&])_=[^&]*/,
+	rheaders = /^(.*?):[ \t]*([^\r\n]*)$/mg,
+	// #7653, #8125, #8152: local protocol detection
+	rlocalProtocol = /^(?:about|app|app-storage|.+-extension|file|res|widget):$/,
+	rnoContent = /^(?:GET|HEAD)$/,
+	rprotocol = /^\/\//,
+	rurl = /^([\w.+-]+:)(?:\/\/(?:[^\/?#]*@|)([^\/?#:]*)(?::(\d+)|)|)/,
+
+	/* Prefilters
+	 * 1) They are useful to introduce custom dataTypes (see ajax/jsonp.js for an example)
+	 * 2) These are called:
+	 *    - BEFORE asking for a transport
+	 *    - AFTER param serialization (s.data is a string if s.processData is true)
+	 * 3) key is the dataType
+	 * 4) the catchall symbol "*" can be used
+	 * 5) execution will start with transport dataType and THEN continue down to "*" if needed
+	 */
+	prefilters = {},
+
+	/* Transports bindings
+	 * 1) key is the dataType
+	 * 2) the catchall symbol "*" can be used
+	 * 3) selection will start with transport dataType and THEN go to "*" if needed
+	 */
+	transports = {},
+
+	// Avoid comment-prolog char sequence (#10098); must appease lint and evade compression
+	allTypes = "*/".concat( "*" ),
+
+	// Document location
+	ajaxLocation = window.location.href,
+
+	// Segment location into parts
+	ajaxLocParts = rurl.exec( ajaxLocation.toLowerCase() ) || [];
+
+// Base "constructor" for jQuery.ajaxPrefilter and jQuery.ajaxTransport
+function addToPrefiltersOrTransports( structure ) {
+
+	// dataTypeExpression is optional and defaults to "*"
+	return function( dataTypeExpression, func ) {
+
+		if ( typeof dataTypeExpression !== "string" ) {
+			func = dataTypeExpression;
+			dataTypeExpression = "*";
+		}
+
+		var dataType,
+			i = 0,
+			dataTypes = dataTypeExpression.toLowerCase().match( rnotwhite ) || [];
+
+		if ( jQuery.isFunction( func ) ) {
+			// For each dataType in the dataTypeExpression
+			while ( (dataType = dataTypes[i++]) ) {
+				// Prepend if requested
+				if ( dataType[0] === "+" ) {
+					dataType = dataType.slice( 1 ) || "*";
+					(structure[ dataType ] = structure[ dataType ] || []).unshift( func );
+
+				// Otherwise append
+				} else {
+					(structure[ dataType ] = structure[ dataType ] || []).push( func );
+				}
+			}
+		}
+	};
+}
+
+// Base inspection function for prefilters and transports
+function inspectPrefiltersOrTransports( structure, options, originalOptions, jqXHR ) {
+
+	var inspected = {},
+		seekingTransport = ( structure === transports );
+
+	function inspect( dataType ) {
+		var selected;
+		inspected[ dataType ] = true;
+		jQuery.each( structure[ dataType ] || [], function( _, prefilterOrFactory ) {
+			var dataTypeOrTransport = prefilterOrFactory( options, originalOptions, jqXHR );
+			if ( typeof dataTypeOrTransport === "string" && !seekingTransport && !inspected[ dataTypeOrTransport ] ) {
+				options.dataTypes.unshift( dataTypeOrTransport );
+				inspect( dataTypeOrTransport );
+				return false;
+			} else if ( seekingTransport ) {
+				return !( selected = dataTypeOrTransport );
+			}
+		});
+		return selected;
+	}
+
+	return inspect( options.dataTypes[ 0 ] ) || !inspected[ "*" ] && inspect( "*" );
+}
+
+// A special extend for ajax options
+// that takes "flat" options (not to be deep extended)
+// Fixes #9887
+function ajaxExtend( target, src ) {
+	var key, deep,
+		flatOptions = jQuery.ajaxSettings.flatOptions || {};
+
+	for ( key in src ) {
+		if ( src[ key ] !== undefined ) {
+			( flatOptions[ key ] ? target : ( deep || (deep = {}) ) )[ key ] = src[ key ];
+		}
+	}
+	if ( deep ) {
+		jQuery.extend( true, target, deep );
+	}
+
+	return target;
+}
+
+/* Handles responses to an ajax request:
+ * - finds the right dataType (mediates between content-type and expected dataType)
+ * - returns the corresponding response
+ */
+function ajaxHandleResponses( s, jqXHR, responses ) {
+
+	var ct, type, finalDataType, firstDataType,
+		contents = s.contents,
+		dataTypes = s.dataTypes;
+
+	// Remove auto dataType and get content-type in the process
+	while ( dataTypes[ 0 ] === "*" ) {
+		dataTypes.shift();
+		if ( ct === undefined ) {
+			ct = s.mimeType || jqXHR.getResponseHeader("Content-Type");
+		}
+	}
+
+	// Check if we're dealing with a known content-type
+	if ( ct ) {
+		for ( type in contents ) {
+			if ( contents[ type ] && contents[ type ].test( ct ) ) {
+				dataTypes.unshift( type );
+				break;
+			}
+		}
+	}
+
+	// Check to see if we have a response for the expected dataType
+	if ( dataTypes[ 0 ] in responses ) {
+		finalDataType = dataTypes[ 0 ];
+	} else {
+		// Try convertible dataTypes
+		for ( type in responses ) {
+			if ( !dataTypes[ 0 ] || s.converters[ type + " " + dataTypes[0] ] ) {
+				finalDataType = type;
+				break;
+			}
+			if ( !firstDataType ) {
+				firstDataType = type;
+			}
+		}
+		// Or just use first one
+		finalDataType = finalDataType || firstDataType;
+	}
+
+	// If we found a dataType
+	// We add the dataType to the list if needed
+	// and return the corresponding response
+	if ( finalDataType ) {
+		if ( finalDataType !== dataTypes[ 0 ] ) {
+			dataTypes.unshift( finalDataType );
+		}
+		return responses[ finalDataType ];
+	}
+}
+
+/* Chain conversions given the request and the original response
+ * Also sets the responseXXX fields on the jqXHR instance
+ */
+function ajaxConvert( s, response, jqXHR, isSuccess ) {
+	var conv2, current, conv, tmp, prev,
+		converters = {},
+		// Work with a copy of dataTypes in case we need to modify it for conversion
+		dataTypes = s.dataTypes.slice();
+
+	// Create converters map with lowercased keys
+	if ( dataTypes[ 1 ] ) {
+		for ( conv in s.converters ) {
+			converters[ conv.toLowerCase() ] = s.converters[ conv ];
+		}
+	}
+
+	current = dataTypes.shift();
+
+	// Convert to each sequential dataType
+	while ( current ) {
+
+		if ( s.responseFields[ current ] ) {
+			jqXHR[ s.responseFields[ current ] ] = response;
+		}
+
+		// Apply the dataFilter if provided
+		if ( !prev && isSuccess && s.dataFilter ) {
+			response = s.dataFilter( response, s.dataType );
+		}
+
+		prev = current;
+		current = dataTypes.shift();
+
+		if ( current ) {
+
+		// There's only work to do if current dataType is non-auto
+			if ( current === "*" ) {
+
+				current = prev;
+
+			// Convert response if prev dataType is non-auto and differs from current
+			} else if ( prev !== "*" && prev !== current ) {
+
+				// Seek a direct converter
+				conv = converters[ prev + " " + current ] || converters[ "* " + current ];
+
+				// If none found, seek a pair
+				if ( !conv ) {
+					for ( conv2 in converters ) {
+
+						// If conv2 outputs current
+						tmp = conv2.split( " " );
+						if ( tmp[ 1 ] === current ) {
+
+							// If prev can be converted to accepted input
+							conv = converters[ prev + " " + tmp[ 0 ] ] ||
+								converters[ "* " + tmp[ 0 ] ];
+							if ( conv ) {
+								// Condense equivalence converters
+								if ( conv === true ) {
+									conv = converters[ conv2 ];
+
+								// Otherwise, insert the intermediate dataType
+								} else if ( converters[ conv2 ] !== true ) {
+									current = tmp[ 0 ];
+									dataTypes.unshift( tmp[ 1 ] );
+								}
+								break;
+							}
+						}
+					}
+				}
+
+				// Apply converter (if not an equivalence)
+				if ( conv !== true ) {
+
+					// Unless errors are allowed to bubble, catch and return them
+					if ( conv && s[ "throws" ] ) {
+						response = conv( response );
+					} else {
+						try {
+							response = conv( response );
+						} catch ( e ) {
+							return { state: "parsererror", error: conv ? e : "No conversion from " + prev + " to " + current };
+						}
+					}
+				}
+			}
+		}
+	}
+
+	return { state: "success", data: response };
+}
+
+jQuery.extend({
+
+	// Counter for holding the number of active queries
+	active: 0,
+
+	// Last-Modified header cache for next request
+	lastModified: {},
+	etag: {},
+
+	ajaxSettings: {
+		url: ajaxLocation,
+		type: "GET",
+		isLocal: rlocalProtocol.test( ajaxLocParts[ 1 ] ),
+		global: true,
+		processData: true,
+		async: true,
+		contentType: "application/x-www-form-urlencoded; charset=UTF-8",
+		/*
+		timeout: 0,
+		data: null,
+		dataType: null,
+		username: null,
+		password: null,
+		cache: null,
+		throws: false,
+		traditional: false,
+		headers: {},
+		*/
+
+		accepts: {
+			"*": allTypes,
+			text: "text/plain",
+			html: "text/html",
+			xml: "application/xml, text/xml",
+			json: "application/json, text/javascript"
+		},
+
+		contents: {
+			xml: /xml/,
+			html: /html/,
+			json: /json/
+		},
+
+		responseFields: {
+			xml: "responseXML",
+			text: "responseText",
+			json: "responseJSON"
+		},
+
+		// Data converters
+		// Keys separate source (or catchall "*") and destination types with a single space
+		converters: {
+
+			// Convert anything to text
+			"* text": String,
+
+			// Text to html (true = no transformation)
+			"text html": true,
+
+			// Evaluate text as a json expression
+			"text json": jQuery.parseJSON,
+
+			// Parse text as xml
+			"text xml": jQuery.parseXML
+		},
+
+		// For options that shouldn't be deep extended:
+		// you can add your own custom options here if
+		// and when you create one that shouldn't be
+		// deep extended (see ajaxExtend)
+		flatOptions: {
+			url: true,
+			context: true
+		}
+	},
+
+	// Creates a full fledged settings object into target
+	// with both ajaxSettings and settings fields.
+	// If target is omitted, writes into ajaxSettings.
+	ajaxSetup: function( target, settings ) {
+		return settings ?
+
+			// Building a settings object
+			ajaxExtend( ajaxExtend( target, jQuery.ajaxSettings ), settings ) :
+
+			// Extending ajaxSettings
+			ajaxExtend( jQuery.ajaxSettings, target );
+	},
+
+	ajaxPrefilter: addToPrefiltersOrTransports( prefilters ),
+	ajaxTransport: addToPrefiltersOrTransports( transports ),
+
+	// Main method
+	ajax: function( url, options ) {
+
+		// If url is an object, simulate pre-1.5 signature
+		if ( typeof url === "object" ) {
+			options = url;
+			url = undefined;
+		}
+
+		// Force options to be an object
+		options = options || {};
+
+		var transport,
+			// URL without anti-cache param
+			cacheURL,
+			// Response headers
+			responseHeadersString,
+			responseHeaders,
+			// timeout handle
+			timeoutTimer,
+			// Cross-domain detection vars
+			parts,
+			// To know if global events are to be dispatched
+			fireGlobals,
+			// Loop variable
+			i,
+			// Create the final options object
+			s = jQuery.ajaxSetup( {}, options ),
+			// Callbacks context
+			callbackContext = s.context || s,
+			// Context for global events is callbackContext if it is a DOM node or jQuery collection
+			globalEventContext = s.context && ( callbackContext.nodeType || callbackContext.jquery ) ?
+				jQuery( callbackContext ) :
+				jQuery.event,
+			// Deferreds
+			deferred = jQuery.Deferred(),
+			completeDeferred = jQuery.Callbacks("once memory"),
+			// Status-dependent callbacks
+			statusCode = s.statusCode || {},
+			// Headers (they are sent all at once)
+			requestHeaders = {},
+			requestHeadersNames = {},
+			// The jqXHR state
+			state = 0,
+			// Default abort message
+			strAbort = "canceled",
+			// Fake xhr
+			jqXHR = {
+				readyState: 0,
+
+				// Builds headers hashtable if needed
+				getResponseHeader: function( key ) {
+					var match;
+					if ( state === 2 ) {
+						if ( !responseHeaders ) {
+							responseHeaders = {};
+							while ( (match = rheaders.exec( responseHeadersString )) ) {
+								responseHeaders[ match[1].toLowerCase() ] = match[ 2 ];
+							}
+						}
+						match = responseHeaders[ key.toLowerCase() ];
+					}
+					return match == null ? null : match;
+				},
+
+				// Raw string
+				getAllResponseHeaders: function() {
+					return state === 2 ? responseHeadersString : null;
+				},
+
+				// Caches the header
+				setRequestHeader: function( name, value ) {
+					var lname = name.toLowerCase();
+					if ( !state ) {
+						name = requestHeadersNames[ lname ] = requestHeadersNames[ lname ] || name;
+						requestHeaders[ name ] = value;
+					}
+					return this;
+				},
+
+				// Overrides response content-type header
+				overrideMimeType: function( type ) {
+					if ( !state ) {
+						s.mimeType = type;
+					}
+					return this;
+				},
+
+				// Status-dependent callbacks
+				statusCode: function( map ) {
+					var code;
+					if ( map ) {
+						if ( state < 2 ) {
+							for ( code in map ) {
+								// Lazy-add the new callback in a way that preserves old ones
+								statusCode[ code ] = [ statusCode[ code ], map[ code ] ];
+							}
+						} else {
+							// Execute the appropriate callbacks
+							jqXHR.always( map[ jqXHR.status ] );
+						}
+					}
+					return this;
+				},
+
+				// Cancel the request
+				abort: function( statusText ) {
+					var finalText = statusText || strAbort;
+					if ( transport ) {
+						transport.abort( finalText );
+					}
+					done( 0, finalText );
+					return this;
+				}
+			};
+
+		// Attach deferreds
+		deferred.promise( jqXHR ).complete = completeDeferred.add;
+		jqXHR.success = jqXHR.done;
+		jqXHR.error = jqXHR.fail;
+
+		// Remove hash character (#7531: and string promotion)
+		// Add protocol if not provided (prefilters might expect it)
+		// Handle falsy url in the settings object (#10093: consistency with old signature)
+		// We also use the url parameter if available
+		s.url = ( ( url || s.url || ajaxLocation ) + "" ).replace( rhash, "" )
+			.replace( rprotocol, ajaxLocParts[ 1 ] + "//" );
+
+		// Alias method option to type as per ticket #12004
+		s.type = options.method || options.type || s.method || s.type;
+
+		// Extract dataTypes list
+		s.dataTypes = jQuery.trim( s.dataType || "*" ).toLowerCase().match( rnotwhite ) || [ "" ];
+
+		// A cross-domain request is in order when we have a protocol:host:port mismatch
+		if ( s.crossDomain == null ) {
+			parts = rurl.exec( s.url.toLowerCase() );
+			s.crossDomain = !!( parts &&
+				( parts[ 1 ] !== ajaxLocParts[ 1 ] || parts[ 2 ] !== ajaxLocParts[ 2 ] ||
+					( parts[ 3 ] || ( parts[ 1 ] === "http:" ? "80" : "443" ) ) !==
+						( ajaxLocParts[ 3 ] || ( ajaxLocParts[ 1 ] === "http:" ? "80" : "443" ) ) )
+			);
+		}
+
+		// Convert data if not already a string
+		if ( s.data && s.processData && typeof s.data !== "string" ) {
+			s.data = jQuery.param( s.data, s.traditional );
+		}
+
+		// Apply prefilters
+		inspectPrefiltersOrTransports( prefilters, s, options, jqXHR );
+
+		// If request was aborted inside a prefilter, stop there
+		if ( state === 2 ) {
+			return jqXHR;
+		}
+
+		// We can fire global events as of now if asked to
+		// Don't fire events if jQuery.event is undefined in an AMD-usage scenario (#15118)
+		fireGlobals = jQuery.event && s.global;
+
+		// Watch for a new set of requests
+		if ( fireGlobals && jQuery.active++ === 0 ) {
+			jQuery.event.trigger("ajaxStart");
+		}
+
+		// Uppercase the type
+		s.type = s.type.toUpperCase();
+
+		// Determine if request has content
+		s.hasContent = !rnoContent.test( s.type );
+
+		// Save the URL in case we're toying with the If-Modified-Since
+		// and/or If-None-Match header later on
+		cacheURL = s.url;
+
+		// More options handling for requests with no content
+		if ( !s.hasContent ) {
+
+			// If data is available, append data to url
+			if ( s.data ) {
+				cacheURL = ( s.url += ( rquery.test( cacheURL ) ? "&" : "?" ) + s.data );
+				// #9682: remove data so that it's not used in an eventual retry
+				delete s.data;
+			}
+
+			// Add anti-cache in url if needed
+			if ( s.cache === false ) {
+				s.url = rts.test( cacheURL ) ?
+
+					// If there is already a '_' parameter, set its value
+					cacheURL.replace( rts, "$1_=" + nonce++ ) :
+
+					// Otherwise add one to the end
+					cacheURL + ( rquery.test( cacheURL ) ? "&" : "?" ) + "_=" + nonce++;
+			}
+		}
+
+		// Set the If-Modified-Since and/or If-None-Match header, if in ifModified mode.
+		if ( s.ifModified ) {
+			if ( jQuery.lastModified[ cacheURL ] ) {
+				jqXHR.setRequestHeader( "If-Modified-Since", jQuery.lastModified[ cacheURL ] );
+			}
+			if ( jQuery.etag[ cacheURL ] ) {
+				jqXHR.setRequestHeader( "If-None-Match", jQuery.etag[ cacheURL ] );
+			}
+		}
+
+		// Set the correct header, if data is being sent
+		if ( s.data && s.hasContent && s.contentType !== false || options.contentType ) {
+			jqXHR.setRequestHeader( "Content-Type", s.contentType );
+		}
+
+		// Set the Accepts header for the server, depending on the dataType
+		jqXHR.setRequestHeader(
+			"Accept",
+			s.dataTypes[ 0 ] && s.accepts[ s.dataTypes[0] ] ?
+				s.accepts[ s.dataTypes[0] ] + ( s.dataTypes[ 0 ] !== "*" ? ", " + allTypes + "; q=0.01" : "" ) :
+				s.accepts[ "*" ]
+		);
+
+		// Check for headers option
+		for ( i in s.headers ) {
+			jqXHR.setRequestHeader( i, s.headers[ i ] );
+		}
+
+		// Allow custom headers/mimetypes and early abort
+		if ( s.beforeSend && ( s.beforeSend.call( callbackContext, jqXHR, s ) === false || state === 2 ) ) {
+			// Abort if not done already and return
+			return jqXHR.abort();
+		}
+
+		// Aborting is no longer a cancellation
+		strAbort = "abort";
+
+		// Install callbacks on deferreds
+		for ( i in { success: 1, error: 1, complete: 1 } ) {
+			jqXHR[ i ]( s[ i ] );
+		}
+
+		// Get transport
+		transport = inspectPrefiltersOrTransports( transports, s, options, jqXHR );
+
+		// If no transport, we auto-abort
+		if ( !transport ) {
+			done( -1, "No Transport" );
+		} else {
+			jqXHR.readyState = 1;
+
+			// Send global event
+			if ( fireGlobals ) {
+				globalEventContext.trigger( "ajaxSend", [ jqXHR, s ] );
+			}
+			// Timeout
+			if ( s.async && s.timeout > 0 ) {
+				timeoutTimer = setTimeout(function() {
+					jqXHR.abort("timeout");
+				}, s.timeout );
+			}
+
+			try {
+				state = 1;
+				transport.send( requestHeaders, done );
+			} catch ( e ) {
+				// Propagate exception as error if not done
+				if ( state < 2 ) {
+					done( -1, e );
+				// Simply rethrow otherwise
+				} else {
+					throw e;
+				}
+			}
+		}
+
+		// Callback for when everything is done
+		function done( status, nativeStatusText, responses, headers ) {
+			var isSuccess, success, error, response, modified,
+				statusText = nativeStatusText;
+
+			// Called once
+			if ( state === 2 ) {
+				return;
+			}
+
+			// State is "done" now
+			state = 2;
+
+			// Clear timeout if it exists
+			if ( timeoutTimer ) {
+				clearTimeout( timeoutTimer );
+			}
+
+			// Dereference transport for early garbage collection
+			// (no matter how long the jqXHR object will be used)
+			transport = undefined;
+
+			// Cache response headers
+			responseHeadersString = headers || "";
+
+			// Set readyState
+			jqXHR.readyState = status > 0 ? 4 : 0;
+
+			// Determine if successful
+			isSuccess = status >= 200 && status < 300 || status === 304;
+
+			// Get response data
+			if ( responses ) {
+				response = ajaxHandleResponses( s, jqXHR, responses );
+			}
+
+			// Convert no matter what (that way responseXXX fields are always set)
+			response = ajaxConvert( s, response, jqXHR, isSuccess );
+
+			// If successful, handle type chaining
+			if ( isSuccess ) {
+
+				// Set the If-Modified-Since and/or If-None-Match header, if in ifModified mode.
+				if ( s.ifModified ) {
+					modified = jqXHR.getResponseHeader("Last-Modified");
+					if ( modified ) {
+						jQuery.lastModified[ cacheURL ] = modified;
+					}
+					modified = jqXHR.getResponseHeader("etag");
+					if ( modified ) {
+						jQuery.etag[ cacheURL ] = modified;
+					}
+				}
+
+				// if no content
+				if ( status === 204 || s.type === "HEAD" ) {
+					statusText = "nocontent";
+
+				// if not modified
+				} else if ( status === 304 ) {
+					statusText = "notmodified";
+
+				// If we have data, let's convert it
+				} else {
+					statusText = response.state;
+					success = response.data;
+					error = response.error;
+					isSuccess = !error;
+				}
+			} else {
+				// Extract error from statusText and normalize for non-aborts
+				error = statusText;
+				if ( status || !statusText ) {
+					statusText = "error";
+					if ( status < 0 ) {
+						status = 0;
+					}
+				}
+			}
+
+			// Set data for the fake xhr object
+			jqXHR.status = status;
+			jqXHR.statusText = ( nativeStatusText || statusText ) + "";
+
+			// Success/Error
+			if ( isSuccess ) {
+				deferred.resolveWith( callbackContext, [ success, statusText, jqXHR ] );
+			} else {
+				deferred.rejectWith( callbackContext, [ jqXHR, statusText, error ] );
+			}
+
+			// Status-dependent callbacks
+			jqXHR.statusCode( statusCode );
+			statusCode = undefined;
+
+			if ( fireGlobals ) {
+				globalEventContext.trigger( isSuccess ? "ajaxSuccess" : "ajaxError",
+					[ jqXHR, s, isSuccess ? success : error ] );
+			}
+
+			// Complete
+			completeDeferred.fireWith( callbackContext, [ jqXHR, statusText ] );
+
+			if ( fireGlobals ) {
+				globalEventContext.trigger( "ajaxComplete", [ jqXHR, s ] );
+				// Handle the global AJAX counter
+				if ( !( --jQuery.active ) ) {
+					jQuery.event.trigger("ajaxStop");
+				}
+			}
+		}
+
+		return jqXHR;
+	},
+
+	getJSON: function( url, data, callback ) {
+		return jQuery.get( url, data, callback, "json" );
+	},
+
+	getScript: function( url, callback ) {
+		return jQuery.get( url, undefined, callback, "script" );
+	}
+});
+
+jQuery.each( [ "get", "post" ], function( i, method ) {
+	jQuery[ method ] = function( url, data, callback, type ) {
+		// Shift arguments if data argument was omitted
+		if ( jQuery.isFunction( data ) ) {
+			type = type || callback;
+			callback = data;
+			data = undefined;
+		}
+
+		return jQuery.ajax({
+			url: url,
+			type: method,
+			dataType: type,
+			data: data,
+			success: callback
+		});
+	};
+});
+
+
+jQuery._evalUrl = function( url ) {
+	return jQuery.ajax({
+		url: url,
+		type: "GET",
+		dataType: "script",
+		async: false,
+		global: false,
+		"throws": true
+	});
+};
+
+
+jQuery.fn.extend({
+	wrapAll: function( html ) {
+		var wrap;
+
+		if ( jQuery.isFunction( html ) ) {
+			return this.each(function( i ) {
+				jQuery( this ).wrapAll( html.call(this, i) );
+			});
+		}
+
+		if ( this[ 0 ] ) {
+
+			// The elements to wrap the target around
+			wrap = jQuery( html, this[ 0 ].ownerDocument ).eq( 0 ).clone( true );
+
+			if ( this[ 0 ].parentNode ) {
+				wrap.insertBefore( this[ 0 ] );
+			}
+
+			wrap.map(function() {
+				var elem = this;
+
+				while ( elem.firstElementChild ) {
+					elem = elem.firstElementChild;
+				}
+
+				return elem;
+			}).append( this );
+		}
+
+		return this;
+	},
+
+	wrapInner: function( html ) {
+		if ( jQuery.isFunction( html ) ) {
+			return this.each(function( i ) {
+				jQuery( this ).wrapInner( html.call(this, i) );
+			});
+		}
+
+		return this.each(function() {
+			var self = jQuery( this ),
+				contents = self.contents();
+
+			if ( contents.length ) {
+				contents.wrapAll( html );
+
+			} else {
+				self.append( html );
+			}
+		});
+	},
+
+	wrap: function( html ) {
+		var isFunction = jQuery.isFunction( html );
+
+		return this.each(function( i ) {
+			jQuery( this ).wrapAll( isFunction ? html.call(this, i) : html );
+		});
+	},
+
+	unwrap: function() {
+		return this.parent().each(function() {
+			if ( !jQuery.nodeName( this, "body" ) ) {
+				jQuery( this ).replaceWith( this.childNodes );
+			}
+		}).end();
+	}
+});
+
+
+jQuery.expr.filters.hidden = function( elem ) {
+	// Support: Opera <= 12.12
+	// Opera reports offsetWidths and offsetHeights less than zero on some elements
+	return elem.offsetWidth <= 0 && elem.offsetHeight <= 0;
+};
+jQuery.expr.filters.visible = function( elem ) {
+	return !jQuery.expr.filters.hidden( elem );
+};
+
+
+
+
+var r20 = /%20/g,
+	rbracket = /\[\]$/,
+	rCRLF = /\r?\n/g,
+	rsubmitterTypes = /^(?:submit|button|image|reset|file)$/i,
+	rsubmittable = /^(?:input|select|textarea|keygen)/i;
+
+function buildParams( prefix, obj, traditional, add ) {
+	var name;
+
+	if ( jQuery.isArray( obj ) ) {
+		// Serialize array item.
+		jQuery.each( obj, function( i, v ) {
+			if ( traditional || rbracket.test( prefix ) ) {
+				// Treat each array item as a scalar.
+				add( prefix, v );
+
+			} else {
+				// Item is non-scalar (array or object), encode its numeric index.
+				buildParams( prefix + "[" + ( typeof v === "object" ? i : "" ) + "]", v, traditional, add );
+			}
+		});
+
+	} else if ( !traditional && jQuery.type( obj ) === "object" ) {
+		// Serialize object item.
+		for ( name in obj ) {
+			buildParams( prefix + "[" + name + "]", obj[ name ], traditional, add );
+		}
+
+	} else {
+		// Serialize scalar item.
+		add( prefix, obj );
+	}
+}
+
+// Serialize an array of form elements or a set of
+// key/values into a query string
+jQuery.param = function( a, traditional ) {
+	var prefix,
+		s = [],
+		add = function( key, value ) {
+			// If value is a function, invoke it and return its value
+			value = jQuery.isFunction( value ) ? value() : ( value == null ? "" : value );
+			s[ s.length ] = encodeURIComponent( key ) + "=" + encodeURIComponent( value );
+		};
+
+	// Set traditional to true for jQuery <= 1.3.2 behavior.
+	if ( traditional === undefined ) {
+		traditional = jQuery.ajaxSettings && jQuery.ajaxSettings.traditional;
+	}
+
+	// If an array was passed in, assume that it is an array of form elements.
+	if ( jQuery.isArray( a ) || ( a.jquery && !jQuery.isPlainObject( a ) ) ) {
+		// Serialize the form elements
+		jQuery.each( a, function() {
+			add( this.name, this.value );
+		});
+
+	} else {
+		// If traditional, encode the "old" way (the way 1.3.2 or older
+		// did it), otherwise encode params recursively.
+		for ( prefix in a ) {
+			buildParams( prefix, a[ prefix ], traditional, add );
+		}
+	}
+
+	// Return the resulting serialization
+	return s.join( "&" ).replace( r20, "+" );
+};
+
+jQuery.fn.extend({
+	serialize: function() {
+		return jQuery.param( this.serializeArray() );
+	},
+	serializeArray: function() {
+		return this.map(function() {
+			// Can add propHook for "elements" to filter or add form elements
+			var elements = jQuery.prop( this, "elements" );
+			return elements ? jQuery.makeArray( elements ) : this;
+		})
+		.filter(function() {
+			var type = this.type;
+
+			// Use .is( ":disabled" ) so that fieldset[disabled] works
+			return this.name && !jQuery( this ).is( ":disabled" ) &&
+				rsubmittable.test( this.nodeName ) && !rsubmitterTypes.test( type ) &&
+				( this.checked || !rcheckableType.test( type ) );
+		})
+		.map(function( i, elem ) {
+			var val = jQuery( this ).val();
+
+			return val == null ?
+				null :
+				jQuery.isArray( val ) ?
+					jQuery.map( val, function( val ) {
+						return { name: elem.name, value: val.replace( rCRLF, "\r\n" ) };
+					}) :
+					{ name: elem.name, value: val.replace( rCRLF, "\r\n" ) };
+		}).get();
+	}
+});
+
+
+jQuery.ajaxSettings.xhr = function() {
+	try {
+		return new XMLHttpRequest();
+	} catch( e ) {}
+};
+
+var xhrId = 0,
+	xhrCallbacks = {},
+	xhrSuccessStatus = {
+		// file protocol always yields status code 0, assume 200
+		0: 200,
+		// Support: IE9
+		// #1450: sometimes IE returns 1223 when it should be 204
+		1223: 204
+	},
+	xhrSupported = jQuery.ajaxSettings.xhr();
+
+// Support: IE9
+// Open requests must be manually aborted on unload (#5280)
+// See https://support.microsoft.com/kb/2856746 for more info
+if ( window.attachEvent ) {
+	window.attachEvent( "onunload", function() {
+		for ( var key in xhrCallbacks ) {
+			xhrCallbacks[ key ]();
+		}
+	});
+}
+
+support.cors = !!xhrSupported && ( "withCredentials" in xhrSupported );
+support.ajax = xhrSupported = !!xhrSupported;
+
+jQuery.ajaxTransport(function( options ) {
+	var callback;
+
+	// Cross domain only allowed if supported through XMLHttpRequest
+	if ( support.cors || xhrSupported && !options.crossDomain ) {
+		return {
+			send: function( headers, complete ) {
+				var i,
+					xhr = options.xhr(),
+					id = ++xhrId;
+
+				xhr.open( options.type, options.url, options.async, options.username, options.password );
+
+				// Apply custom fields if provided
+				if ( options.xhrFields ) {
+					for ( i in options.xhrFields ) {
+						xhr[ i ] = options.xhrFields[ i ];
+					}
+				}
+
+				// Override mime type if needed
+				if ( options.mimeType && xhr.overrideMimeType ) {
+					xhr.overrideMimeType( options.mimeType );
+				}
+
+				// X-Requested-With header
+				// For cross-domain requests, seeing as conditions for a preflight are
+				// akin to a jigsaw puzzle, we simply never set it to be sure.
+				// (it can always be set on a per-request basis or even using ajaxSetup)
+				// For same-domain requests, won't change header if already provided.
+				if ( !options.crossDomain && !headers["X-Requested-With"] ) {
+					headers["X-Requested-With"] = "XMLHttpRequest";
+				}
+
+				// Set headers
+				for ( i in headers ) {
+					xhr.setRequestHeader( i, headers[ i ] );
+				}
+
+				// Callback
+				callback = function( type ) {
+					return function() {
+						if ( callback ) {
+							delete xhrCallbacks[ id ];
+							callback = xhr.onload = xhr.onerror = null;
+
+							if ( type === "abort" ) {
+								xhr.abort();
+							} else if ( type === "error" ) {
+								complete(
+									// file: protocol always yields status 0; see #8605, #14207
+									xhr.status,
+									xhr.statusText
+								);
+							} else {
+								complete(
+									xhrSuccessStatus[ xhr.status ] || xhr.status,
+									xhr.statusText,
+									// Support: IE9
+									// Accessing binary-data responseText throws an exception
+									// (#11426)
+									typeof xhr.responseText === "string" ? {
+										text: xhr.responseText
+									} : undefined,
+									xhr.getAllResponseHeaders()
+								);
+							}
+						}
+					};
+				};
+
+				// Listen to events
+				xhr.onload = callback();
+				xhr.onerror = callback("error");
+
+				// Create the abort callback
+				callback = xhrCallbacks[ id ] = callback("abort");
+
+				try {
+					// Do send the request (this may raise an exception)
+					xhr.send( options.hasContent && options.data || null );
+				} catch ( e ) {
+					// #14683: Only rethrow if this hasn't been notified as an error yet
+					if ( callback ) {
+						throw e;
+					}
+				}
+			},
+
+			abort: function() {
+				if ( callback ) {
+					callback();
+				}
+			}
+		};
+	}
+});
+
+
+
+
+// Install script dataType
+jQuery.ajaxSetup({
+	accepts: {
+		script: "text/javascript, application/javascript, application/ecmascript, application/x-ecmascript"
+	},
+	contents: {
+		script: /(?:java|ecma)script/
+	},
+	converters: {
+		"text script": function( text ) {
+			jQuery.globalEval( text );
+			return text;
+		}
+	}
+});
+
+// Handle cache's special case and crossDomain
+jQuery.ajaxPrefilter( "script", function( s ) {
+	if ( s.cache === undefined ) {
+		s.cache = false;
+	}
+	if ( s.crossDomain ) {
+		s.type = "GET";
+	}
+});
+
+// Bind script tag hack transport
+jQuery.ajaxTransport( "script", function( s ) {
+	// This transport only deals with cross domain requests
+	if ( s.crossDomain ) {
+		var script, callback;
+		return {
+			send: function( _, complete ) {
+				script = jQuery("<script>").prop({
+					async: true,
+					charset: s.scriptCharset,
+					src: s.url
+				}).on(
+					"load error",
+					callback = function( evt ) {
+						script.remove();
+						callback = null;
+						if ( evt ) {
+							complete( evt.type === "error" ? 404 : 200, evt.type );
+						}
+					}
+				);
+				document.head.appendChild( script[ 0 ] );
+			},
+			abort: function() {
+				if ( callback ) {
+					callback();
+				}
+			}
+		};
+	}
+});
+
+
+
+
+var oldCallbacks = [],
+	rjsonp = /(=)\?(?=&|$)|\?\?/;
+
+// Default jsonp settings
+jQuery.ajaxSetup({
+	jsonp: "callback",
+	jsonpCallback: function() {
+		var callback = oldCallbacks.pop() || ( jQuery.expando + "_" + ( nonce++ ) );
+		this[ callback ] = true;
+		return callback;
+	}
+});
+
+// Detect, normalize options and install callbacks for jsonp requests
+jQuery.ajaxPrefilter( "json jsonp", function( s, originalSettings, jqXHR ) {
+
+	var callbackName, overwritten, responseContainer,
+		jsonProp = s.jsonp !== false && ( rjsonp.test( s.url ) ?
+			"url" :
+			typeof s.data === "string" && !( s.contentType || "" ).indexOf("application/x-www-form-urlencoded") && rjsonp.test( s.data ) && "data"
+		);
+
+	// Handle iff the expected data type is "jsonp" or we have a parameter to set
+	if ( jsonProp || s.dataTypes[ 0 ] === "jsonp" ) {
+
+		// Get callback name, remembering preexisting value associated with it
+		callbackName = s.jsonpCallback = jQuery.isFunction( s.jsonpCallback ) ?
+			s.jsonpCallback() :
+			s.jsonpCallback;
+
+		// Insert callback into url or form data
+		if ( jsonProp ) {
+			s[ jsonProp ] = s[ jsonProp ].replace( rjsonp, "$1" + callbackName );
+		} else if ( s.jsonp !== false ) {
+			s.url += ( rquery.test( s.url ) ? "&" : "?" ) + s.jsonp + "=" + callbackName;
+		}
+
+		// Use data converter to retrieve json after script execution
+		s.converters["script json"] = function() {
+			if ( !responseContainer ) {
+				jQuery.error( callbackName + " was not called" );
+			}
+			return responseContainer[ 0 ];
+		};
+
+		// force json dataType
+		s.dataTypes[ 0 ] = "json";
+
+		// Install callback
+		overwritten = window[ callbackName ];
+		window[ callbackName ] = function() {
+			responseContainer = arguments;
+		};
+
+		// Clean-up function (fires after converters)
+		jqXHR.always(function() {
+			// Restore preexisting value
+			window[ callbackName ] = overwritten;
+
+			// Save back as free
+			if ( s[ callbackName ] ) {
+				// make sure that re-using the options doesn't screw things around
+				s.jsonpCallback = originalSettings.jsonpCallback;
+
+				// save the callback name for future use
+				oldCallbacks.push( callbackName );
+			}
+
+			// Call if it was a function and we have a response
+			if ( responseContainer && jQuery.isFunction( overwritten ) ) {
+				overwritten( responseContainer[ 0 ] );
+			}
+
+			responseContainer = overwritten = undefined;
+		});
+
+		// Delegate to script
+		return "script";
+	}
+});
+
+
+
+
+// data: string of html
+// context (optional): If specified, the fragment will be created in this context, defaults to document
+// keepScripts (optional): If true, will include scripts passed in the html string
+jQuery.parseHTML = function( data, context, keepScripts ) {
+	if ( !data || typeof data !== "string" ) {
+		return null;
+	}
+	if ( typeof context === "boolean" ) {
+		keepScripts = context;
+		context = false;
+	}
+	context = context || document;
+
+	var parsed = rsingleTag.exec( data ),
+		scripts = !keepScripts && [];
+
+	// Single tag
+	if ( parsed ) {
+		return [ context.createElement( parsed[1] ) ];
+	}
+
+	parsed = jQuery.buildFragment( [ data ], context, scripts );
+
+	if ( scripts && scripts.length ) {
+		jQuery( scripts ).remove();
+	}
+
+	return jQuery.merge( [], parsed.childNodes );
+};
+
+
+// Keep a copy of the old load method
+var _load = jQuery.fn.load;
+
+/**
+ * Load a url into a page
+ */
+jQuery.fn.load = function( url, params, callback ) {
+	if ( typeof url !== "string" && _load ) {
+		return _load.apply( this, arguments );
+	}
+
+	var selector, type, response,
+		self = this,
+		off = url.indexOf(" ");
+
+	if ( off >= 0 ) {
+		selector = jQuery.trim( url.slice( off ) );
+		url = url.slice( 0, off );
+	}
+
+	// If it's a function
+	if ( jQuery.isFunction( params ) ) {
+
+		// We assume that it's the callback
+		callback = params;
+		params = undefined;
+
+	// Otherwise, build a param string
+	} else if ( params && typeof params === "object" ) {
+		type = "POST";
+	}
+
+	// If we have elements to modify, make the request
+	if ( self.length > 0 ) {
+		jQuery.ajax({
+			url: url,
+
+			// if "type" variable is undefined, then "GET" method will be used
+			type: type,
+			dataType: "html",
+			data: params
+		}).done(function( responseText ) {
+
+			// Save response for use in complete callback
+			response = arguments;
+
+			self.html( selector ?
+
+				// If a selector was specified, locate the right elements in a dummy div
+				// Exclude scripts to avoid IE 'Permission Denied' errors
+				jQuery("<div>").append( jQuery.parseHTML( responseText ) ).find( selector ) :
+
+				// Otherwise use the full result
+				responseText );
+
+		}).complete( callback && function( jqXHR, status ) {
+			self.each( callback, response || [ jqXHR.responseText, status, jqXHR ] );
+		});
+	}
+
+	return this;
+};
+
+
+
+
+// Attach a bunch of functions for handling common AJAX events
+jQuery.each( [ "ajaxStart", "ajaxStop", "ajaxComplete", "ajaxError", "ajaxSuccess", "ajaxSend" ], function( i, type ) {
+	jQuery.fn[ type ] = function( fn ) {
+		return this.on( type, fn );
+	};
+});
+
+
+
+
+jQuery.expr.filters.animated = function( elem ) {
+	return jQuery.grep(jQuery.timers, function( fn ) {
+		return elem === fn.elem;
+	}).length;
+};
+
+
+
+
+var docElem = window.document.documentElement;
+
+/**
+ * Gets a window from an element
+ */
+function getWindow( elem ) {
+	return jQuery.isWindow( elem ) ? elem : elem.nodeType === 9 && elem.defaultView;
+}
+
+jQuery.offset = {
+	setOffset: function( elem, options, i ) {
+		var curPosition, curLeft, curCSSTop, curTop, curOffset, curCSSLeft, calculatePosition,
+			position = jQuery.css( elem, "position" ),
+			curElem = jQuery( elem ),
+			props = {};
+
+		// Set position first, in-case top/left are set even on static elem
+		if ( position === "static" ) {
+			elem.style.position = "relative";
+		}
+
+		curOffset = curElem.offset();
+		curCSSTop = jQuery.css( elem, "top" );
+		curCSSLeft = jQuery.css( elem, "left" );
+		calculatePosition = ( position === "absolute" || position === "fixed" ) &&
+			( curCSSTop + curCSSLeft ).indexOf("auto") > -1;
+
+		// Need to be able to calculate position if either
+		// top or left is auto and position is either absolute or fixed
+		if ( calculatePosition ) {
+			curPosition = curElem.position();
+			curTop = curPosition.top;
+			curLeft = curPosition.left;
+
+		} else {
+			curTop = parseFloat( curCSSTop ) || 0;
+			curLeft = parseFloat( curCSSLeft ) || 0;
+		}
+
+		if ( jQuery.isFunction( options ) ) {
+			options = options.call( elem, i, curOffset );
+		}
+
+		if ( options.top != null ) {
+			props.top = ( options.top - curOffset.top ) + curTop;
+		}
+		if ( options.left != null ) {
+			props.left = ( options.left - curOffset.left ) + curLeft;
+		}
+
+		if ( "using" in options ) {
+			options.using.call( elem, props );
+
+		} else {
+			curElem.css( props );
+		}
+	}
+};
+
+jQuery.fn.extend({
+	offset: function( options ) {
+		if ( arguments.length ) {
+			return options === undefined ?
+				this :
+				this.each(function( i ) {
+					jQuery.offset.setOffset( this, options, i );
+				});
+		}
+
+		var docElem, win,
+			elem = this[ 0 ],
+			box = { top: 0, left: 0 },
+			doc = elem && elem.ownerDocument;
+
+		if ( !doc ) {
+			return;
+		}
+
+		docElem = doc.documentElement;
+
+		// Make sure it's not a disconnected DOM node
+		if ( !jQuery.contains( docElem, elem ) ) {
+			return box;
+		}
+
+		// Support: BlackBerry 5, iOS 3 (original iPhone)
+		// If we don't have gBCR, just use 0,0 rather than error
+		if ( typeof elem.getBoundingClientRect !== strundefined ) {
+			box = elem.getBoundingClientRect();
+		}
+		win = getWindow( doc );
+		return {
+			top: box.top + win.pageYOffset - docElem.clientTop,
+			left: box.left + win.pageXOffset - docElem.clientLeft
+		};
+	},
+
+	position: function() {
+		if ( !this[ 0 ] ) {
+			return;
+		}
+
+		var offsetParent, offset,
+			elem = this[ 0 ],
+			parentOffset = { top: 0, left: 0 };
+
+		// Fixed elements are offset from window (parentOffset = {top:0, left: 0}, because it is its only offset parent
+		if ( jQuery.css( elem, "position" ) === "fixed" ) {
+			// Assume getBoundingClientRect is there when computed position is fixed
+			offset = elem.getBoundingClientRect();
+
+		} else {
+			// Get *real* offsetParent
+			offsetParent = this.offsetParent();
+
+			// Get correct offsets
+			offset = this.offset();
+			if ( !jQuery.nodeName( offsetParent[ 0 ], "html" ) ) {
+				parentOffset = offsetParent.offset();
+			}
+
+			// Add offsetParent borders
+			parentOffset.top += jQuery.css( offsetParent[ 0 ], "borderTopWidth", true );
+			parentOffset.left += jQuery.css( offsetParent[ 0 ], "borderLeftWidth", true );
+		}
+
+		// Subtract parent offsets and element margins
+		return {
+			top: offset.top - parentOffset.top - jQuery.css( elem, "marginTop", true ),
+			left: offset.left - parentOffset.left - jQuery.css( elem, "marginLeft", true )
+		};
+	},
+
+	offsetParent: function() {
+		return this.map(function() {
+			var offsetParent = this.offsetParent || docElem;
+
+			while ( offsetParent && ( !jQuery.nodeName( offsetParent, "html" ) && jQuery.css( offsetParent, "position" ) === "static" ) ) {
+				offsetParent = offsetParent.offsetParent;
+			}
+
+			return offsetParent || docElem;
+		});
+	}
+});
+
+// Create scrollLeft and scrollTop methods
+jQuery.each( { scrollLeft: "pageXOffset", scrollTop: "pageYOffset" }, function( method, prop ) {
+	var top = "pageYOffset" === prop;
+
+	jQuery.fn[ method ] = function( val ) {
+		return access( this, function( elem, method, val ) {
+			var win = getWindow( elem );
+
+			if ( val === undefined ) {
+				return win ? win[ prop ] : elem[ method ];
+			}
+
+			if ( win ) {
+				win.scrollTo(
+					!top ? val : window.pageXOffset,
+					top ? val : window.pageYOffset
+				);
+
+			} else {
+				elem[ method ] = val;
+			}
+		}, method, val, arguments.length, null );
+	};
+});
+
+// Support: Safari<7+, Chrome<37+
+// Add the top/left cssHooks using jQuery.fn.position
+// Webkit bug: https://bugs.webkit.org/show_bug.cgi?id=29084
+// Blink bug: https://code.google.com/p/chromium/issues/detail?id=229280
+// getComputedStyle returns percent when specified for top/left/bottom/right;
+// rather than make the css module depend on the offset module, just check for it here
+jQuery.each( [ "top", "left" ], function( i, prop ) {
+	jQuery.cssHooks[ prop ] = addGetHookIf( support.pixelPosition,
+		function( elem, computed ) {
+			if ( computed ) {
+				computed = curCSS( elem, prop );
+				// If curCSS returns percentage, fallback to offset
+				return rnumnonpx.test( computed ) ?
+					jQuery( elem ).position()[ prop ] + "px" :
+					computed;
+			}
+		}
+	);
+});
+
+
+// Create innerHeight, innerWidth, height, width, outerHeight and outerWidth methods
+jQuery.each( { Height: "height", Width: "width" }, function( name, type ) {
+	jQuery.each( { padding: "inner" + name, content: type, "": "outer" + name }, function( defaultExtra, funcName ) {
+		// Margin is only for outerHeight, outerWidth
+		jQuery.fn[ funcName ] = function( margin, value ) {
+			var chainable = arguments.length && ( defaultExtra || typeof margin !== "boolean" ),
+				extra = defaultExtra || ( margin === true || value === true ? "margin" : "border" );
+
+			return access( this, function( elem, type, value ) {
+				var doc;
+
+				if ( jQuery.isWindow( elem ) ) {
+					// As of 5/8/2012 this will yield incorrect results for Mobile Safari, but there
+					// isn't a whole lot we can do. See pull request at this URL for discussion:
+					// https://github.com/jquery/jquery/pull/764
+					return elem.document.documentElement[ "client" + name ];
+				}
+
+				// Get document width or height
+				if ( elem.nodeType === 9 ) {
+					doc = elem.documentElement;
+
+					// Either scroll[Width/Height] or offset[Width/Height] or client[Width/Height],
+					// whichever is greatest
+					return Math.max(
+						elem.body[ "scroll" + name ], doc[ "scroll" + name ],
+						elem.body[ "offset" + name ], doc[ "offset" + name ],
+						doc[ "client" + name ]
+					);
+				}
+
+				return value === undefined ?
+					// Get width or height on the element, requesting but not forcing parseFloat
+					jQuery.css( elem, type, extra ) :
+
+					// Set width or height on the element
+					jQuery.style( elem, type, value, extra );
+			}, type, chainable ? margin : undefined, chainable, null );
+		};
+	});
+});
+
+
+// The number of elements contained in the matched element set
+jQuery.fn.size = function() {
+	return this.length;
+};
+
+jQuery.fn.andSelf = jQuery.fn.addBack;
+
+
+
+
+// Register as a named AMD module, since jQuery can be concatenated with other
+// files that may use define, but not via a proper concatenation script that
+// understands anonymous AMD modules. A named AMD is safest and most robust
+// way to register. Lowercase jquery is used because AMD module names are
+// derived from file names, and jQuery is normally delivered in a lowercase
+// file name. Do this after creating the global so that if an AMD module wants
+// to call noConflict to hide this version of jQuery, it will work.
+
+// Note that for maximum portability, libraries that are not jQuery should
+// declare themselves as anonymous modules, and avoid setting a global if an
+// AMD loader is present. jQuery is a special case. For more information, see
+// https://github.com/jrburke/requirejs/wiki/Updating-existing-libraries#wiki-anon
+
+if ( typeof define === "function" && define.amd ) {
+	define( "jquery", [], function() {
+		return jQuery;
+	});
+}
+
+
+
+
+var
+	// Map over jQuery in case of overwrite
+	_jQuery = window.jQuery,
+
+	// Map over the $ in case of overwrite
+	_$ = window.$;
+
+jQuery.noConflict = function( deep ) {
+	if ( window.$ === jQuery ) {
+		window.$ = _$;
+	}
+
+	if ( deep && window.jQuery === jQuery ) {
+		window.jQuery = _jQuery;
+	}
+
+	return jQuery;
+};
+
+// Expose jQuery and $ identifiers, even in AMD
+// (#7102#comment:10, https://github.com/jquery/jquery/pull/557)
+// and CommonJS for browser emulators (#13566)
+if ( typeof noGlobal === strundefined ) {
+	window.jQuery = window.$ = jQuery;
+}
+
+
+
+
+return jQuery;
+
+}));
\ No newline at end of file
diff --git a/guacamole/src/main/webapp/lib/lodash/LICENSE.txt b/guacamole/src/main/webapp/lib/lodash/LICENSE.txt
new file mode 100644
index 0000000..49869bb
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/lodash/LICENSE.txt
@@ -0,0 +1,22 @@
+Copyright 2012-2013 The Dojo Foundation <http://dojofoundation.org/>
+Based on Underscore.js 1.5.2, copyright 2009-2013 Jeremy Ashkenas,
+DocumentCloud and Investigative Reporters & Editors <http://underscorejs.org/>
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
\ No newline at end of file
diff --git a/guacamole/src/main/webapp/lib/lodash/lodash.js b/guacamole/src/main/webapp/lib/lodash/lodash.js
new file mode 100644
index 0000000..5dfbe35
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/lodash/lodash.js
@@ -0,0 +1,56 @@
+/*!
+ * @license
+ * Lo-Dash 2.4.1 (Custom Build) lodash.com/license | Underscore.js 1.5.2 underscorejs.org/LICENSE
+ * Build: `lodash modern -o ./dist/lodash.js`
+ */
+;(function(){function n(n,t,e){e=(e||0)-1;for(var r=n?n.length:0;++e<r;)if(n[e]===t)return e;return-1}function t(t,e){var r=typeof e;if(t=t.l,"boolean"==r||null==e)return t[e]?0:-1;"number"!=r&&"string"!=r&&(r="object");var u="number"==r?e:m+e;return t=(t=t[r])&&t[u],"object"==r?t&&-1<n(t,e)?0:-1:t?0:-1}function e(n){var t=this.l,e=typeof n;if("boolean"==e||null==n)t[n]=true;else{"number"!=e&&"string"!=e&&(e="object");var r="number"==e?n:m+n,t=t[e]||(t[e]={});"object"==e?(t[r]||(t[r]=[]) [...]
+}}function r(n){return n.charCodeAt(0)}function u(n,t){for(var e=n.m,r=t.m,u=-1,o=e.length;++u<o;){var i=e[u],a=r[u];if(i!==a){if(i>a||typeof i=="undefined")return 1;if(i<a||typeof a=="undefined")return-1}}return n.n-t.n}function o(n){var t=-1,r=n.length,u=n[0],o=n[r/2|0],i=n[r-1];if(u&&typeof u=="object"&&o&&typeof o=="object"&&i&&typeof i=="object")return false;for(u=f(),u["false"]=u["null"]=u["true"]=u.undefined=false,o=f(),o.k=n,o.l=u,o.push=e;++t<r;)o.push(n[t]);return o}function i( [...]
+}function a(){return h.pop()||[]}function f(){return g.pop()||{k:null,l:null,m:null,"false":false,n:0,"null":false,number:null,object:null,push:null,string:null,"true":false,undefined:false,o:null}}function l(n){n.length=0,h.length<_&&h.push(n)}function c(n){var t=n.l;t&&c(t),n.k=n.l=n.m=n.object=n.number=n.string=n.o=null,g.length<_&&g.push(n)}function p(n,t,e){t||(t=0),typeof e=="undefined"&&(e=n?n.length:0);var r=-1;e=e-t||0;for(var u=Array(0>e?0:e);++r<e;)u[r]=n[t+r];return u}functio [...]
+t=t&&typeof e=="undefined"?t:tt(t,e,3);for(var r=-1,u=V[typeof n]&&Fe(n),o=u?u.length:0;++r<o&&(e=u[r],false!==t(n[e],e,n)););return n}function g(n,t,e){var r;if(!n||!V[typeof n])return n;t=t&&typeof e=="undefined"?t:tt(t,e,3);for(r in n)if(false===t(n[r],r,n))break;return n}function _(n,t,e){var r,u=n,o=u;if(!u)return o;for(var i=arguments,a=0,f=typeof e=="number"?2:i.length;++a<f;)if((u=i[a])&&V[typeof u])for(var l=-1,c=V[typeof u]&&Fe(u),p=c?c.length:0;++l<p;)r=c[l],"undefined"==typeo [...]
+return o}function U(n,t,e){var r,u=n,o=u;if(!u)return o;var i=arguments,a=0,f=typeof e=="number"?2:i.length;if(3<f&&"function"==typeof i[f-2])var l=tt(i[--f-1],i[f--],2);else 2<f&&"function"==typeof i[f-1]&&(l=i[--f]);for(;++a<f;)if((u=i[a])&&V[typeof u])for(var c=-1,p=V[typeof u]&&Fe(u),s=p?p.length:0;++c<s;)r=p[c],o[r]=l?l(o[r],u[r]):u[r];return o}function H(n){var t,e=[];if(!n||!V[typeof n])return e;for(t in n)me.call(n,t)&&e.push(t);return e}function J(n){return n&&typeof n=="object" [...]
+}function Q(n,t){this.__chain__=!!t,this.__wrapped__=n}function X(n){function t(){if(r){var n=p(r);be.apply(n,arguments)}if(this instanceof t){var o=nt(e.prototype),n=e.apply(o,n||arguments);return wt(n)?n:o}return e.apply(u,n||arguments)}var e=n[0],r=n[2],u=n[4];return $e(t,n),t}function Z(n,t,e,r,u){if(e){var o=e(n);if(typeof o!="undefined")return o}if(!wt(n))return n;var i=ce.call(n);if(!K[i])return n;var f=Ae[i];switch(i){case T:case F:return new f(+n);case W:case P:return new f(n);c [...]
+}if(i=Te(n),t){var c=!r;r||(r=a()),u||(u=a());for(var s=r.length;s--;)if(r[s]==n)return u[s];o=i?f(n.length):{}}else o=i?p(n):U({},n);return i&&(me.call(n,"index")&&(o.index=n.index),me.call(n,"input")&&(o.input=n.input)),t?(r.push(n),u.push(o),(i?St:h)(n,function(n,i){o[i]=Z(n,t,e,r,u)}),c&&(l(r),l(u)),o):o}function nt(n){return wt(n)?ke(n):{}}function tt(n,t,e){if(typeof n!="function")return Ut;if(typeof t=="undefined"||!("prototype"in n))return n;var r=n.__bindData__;if(typeof r=="und [...]
+De.funcNames||(r=!O.test(u)),r||(r=E.test(u),$e(n,r))}if(false===r||true!==r&&1&r[1])return n;switch(e){case 1:return function(e){return n.call(t,e)};case 2:return function(e,r){return n.call(t,e,r)};case 3:return function(e,r,u){return n.call(t,e,r,u)};case 4:return function(e,r,u,o){return n.call(t,e,r,u,o)}}return Mt(n,t)}function et(n){function t(){var n=f?i:this;if(u){var h=p(u);be.apply(h,arguments)}return(o||c)&&(h||(h=p(arguments)),o&&be.apply(h,o),c&&h.length<a)?(r|=16,et([e,s?r [...]
+}var e=n[0],r=n[1],u=n[2],o=n[3],i=n[4],a=n[5],f=1&r,l=2&r,c=4&r,s=8&r,v=e;return $e(t,n),t}function rt(e,r){var u=-1,i=st(),a=e?e.length:0,f=a>=b&&i===n,l=[];if(f){var p=o(r);p?(i=t,r=p):f=false}for(;++u<a;)p=e[u],0>i(r,p)&&l.push(p);return f&&c(r),l}function ut(n,t,e,r){r=(r||0)-1;for(var u=n?n.length:0,o=[];++r<u;){var i=n[r];if(i&&typeof i=="object"&&typeof i.length=="number"&&(Te(i)||yt(i))){t||(i=ut(i,t,e));var a=-1,f=i.length,l=o.length;for(o.length+=f;++a<f;)o[l++]=i[a]}else e||o [...]
+}function ot(n,t,e,r,u,o){if(e){var i=e(n,t);if(typeof i!="undefined")return!!i}if(n===t)return 0!==n||1/n==1/t;if(n===n&&!(n&&V[typeof n]||t&&V[typeof t]))return false;if(null==n||null==t)return n===t;var f=ce.call(n),c=ce.call(t);if(f==D&&(f=q),c==D&&(c=q),f!=c)return false;switch(f){case T:case F:return+n==+t;case W:return n!=+n?t!=+t:0==n?1/n==1/t:n==+t;case z:case P:return n==oe(t)}if(c=f==$,!c){var p=me.call(n,"__wrapped__"),s=me.call(t,"__wrapped__");if(p||s)return ot(p?n.__wrappe [...]
+if(f!=q)return false;if(f=n.constructor,p=t.constructor,f!=p&&!(dt(f)&&f instanceof f&&dt(p)&&p instanceof p)&&"constructor"in n&&"constructor"in t)return false}for(f=!u,u||(u=a()),o||(o=a()),p=u.length;p--;)if(u[p]==n)return o[p]==t;var v=0,i=true;if(u.push(n),o.push(t),c){if(p=n.length,v=t.length,(i=v==p)||r)for(;v--;)if(c=p,s=t[v],r)for(;c--&&!(i=ot(n[c],s,e,r,u,o)););else if(!(i=ot(n[v],s,e,r,u,o)))break}else g(t,function(t,a,f){return me.call(f,a)?(v++,i=me.call(n,a)&&ot(n[a],t,e,r, [...]
+});return u.pop(),o.pop(),f&&(l(u),l(o)),i}function it(n,t,e,r,u){(Te(t)?St:h)(t,function(t,o){var i,a,f=t,l=n[o];if(t&&((a=Te(t))||Pe(t))){for(f=r.length;f--;)if(i=r[f]==t){l=u[f];break}if(!i){var c;e&&(f=e(l,t),c=typeof f!="undefined")&&(l=f),c||(l=a?Te(l)?l:[]:Pe(l)?l:{}),r.push(t),u.push(l),c||it(l,t,e,r,u)}}else e&&(f=e(l,t),typeof f=="undefined"&&(f=t)),typeof f!="undefined"&&(l=f);n[o]=l})}function at(n,t){return n+he(Re()*(t-n+1))}function ft(e,r,u){var i=-1,f=st(),p=e?e.length:0 [...]
+for(v&&(h=o(h),f=t);++i<p;){var g=e[i],y=u?u(g,i,e):g;(r?!i||h[h.length-1]!==y:0>f(h,y))&&((u||v)&&h.push(y),s.push(g))}return v?(l(h.k),c(h)):u&&l(h),s}function lt(n){return function(t,e,r){var u={};e=J.createCallback(e,r,3),r=-1;var o=t?t.length:0;if(typeof o=="number")for(;++r<o;){var i=t[r];n(u,i,e(i,r,t),t)}else h(t,function(t,r,o){n(u,t,e(t,r,o),o)});return u}}function ct(n,t,e,r,u,o){var i=1&t,a=4&t,f=16&t,l=32&t;if(!(2&t||dt(n)))throw new ie;f&&!e.length&&(t&=-17,f=e=false),l&&!r [...]
+var c=n&&n.__bindData__;return c&&true!==c?(c=p(c),c[2]&&(c[2]=p(c[2])),c[3]&&(c[3]=p(c[3])),!i||1&c[1]||(c[4]=u),!i&&1&c[1]&&(t|=8),!a||4&c[1]||(c[5]=o),f&&be.apply(c[2]||(c[2]=[]),e),l&&we.apply(c[3]||(c[3]=[]),r),c[1]|=t,ct.apply(null,c)):(1==t||17===t?X:et)([n,t,e,r,u,o])}function pt(n){return Be[n]}function st(){var t=(t=J.indexOf)===Wt?n:t;return t}function vt(n){return typeof n=="function"&&pe.test(n)}function ht(n){var t,e;return n&&ce.call(n)==q&&(t=n.constructor,!dt(t)||t insta [...]
+}),typeof e=="undefined"||me.call(n,e)):false}function gt(n){return We[n]}function yt(n){return n&&typeof n=="object"&&typeof n.length=="number"&&ce.call(n)==D||false}function mt(n,t,e){var r=Fe(n),u=r.length;for(t=tt(t,e,3);u--&&(e=r[u],false!==t(n[e],e,n)););return n}function bt(n){var t=[];return g(n,function(n,e){dt(n)&&t.push(e)}),t.sort()}function _t(n){for(var t=-1,e=Fe(n),r=e.length,u={};++t<r;){var o=e[t];u[n[o]]=o}return u}function dt(n){return typeof n=="function"}function wt( [...]
+}function jt(n){return typeof n=="number"||n&&typeof n=="object"&&ce.call(n)==W||false}function kt(n){return typeof n=="string"||n&&typeof n=="object"&&ce.call(n)==P||false}function xt(n){for(var t=-1,e=Fe(n),r=e.length,u=Xt(r);++t<r;)u[t]=n[e[t]];return u}function Ct(n,t,e){var r=-1,u=st(),o=n?n.length:0,i=false;return e=(0>e?Ie(0,o+e):e)||0,Te(n)?i=-1<u(n,t,e):typeof o=="number"?i=-1<(kt(n)?n.indexOf(t,e):u(n,t,e)):h(n,function(n){return++r<e?void 0:!(i=n===t)}),i}function Ot(n,t,e){va [...]
+var u=n?n.length:0;if(typeof u=="number")for(;++e<u&&(r=!!t(n[e],e,n)););else h(n,function(n,e,u){return r=!!t(n,e,u)});return r}function Nt(n,t,e){var r=[];t=J.createCallback(t,e,3),e=-1;var u=n?n.length:0;if(typeof u=="number")for(;++e<u;){var o=n[e];t(o,e,n)&&r.push(o)}else h(n,function(n,e,u){t(n,e,u)&&r.push(n)});return r}function It(n,t,e){t=J.createCallback(t,e,3),e=-1;var r=n?n.length:0;if(typeof r!="number"){var u;return h(n,function(n,e,r){return t(n,e,r)?(u=n,false):void 0}),u [...]
+if(t(o,e,n))return o}}function St(n,t,e){var r=-1,u=n?n.length:0;if(t=t&&typeof e=="undefined"?t:tt(t,e,3),typeof u=="number")for(;++r<u&&false!==t(n[r],r,n););else h(n,t);return n}function Et(n,t,e){var r=n?n.length:0;if(t=t&&typeof e=="undefined"?t:tt(t,e,3),typeof r=="number")for(;r--&&false!==t(n[r],r,n););else{var u=Fe(n),r=u.length;h(n,function(n,e,o){return e=u?u[--r]:--r,t(o[e],e,o)})}return n}function Rt(n,t,e){var r=-1,u=n?n.length:0;if(t=J.createCallback(t,e,3),typeof u=="numb [...]
+else o=[],h(n,function(n,e,u){o[++r]=t(n,e,u)});return o}function At(n,t,e){var u=-1/0,o=u;if(typeof t!="function"&&e&&e[t]===n&&(t=null),null==t&&Te(n)){e=-1;for(var i=n.length;++e<i;){var a=n[e];a>o&&(o=a)}}else t=null==t&&kt(n)?r:J.createCallback(t,e,3),St(n,function(n,e,r){e=t(n,e,r),e>u&&(u=e,o=n)});return o}function Dt(n,t,e,r){if(!n)return e;var u=3>arguments.length;t=J.createCallback(t,r,4);var o=-1,i=n.length;if(typeof i=="number")for(u&&(e=n[++o]);++o<i;)e=t(e,n[o],o,n);else h( [...]
+});return e}function $t(n,t,e,r){var u=3>arguments.length;return t=J.createCallback(t,r,4),Et(n,function(n,r,o){e=u?(u=false,n):t(e,n,r,o)}),e}function Tt(n){var t=-1,e=n?n.length:0,r=Xt(typeof e=="number"?e:0);return St(n,function(n){var e=at(0,++t);r[t]=r[e],r[e]=n}),r}function Ft(n,t,e){var r;t=J.createCallback(t,e,3),e=-1;var u=n?n.length:0;if(typeof u=="number")for(;++e<u&&!(r=t(n[e],e,n)););else h(n,function(n,e,u){return!(r=t(n,e,u))});return!!r}function Bt(n,t,e){var r=0,u=n?n.le [...]
+for(t=J.createCallback(t,e,3);++o<u&&t(n[o],o,n);)r++}else if(r=t,null==r||e)return n?n[0]:v;return p(n,0,Se(Ie(0,r),u))}function Wt(t,e,r){if(typeof r=="number"){var u=t?t.length:0;r=0>r?Ie(0,u+r):r||0}else if(r)return r=zt(t,e),t[r]===e?r:-1;return n(t,e,r)}function qt(n,t,e){if(typeof t!="number"&&null!=t){var r=0,u=-1,o=n?n.length:0;for(t=J.createCallback(t,e,3);++u<o&&t(n[u],u,n);)r++}else r=null==t||e?1:Ie(0,t);return p(n,r)}function zt(n,t,e,r){var u=0,o=n?n.length:u;for(e=e?J.cre [...]
+return u}function Pt(n,t,e,r){return typeof t!="boolean"&&null!=t&&(r=e,e=typeof t!="function"&&r&&r[t]===n?null:t,t=false),null!=e&&(e=J.createCallback(e,r,3)),ft(n,t,e)}function Kt(){for(var n=1<arguments.length?arguments:arguments[0],t=-1,e=n?At(Ve(n,"length")):0,r=Xt(0>e?0:e);++t<e;)r[t]=Ve(n,t);return r}function Lt(n,t){var e=-1,r=n?n.length:0,u={};for(t||!r||Te(n[0])||(t=[]);++e<r;){var o=n[e];t?u[o]=t[e]:o&&(u[o[0]]=o[1])}return u}function Mt(n,t){return 2<arguments.length?ct(n,17 [...]
+}function Vt(n,t,e){function r(){c&&ve(c),i=c=p=v,(g||h!==t)&&(s=Ue(),a=n.apply(l,o),c||i||(o=l=null))}function u(){var e=t-(Ue()-f);0<e?c=_e(u,e):(i&&ve(i),e=p,i=c=p=v,e&&(s=Ue(),a=n.apply(l,o),c||i||(o=l=null)))}var o,i,a,f,l,c,p,s=0,h=false,g=true;if(!dt(n))throw new ie;if(t=Ie(0,t)||0,true===e)var y=true,g=false;else wt(e)&&(y=e.leading,h="maxWait"in e&&(Ie(t,e.maxWait)||0),g="trailing"in e?e.trailing:g);return function(){if(o=arguments,f=Ue(),l=this,p=g&&(c||!y),false===h)var e=y&&! [...]
+m?(i&&(i=ve(i)),s=f,a=n.apply(l,o)):i||(i=_e(r,v))}return m&&c?c=ve(c):c||t===h||(c=_e(u,t)),e&&(m=true,a=n.apply(l,o)),!m||c||i||(o=l=null),a}}function Ut(n){return n}function Gt(n,t,e){var r=true,u=t&&bt(t);t&&(e||u.length)||(null==e&&(e=t),o=Q,t=n,n=J,u=bt(t)),false===e?r=false:wt(e)&&"chain"in e&&(r=e.chain);var o=n,i=dt(o);St(u,function(e){var u=n[e]=t[e];i&&(o.prototype[e]=function(){var t=this.__chain__,e=this.__wrapped__,i=[e];if(be.apply(i,arguments),i=u.apply(n,i),r||t){if(e=== [...]
+i=new o(i),i.__chain__=t}return i})})}function Ht(){}function Jt(n){return function(t){return t[n]}}function Qt(){return this.__wrapped__}e=e?Y.defaults(G.Object(),e,Y.pick(G,A)):G;var Xt=e.Array,Yt=e.Boolean,Zt=e.Date,ne=e.Function,te=e.Math,ee=e.Number,re=e.Object,ue=e.RegExp,oe=e.String,ie=e.TypeError,ae=[],fe=re.prototype,le=e._,ce=fe.toString,pe=ue("^"+oe(ce).replace(/[.*+?^${}()|[\]\\]/g,"\\$&").replace(/toString| for [^\]]+/g,".*?")+"$"),se=te.ceil,ve=e.clearTimeout,he=te.floor,ge [...]
+}catch(r){}return e}(),ke=vt(ke=re.create)&&ke,xe=vt(xe=Xt.isArray)&&xe,Ce=e.isFinite,Oe=e.isNaN,Ne=vt(Ne=re.keys)&&Ne,Ie=te.max,Se=te.min,Ee=e.parseInt,Re=te.random,Ae={};Ae[$]=Xt,Ae[T]=Yt,Ae[F]=Zt,Ae[B]=ne,Ae[q]=re,Ae[W]=ee,Ae[z]=ue,Ae[P]=oe,Q.prototype=J.prototype;var De=J.support={};De.funcDecomp=!vt(e.a)&&E.test(s),De.funcNames=typeof ne.name=="string",J.templateSettings={escape:/<%-([\s\S]+?)%>/g,evaluate:/<%([\s\S]+?)%>/g,interpolate:N,variable:"",imports:{_:J}},ke||(nt=function() [...]
+var r=new n;n.prototype=null}return r||e.Object()}}());var $e=je?function(n,t){M.value=t,je(n,"__bindData__",M)}:Ht,Te=xe||function(n){return n&&typeof n=="object"&&typeof n.length=="number"&&ce.call(n)==$||false},Fe=Ne?function(n){return wt(n)?Ne(n):[]}:H,Be={"&":"&","<":"<",">":">",'"':""","'":"'"},We=_t(Be),qe=ue("("+Fe(We).join("|")+")","g"),ze=ue("["+Fe(Be).join("")+"]","g"),Pe=ye?function(n){if(!n||ce.call(n)!=q)return false;var t=n.valueOf,e=vt(t)&&(e=ye(t))&&ye [...]
+}:ht,Ke=lt(function(n,t,e){me.call(n,e)?n[e]++:n[e]=1}),Le=lt(function(n,t,e){(me.call(n,e)?n[e]:n[e]=[]).push(t)}),Me=lt(function(n,t,e){n[e]=t}),Ve=Rt,Ue=vt(Ue=Zt.now)&&Ue||function(){return(new Zt).getTime()},Ge=8==Ee(d+"08")?Ee:function(n,t){return Ee(kt(n)?n.replace(I,""):n,t||0)};return J.after=function(n,t){if(!dt(t))throw new ie;return function(){return 1>--n?t.apply(this,arguments):void 0}},J.assign=U,J.at=function(n){for(var t=arguments,e=-1,r=ut(t,true,false,1),t=t[2]&&t[2][t[ [...]
+return u},J.bind=Mt,J.bindAll=function(n){for(var t=1<arguments.length?ut(arguments,true,false,1):bt(n),e=-1,r=t.length;++e<r;){var u=t[e];n[u]=ct(n[u],1,null,null,n)}return n},J.bindKey=function(n,t){return 2<arguments.length?ct(t,19,p(arguments,2),null,n):ct(t,3,null,null,n)},J.chain=function(n){return n=new Q(n),n.__chain__=true,n},J.compact=function(n){for(var t=-1,e=n?n.length:0,r=[];++t<e;){var u=n[t];u&&r.push(u)}return r},J.compose=function(){for(var n=arguments,t=n.length;t--;)i [...]
+return function(){for(var t=arguments,e=n.length;e--;)t=[n[e].apply(this,t)];return t[0]}},J.constant=function(n){return function(){return n}},J.countBy=Ke,J.create=function(n,t){var e=nt(n);return t?U(e,t):e},J.createCallback=function(n,t,e){var r=typeof n;if(null==n||"function"==r)return tt(n,t,e);if("object"!=r)return Jt(n);var u=Fe(n),o=u[0],i=n[o];return 1!=u.length||i!==i||wt(i)?function(t){for(var e=u.length,r=false;e--&&(r=ot(t[u[e]],n[u[e]],null,true)););return r}:function(n){re [...]
+}},J.curry=function(n,t){return t=typeof t=="number"?t:+t||n.length,ct(n,4,null,null,null,t)},J.debounce=Vt,J.defaults=_,J.defer=function(n){if(!dt(n))throw new ie;var t=p(arguments,1);return _e(function(){n.apply(v,t)},1)},J.delay=function(n,t){if(!dt(n))throw new ie;var e=p(arguments,2);return _e(function(){n.apply(v,e)},t)},J.difference=function(n){return rt(n,ut(arguments,true,true,1))},J.filter=Nt,J.flatten=function(n,t,e,r){return typeof t!="boolean"&&null!=t&&(r=e,e=typeof t!="fun [...]
+},J.forEach=St,J.forEachRight=Et,J.forIn=g,J.forInRight=function(n,t,e){var r=[];g(n,function(n,t){r.push(t,n)});var u=r.length;for(t=tt(t,e,3);u--&&false!==t(r[u--],r[u],n););return n},J.forOwn=h,J.forOwnRight=mt,J.functions=bt,J.groupBy=Le,J.indexBy=Me,J.initial=function(n,t,e){var r=0,u=n?n.length:0;if(typeof t!="number"&&null!=t){var o=u;for(t=J.createCallback(t,e,3);o--&&t(n[o],o,n);)r++}else r=null==t||e?1:t||r;return p(n,0,Se(Ie(0,u-r),u))},J.intersection=function(){for(var e=[],r [...]
+(Te(v)||yt(v))&&(e.push(v),i.push(p&&v.length>=b&&o(r?e[r]:s)))}var p=e[0],h=-1,g=p?p.length:0,y=[];n:for(;++h<g;){var m=i[0],v=p[h];if(0>(m?t(m,v):f(s,v))){for(r=u,(m||s).push(v);--r;)if(m=i[r],0>(m?t(m,v):f(e[r],v)))continue n;y.push(v)}}for(;u--;)(m=i[u])&&c(m);return l(i),l(s),y},J.invert=_t,J.invoke=function(n,t){var e=p(arguments,2),r=-1,u=typeof t=="function",o=n?n.length:0,i=Xt(typeof o=="number"?o:0);return St(n,function(n){i[++r]=(u?t:n[t]).apply(n,e)}),i},J.keys=Fe,J.map=Rt,J. [...]
+return t=J.createCallback(t,e,3),h(n,function(n,e,u){r[e]=t(n,e,u)}),r},J.max=At,J.memoize=function(n,t){function e(){var r=e.cache,u=t?t.apply(this,arguments):m+arguments[0];return me.call(r,u)?r[u]:r[u]=n.apply(this,arguments)}if(!dt(n))throw new ie;return e.cache={},e},J.merge=function(n){var t=arguments,e=2;if(!wt(n))return n;if("number"!=typeof t[2]&&(e=t.length),3<e&&"function"==typeof t[e-2])var r=tt(t[--e-1],t[e--],2);else 2<e&&"function"==typeof t[e-1]&&(r=t[--e]);for(var t=p(ar [...]
+return l(o),l(i),n},J.min=function(n,t,e){var u=1/0,o=u;if(typeof t!="function"&&e&&e[t]===n&&(t=null),null==t&&Te(n)){e=-1;for(var i=n.length;++e<i;){var a=n[e];a<o&&(o=a)}}else t=null==t&&kt(n)?r:J.createCallback(t,e,3),St(n,function(n,e,r){e=t(n,e,r),e<u&&(u=e,o=n)});return o},J.omit=function(n,t,e){var r={};if(typeof t!="function"){var u=[];g(n,function(n,t){u.push(t)});for(var u=rt(u,ut(arguments,true,false,1)),o=-1,i=u.length;++o<i;){var a=u[o];r[a]=n[a]}}else t=J.createCallback(t, [...]
+});return r},J.once=function(n){var t,e;if(!dt(n))throw new ie;return function(){return t?e:(t=true,e=n.apply(this,arguments),n=null,e)}},J.pairs=function(n){for(var t=-1,e=Fe(n),r=e.length,u=Xt(r);++t<r;){var o=e[t];u[t]=[o,n[o]]}return u},J.partial=function(n){return ct(n,16,p(arguments,1))},J.partialRight=function(n){return ct(n,32,null,p(arguments,1))},J.pick=function(n,t,e){var r={};if(typeof t!="function")for(var u=-1,o=ut(arguments,true,false,1),i=wt(n)?o.length:0;++u<i;){var a=o[ [...]
+}else t=J.createCallback(t,e,3),g(n,function(n,e,u){t(n,e,u)&&(r[e]=n)});return r},J.pluck=Ve,J.property=Jt,J.pull=function(n){for(var t=arguments,e=0,r=t.length,u=n?n.length:0;++e<r;)for(var o=-1,i=t[e];++o<u;)n[o]===i&&(de.call(n,o--,1),u--);return n},J.range=function(n,t,e){n=+n||0,e=typeof e=="number"?e:+e||1,null==t&&(t=n,n=0);var r=-1;t=Ie(0,se((t-n)/(e||1)));for(var u=Xt(t);++r<t;)u[r]=n,n+=e;return u},J.reject=function(n,t,e){return t=J.createCallback(t,e,3),Nt(n,function(n,e,r){ [...]
+})},J.remove=function(n,t,e){var r=-1,u=n?n.length:0,o=[];for(t=J.createCallback(t,e,3);++r<u;)e=n[r],t(e,r,n)&&(o.push(e),de.call(n,r--,1),u--);return o},J.rest=qt,J.shuffle=Tt,J.sortBy=function(n,t,e){var r=-1,o=Te(t),i=n?n.length:0,p=Xt(typeof i=="number"?i:0);for(o||(t=J.createCallback(t,e,3)),St(n,function(n,e,u){var i=p[++r]=f();o?i.m=Rt(t,function(t){return n[t]}):(i.m=a())[0]=t(n,e,u),i.n=r,i.o=n}),i=p.length,p.sort(u);i--;)n=p[i],p[i]=n.o,o||l(n.m),c(n);return p},J.tap=function( [...]
+},J.throttle=function(n,t,e){var r=true,u=true;if(!dt(n))throw new ie;return false===e?r=false:wt(e)&&(r="leading"in e?e.leading:r,u="trailing"in e?e.trailing:u),L.leading=r,L.maxWait=t,L.trailing=u,Vt(n,t,L)},J.times=function(n,t,e){n=-1<(n=+n)?n:0;var r=-1,u=Xt(n);for(t=tt(t,e,1);++r<n;)u[r]=t(r);return u},J.toArray=function(n){return n&&typeof n.length=="number"?p(n):xt(n)},J.transform=function(n,t,e,r){var u=Te(n);if(null==e)if(u)e=[];else{var o=n&&n.constructor;e=nt(o&&o.prototype)} [...]
+})),e},J.union=function(){return ft(ut(arguments,true,true))},J.uniq=Pt,J.values=xt,J.where=Nt,J.without=function(n){return rt(n,p(arguments,1))},J.wrap=function(n,t){return ct(t,16,[n])},J.xor=function(){for(var n=-1,t=arguments.length;++n<t;){var e=arguments[n];if(Te(e)||yt(e))var r=r?ft(rt(r,e).concat(rt(e,r))):e}return r||[]},J.zip=Kt,J.zipObject=Lt,J.collect=Rt,J.drop=qt,J.each=St,J.eachRight=Et,J.extend=U,J.methods=bt,J.object=Lt,J.select=Nt,J.tail=qt,J.unique=Pt,J.unzip=Kt,Gt(J),J [...]
+},J.cloneDeep=function(n,t,e){return Z(n,true,typeof t=="function"&&tt(t,e,1))},J.contains=Ct,J.escape=function(n){return null==n?"":oe(n).replace(ze,pt)},J.every=Ot,J.find=It,J.findIndex=function(n,t,e){var r=-1,u=n?n.length:0;for(t=J.createCallback(t,e,3);++r<u;)if(t(n[r],r,n))return r;return-1},J.findKey=function(n,t,e){var r;return t=J.createCallback(t,e,3),h(n,function(n,e,u){return t(n,e,u)?(r=e,false):void 0}),r},J.findLast=function(n,t,e){var r;return t=J.createCallback(t,e,3),Et [...]
+}),r},J.findLastIndex=function(n,t,e){var r=n?n.length:0;for(t=J.createCallback(t,e,3);r--;)if(t(n[r],r,n))return r;return-1},J.findLastKey=function(n,t,e){var r;return t=J.createCallback(t,e,3),mt(n,function(n,e,u){return t(n,e,u)?(r=e,false):void 0}),r},J.has=function(n,t){return n?me.call(n,t):false},J.identity=Ut,J.indexOf=Wt,J.isArguments=yt,J.isArray=Te,J.isBoolean=function(n){return true===n||false===n||n&&typeof n=="object"&&ce.call(n)==T||false},J.isDate=function(n){return n&&ty [...]
+},J.isElement=function(n){return n&&1===n.nodeType||false},J.isEmpty=function(n){var t=true;if(!n)return t;var e=ce.call(n),r=n.length;return e==$||e==P||e==D||e==q&&typeof r=="number"&&dt(n.splice)?!r:(h(n,function(){return t=false}),t)},J.isEqual=function(n,t,e,r){return ot(n,t,typeof e=="function"&&tt(e,r,2))},J.isFinite=function(n){return Ce(n)&&!Oe(parseFloat(n))},J.isFunction=dt,J.isNaN=function(n){return jt(n)&&n!=+n},J.isNull=function(n){return null===n},J.isNumber=jt,J.isObject= [...]
+},J.isString=kt,J.isUndefined=function(n){return typeof n=="undefined"},J.lastIndexOf=function(n,t,e){var r=n?n.length:0;for(typeof e=="number"&&(r=(0>e?Ie(0,r+e):Se(e,r-1))+1);r--;)if(n[r]===t)return r;return-1},J.mixin=Gt,J.noConflict=function(){return e._=le,this},J.noop=Ht,J.now=Ue,J.parseInt=Ge,J.random=function(n,t,e){var r=null==n,u=null==t;return null==e&&(typeof n=="boolean"&&u?(e=n,n=1):u||typeof t!="boolean"||(e=t,u=true)),r&&u&&(t=1),n=+n||0,u?(t=n,n=0):t=+t||0,e||n%1||t%1?(e [...]
+},J.reduce=Dt,J.reduceRight=$t,J.result=function(n,t){if(n){var e=n[t];return dt(e)?n[t]():e}},J.runInContext=s,J.size=function(n){var t=n?n.length:0;return typeof t=="number"?t:Fe(n).length},J.some=Ft,J.sortedIndex=zt,J.template=function(n,t,e){var r=J.templateSettings;n=oe(n||""),e=_({},e,r);var u,o=_({},e.imports,r.imports),r=Fe(o),o=xt(o),a=0,f=e.interpolate||S,l="__p+='",f=ue((e.escape||S).source+"|"+f.source+"|"+(f===N?x:S).source+"|"+(e.evaluate||S).source+"|$","g");n.replace(f,fu [...]
+}),l+="';",f=e=e.variable,f||(e="obj",l="with("+e+"){"+l+"}"),l=(u?l.replace(w,""):l).replace(j,"$1").replace(k,"$1;"),l="function("+e+"){"+(f?"":e+"||("+e+"={});")+"var __t,__p='',__e=_.escape"+(u?",__j=Array.prototype.join;function print(){__p+=__j.call(arguments,'')}":";")+l+"return __p}";try{var c=ne(r,"return "+l).apply(v,o)}catch(p){throw p.source=l,p}return t?c(t):(c.source=l,c)},J.unescape=function(n){return null==n?"":oe(n).replace(qe,gt)},J.uniqueId=function(n){var t=++y;return [...]
+},J.all=Ot,J.any=Ft,J.detect=It,J.findWhere=It,J.foldl=Dt,J.foldr=$t,J.include=Ct,J.inject=Dt,Gt(function(){var n={};return h(J,function(t,e){J.prototype[e]||(n[e]=t)}),n}(),false),J.first=Bt,J.last=function(n,t,e){var r=0,u=n?n.length:0;if(typeof t!="number"&&null!=t){var o=u;for(t=J.createCallback(t,e,3);o--&&t(n[o],o,n);)r++}else if(r=t,null==r||e)return n?n[u-1]:v;return p(n,Ie(0,u-r))},J.sample=function(n,t,e){return n&&typeof n.length!="number"&&(n=xt(n)),null==t||e?n?n[at(0,n.leng [...]
+},J.take=Bt,J.head=Bt,h(J,function(n,t){var e="sample"!==t;J.prototype[t]||(J.prototype[t]=function(t,r){var u=this.__chain__,o=n(this.__wrapped__,t,r);return u||null!=t&&(!r||e&&typeof t=="function")?new Q(o,u):o})}),J.VERSION="2.4.1",J.prototype.chain=function(){return this.__chain__=true,this},J.prototype.toString=function(){return oe(this.__wrapped__)},J.prototype.value=Qt,J.prototype.valueOf=Qt,St(["join","pop","shift"],function(n){var t=ae[n];J.prototype[n]=function(){var n=this.__ [...]
+return n?new Q(e,n):e}}),St(["push","reverse","sort","unshift"],function(n){var t=ae[n];J.prototype[n]=function(){return t.apply(this.__wrapped__,arguments),this}}),St(["concat","slice","splice"],function(n){var t=ae[n];J.prototype[n]=function(){return new Q(t.apply(this.__wrapped__,arguments),this.__chain__)}}),J}var v,h=[],g=[],y=0,m=+new Date+"",b=75,_=40,d=" \t\x0B\f\xa0\ufeff\n\r\u2028\u2029\u1680\u180e\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3 [...]
+K[B]=false,K[D]=K[$]=K[T]=K[F]=K[W]=K[q]=K[z]=K[P]=true;var L={leading:false,maxWait:0,trailing:false},M={configurable:false,enumerable:false,value:null,writable:false},V={"boolean":false,"function":true,object:true,number:false,string:false,undefined:false},U={"\\":"\\","'":"'","\n":"n","\r":"r","\t":"t","\u2028":"u2028","\u2029":"u2029"},G=V[typeof window]&&window||this,H=V[typeof exports]&&exports&&!exports.nodeType&&exports,J=V[typeof module]&&module&&!module.nodeType&&module,Q=J&&J. [...]
+var Y=s();typeof define=="function"&&typeof define.amd=="object"&&define.amd?(G._=Y, define(function(){return Y})):H&&J?Q?(J.exports=Y)._=Y:H._=Y:G._=Y}).call(this);
diff --git a/guacamole/src/main/webapp/lib/messageformat/LICENSE b/guacamole/src/main/webapp/lib/messageformat/LICENSE
new file mode 100644
index 0000000..ee7d6a5
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/messageformat/LICENSE
@@ -0,0 +1,14 @@
+            DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
+                    Version 2, December 2004
+
+ Copyright (C) 2004 Sam Hocevar <sam at hocevar.net>
+
+ Everyone is permitted to copy and distribute verbatim or modified
+ copies of this license document, and changing it is allowed as long
+ as the name is changed.
+
+            DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
+   TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
+
+  0. You just DO WHAT THE FUCK YOU WANT TO.
+
diff --git a/guacamole/src/main/webapp/lib/messageformat/locale/af.js b/guacamole/src/main/webapp/lib/messageformat/locale/af.js
new file mode 100644
index 0000000..b03849f
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/messageformat/locale/af.js
@@ -0,0 +1,6 @@
+MessageFormat.locale.af = function ( n ) {
+  if ( n === 1 ) {
+    return "one";
+  }
+  return "other";
+};
diff --git a/guacamole/src/main/webapp/lib/messageformat/locale/am.js b/guacamole/src/main/webapp/lib/messageformat/locale/am.js
new file mode 100644
index 0000000..aa8af1d
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/messageformat/locale/am.js
@@ -0,0 +1,6 @@
+MessageFormat.locale.am = function(n) {
+  if (n === 0 || n == 1) {
+    return 'one';
+  }
+  return 'other';
+};
diff --git a/guacamole/src/main/webapp/lib/messageformat/locale/ar.js b/guacamole/src/main/webapp/lib/messageformat/locale/ar.js
new file mode 100644
index 0000000..d33d95d
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/messageformat/locale/ar.js
@@ -0,0 +1,18 @@
+MessageFormat.locale.ar = function(n) {
+  if (n === 0) {
+    return 'zero';
+  }
+  if (n == 1) {
+    return 'one';
+  }
+  if (n == 2) {
+    return 'two';
+  }
+  if ((n % 100) >= 3 && (n % 100) <= 10 && n == Math.floor(n)) {
+    return 'few';
+  }
+  if ((n % 100) >= 11 && (n % 100) <= 99 && n == Math.floor(n)) {
+    return 'many';
+  }
+  return 'other';
+};
diff --git a/guacamole/src/main/webapp/lib/messageformat/locale/bg.js b/guacamole/src/main/webapp/lib/messageformat/locale/bg.js
new file mode 100644
index 0000000..868baea
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/messageformat/locale/bg.js
@@ -0,0 +1,6 @@
+MessageFormat.locale.bg = function ( n ) {
+  if ( n === 1 ) {
+    return "one";
+  }
+  return "other";
+};
diff --git a/guacamole/src/main/webapp/lib/messageformat/locale/bn.js b/guacamole/src/main/webapp/lib/messageformat/locale/bn.js
new file mode 100644
index 0000000..1641ff3
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/messageformat/locale/bn.js
@@ -0,0 +1,6 @@
+MessageFormat.locale.bn = function ( n ) {
+  if ( n === 1 ) {
+    return "one";
+  }
+  return "other";
+};
diff --git a/guacamole/src/main/webapp/lib/messageformat/locale/br.js b/guacamole/src/main/webapp/lib/messageformat/locale/br.js
new file mode 100644
index 0000000..2e0d43f
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/messageformat/locale/br.js
@@ -0,0 +1,18 @@
+MessageFormat.locale.br = function (n) {
+  if (n === 0) {
+    return 'zero';
+  }
+  if (n == 1) {
+    return 'one';
+  }
+  if (n == 2) {
+    return 'two';
+  }
+  if (n == 3) {
+    return 'few';
+  }
+  if (n == 6) {
+    return 'many';
+  }
+  return 'other';
+};
diff --git a/guacamole/src/main/webapp/lib/messageformat/locale/ca.js b/guacamole/src/main/webapp/lib/messageformat/locale/ca.js
new file mode 100644
index 0000000..e2a685c
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/messageformat/locale/ca.js
@@ -0,0 +1,6 @@
+MessageFormat.locale.ca = function ( n ) {
+  if ( n === 1 ) {
+    return "one";
+  }
+  return "other";
+};
diff --git a/guacamole/src/main/webapp/lib/messageformat/locale/cs.js b/guacamole/src/main/webapp/lib/messageformat/locale/cs.js
new file mode 100644
index 0000000..6a7f67e
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/messageformat/locale/cs.js
@@ -0,0 +1,9 @@
+MessageFormat.locale.cs = function (n) {
+  if (n == 1) {
+    return 'one';
+  }
+  if (n == 2 || n == 3 || n == 4) {
+    return 'few';
+  }
+  return 'other';
+};
diff --git a/guacamole/src/main/webapp/lib/messageformat/locale/cy.js b/guacamole/src/main/webapp/lib/messageformat/locale/cy.js
new file mode 100644
index 0000000..d98b1f4
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/messageformat/locale/cy.js
@@ -0,0 +1,18 @@
+MessageFormat.locale.cy = function (n) {
+  if (n === 0) {
+    return 'zero';
+  }
+  if (n == 1) {
+    return 'one';
+  }
+  if (n == 2) {
+    return 'two';
+  }
+  if (n == 3) {
+    return 'few';
+  }
+  if (n == 6) {
+    return 'many';
+  }
+  return 'other';
+};
diff --git a/guacamole/src/main/webapp/lib/messageformat/locale/da.js b/guacamole/src/main/webapp/lib/messageformat/locale/da.js
new file mode 100644
index 0000000..7ea5765
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/messageformat/locale/da.js
@@ -0,0 +1,6 @@
+MessageFormat.locale.da = function ( n ) {
+  if ( n === 1 ) {
+    return "one";
+  }
+  return "other";
+};
diff --git a/guacamole/src/main/webapp/lib/messageformat/locale/de.js b/guacamole/src/main/webapp/lib/messageformat/locale/de.js
new file mode 100644
index 0000000..edca71c
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/messageformat/locale/de.js
@@ -0,0 +1,6 @@
+MessageFormat.locale.de = function ( n ) {
+  if ( n === 1 ) {
+    return "one";
+  }
+  return "other";
+};
diff --git a/guacamole/src/main/webapp/lib/messageformat/locale/el.js b/guacamole/src/main/webapp/lib/messageformat/locale/el.js
new file mode 100644
index 0000000..8c5215a
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/messageformat/locale/el.js
@@ -0,0 +1,6 @@
+MessageFormat.locale.el = function ( n ) {
+  if ( n === 1 ) {
+    return "one";
+  }
+  return "other";
+};
diff --git a/guacamole/src/main/webapp/lib/messageformat/locale/en.js b/guacamole/src/main/webapp/lib/messageformat/locale/en.js
new file mode 100644
index 0000000..c2380b9
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/messageformat/locale/en.js
@@ -0,0 +1,6 @@
+MessageFormat.locale.en = function ( n ) {
+  if ( n === 1 ) {
+    return "one";
+  }
+  return "other";
+};
diff --git a/guacamole/src/main/webapp/lib/messageformat/locale/es.js b/guacamole/src/main/webapp/lib/messageformat/locale/es.js
new file mode 100644
index 0000000..4397d10
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/messageformat/locale/es.js
@@ -0,0 +1,6 @@
+MessageFormat.locale.es = function ( n ) {
+  if ( n === 1 ) {
+    return "one";
+  }
+  return "other";
+};
diff --git a/guacamole/src/main/webapp/lib/messageformat/locale/et.js b/guacamole/src/main/webapp/lib/messageformat/locale/et.js
new file mode 100644
index 0000000..d4b7f5a
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/messageformat/locale/et.js
@@ -0,0 +1,6 @@
+MessageFormat.locale.et = function ( n ) {
+  if ( n === 1 ) {
+    return "one";
+  }
+  return "other";
+};
diff --git a/guacamole/src/main/webapp/lib/messageformat/locale/eu.js b/guacamole/src/main/webapp/lib/messageformat/locale/eu.js
new file mode 100644
index 0000000..6da55df
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/messageformat/locale/eu.js
@@ -0,0 +1,6 @@
+MessageFormat.locale.eu = function ( n ) {
+  if ( n === 1 ) {
+    return "one";
+  }
+  return "other";
+};
diff --git a/guacamole/src/main/webapp/lib/messageformat/locale/fa.js b/guacamole/src/main/webapp/lib/messageformat/locale/fa.js
new file mode 100644
index 0000000..4280d1d
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/messageformat/locale/fa.js
@@ -0,0 +1,3 @@
+MessageFormat.locale.fa = function ( n ) {
+  return "other";
+};
diff --git a/guacamole/src/main/webapp/lib/messageformat/locale/fi.js b/guacamole/src/main/webapp/lib/messageformat/locale/fi.js
new file mode 100644
index 0000000..3315a84
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/messageformat/locale/fi.js
@@ -0,0 +1,6 @@
+MessageFormat.locale.fi = function ( n ) {
+  if ( n === 1 ) {
+    return "one";
+  }
+  return "other";
+};
diff --git a/guacamole/src/main/webapp/lib/messageformat/locale/fil.js b/guacamole/src/main/webapp/lib/messageformat/locale/fil.js
new file mode 100644
index 0000000..af882da
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/messageformat/locale/fil.js
@@ -0,0 +1,6 @@
+MessageFormat.locale.fil = function(n) {
+  if (n === 0 || n == 1) {
+    return 'one';
+  }
+  return 'other';
+};
diff --git a/guacamole/src/main/webapp/lib/messageformat/locale/fr.js b/guacamole/src/main/webapp/lib/messageformat/locale/fr.js
new file mode 100644
index 0000000..e562c3f
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/messageformat/locale/fr.js
@@ -0,0 +1,6 @@
+MessageFormat.locale.fr = function (n) {
+  if (n >= 0 && n < 2) {
+    return 'one';
+  }
+  return 'other';
+};
diff --git a/guacamole/src/main/webapp/lib/messageformat/locale/ga.js b/guacamole/src/main/webapp/lib/messageformat/locale/ga.js
new file mode 100644
index 0000000..c29aaad
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/messageformat/locale/ga.js
@@ -0,0 +1,9 @@
+MessageFormat.locale.ga = function (n) {
+  if (n == 1) {
+    return 'one';
+  }
+  if (n == 2) {
+    return 'two';
+  }
+  return 'other';
+};
diff --git a/guacamole/src/main/webapp/lib/messageformat/locale/gl.js b/guacamole/src/main/webapp/lib/messageformat/locale/gl.js
new file mode 100644
index 0000000..0d2a1b4
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/messageformat/locale/gl.js
@@ -0,0 +1,6 @@
+MessageFormat.locale.gl = function ( n ) {
+  if ( n === 1 ) {
+    return "one";
+  }
+  return "other";
+};
diff --git a/guacamole/src/main/webapp/lib/messageformat/locale/gsw.js b/guacamole/src/main/webapp/lib/messageformat/locale/gsw.js
new file mode 100644
index 0000000..9aae2bc
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/messageformat/locale/gsw.js
@@ -0,0 +1,6 @@
+MessageFormat.locale.gsw = function ( n ) {
+  if ( n === 1 ) {
+    return "one";
+  }
+  return "other";
+};
diff --git a/guacamole/src/main/webapp/lib/messageformat/locale/gu.js b/guacamole/src/main/webapp/lib/messageformat/locale/gu.js
new file mode 100644
index 0000000..70820dd
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/messageformat/locale/gu.js
@@ -0,0 +1,6 @@
+MessageFormat.locale.gu = function ( n ) {
+  if ( n === 1 ) {
+    return "one";
+  }
+  return "other";
+};
diff --git a/guacamole/src/main/webapp/lib/messageformat/locale/he.js b/guacamole/src/main/webapp/lib/messageformat/locale/he.js
new file mode 100644
index 0000000..bf828a5
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/messageformat/locale/he.js
@@ -0,0 +1,6 @@
+MessageFormat.locale.he = function ( n ) {
+  if ( n === 1 ) {
+    return "one";
+  }
+  return "other";
+};
diff --git a/guacamole/src/main/webapp/lib/messageformat/locale/hi.js b/guacamole/src/main/webapp/lib/messageformat/locale/hi.js
new file mode 100644
index 0000000..68fac22
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/messageformat/locale/hi.js
@@ -0,0 +1,6 @@
+MessageFormat.locale.hi = function(n) {
+  if (n === 0 || n == 1) {
+    return 'one';
+  }
+  return 'other';
+};
diff --git a/guacamole/src/main/webapp/lib/messageformat/locale/hr.js b/guacamole/src/main/webapp/lib/messageformat/locale/hr.js
new file mode 100644
index 0000000..668e28e
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/messageformat/locale/hr.js
@@ -0,0 +1,14 @@
+MessageFormat.locale.hr = function (n) {
+  if ((n % 10) == 1 && (n % 100) != 11) {
+    return 'one';
+  }
+  if ((n % 10) >= 2 && (n % 10) <= 4 &&
+      ((n % 100) < 12 || (n % 100) > 14) && n == Math.floor(n)) {
+    return 'few';
+  }
+  if ((n % 10) === 0 || ((n % 10) >= 5 && (n % 10) <= 9) ||
+      ((n % 100) >= 11 && (n % 100) <= 14) && n == Math.floor(n)) {
+    return 'many';
+  }
+  return 'other';
+};
diff --git a/guacamole/src/main/webapp/lib/messageformat/locale/hu.js b/guacamole/src/main/webapp/lib/messageformat/locale/hu.js
new file mode 100644
index 0000000..1fa3c21
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/messageformat/locale/hu.js
@@ -0,0 +1,3 @@
+MessageFormat.locale.hu = function(n) {
+  return 'other';
+};
diff --git a/guacamole/src/main/webapp/lib/messageformat/locale/id.js b/guacamole/src/main/webapp/lib/messageformat/locale/id.js
new file mode 100644
index 0000000..fb4b62b
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/messageformat/locale/id.js
@@ -0,0 +1,3 @@
+MessageFormat.locale.id = function(n) {
+  return 'other';
+};
diff --git a/guacamole/src/main/webapp/lib/messageformat/locale/in.js b/guacamole/src/main/webapp/lib/messageformat/locale/in.js
new file mode 100644
index 0000000..95abe00
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/messageformat/locale/in.js
@@ -0,0 +1,3 @@
+MessageFormat.locale["in"] = function(n) {
+  return 'other';
+};
diff --git a/guacamole/src/main/webapp/lib/messageformat/locale/is.js b/guacamole/src/main/webapp/lib/messageformat/locale/is.js
new file mode 100644
index 0000000..48efd8f
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/messageformat/locale/is.js
@@ -0,0 +1,6 @@
+MessageFormat.locale.is = function ( n ) {
+  if ( n === 1 ) {
+    return "one";
+  }
+  return "other";
+};
diff --git a/guacamole/src/main/webapp/lib/messageformat/locale/it.js b/guacamole/src/main/webapp/lib/messageformat/locale/it.js
new file mode 100644
index 0000000..be964cc
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/messageformat/locale/it.js
@@ -0,0 +1,6 @@
+MessageFormat.locale.it = function ( n ) {
+  if ( n === 1 ) {
+    return "one";
+  }
+  return "other";
+};
diff --git a/guacamole/src/main/webapp/lib/messageformat/locale/iw.js b/guacamole/src/main/webapp/lib/messageformat/locale/iw.js
new file mode 100644
index 0000000..a25fb2b
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/messageformat/locale/iw.js
@@ -0,0 +1,6 @@
+MessageFormat.locale.iw = function ( n ) {
+  if ( n === 1 ) {
+    return "one";
+  }
+  return "other";
+};
diff --git a/guacamole/src/main/webapp/lib/messageformat/locale/ja.js b/guacamole/src/main/webapp/lib/messageformat/locale/ja.js
new file mode 100644
index 0000000..a02267f
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/messageformat/locale/ja.js
@@ -0,0 +1,3 @@
+MessageFormat.locale.ja = function ( n ) {
+  return "other";
+};
diff --git a/guacamole/src/main/webapp/lib/messageformat/locale/kn.js b/guacamole/src/main/webapp/lib/messageformat/locale/kn.js
new file mode 100644
index 0000000..44c782d
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/messageformat/locale/kn.js
@@ -0,0 +1,3 @@
+MessageFormat.locale.kn = function ( n ) {
+  return "other";
+};
diff --git a/guacamole/src/main/webapp/lib/messageformat/locale/ko.js b/guacamole/src/main/webapp/lib/messageformat/locale/ko.js
new file mode 100644
index 0000000..899ffea
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/messageformat/locale/ko.js
@@ -0,0 +1,3 @@
+MessageFormat.locale.ko = function ( n ) {
+  return "other";
+};
diff --git a/guacamole/src/main/webapp/lib/messageformat/locale/lag.js b/guacamole/src/main/webapp/lib/messageformat/locale/lag.js
new file mode 100644
index 0000000..d4990b9
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/messageformat/locale/lag.js
@@ -0,0 +1,9 @@
+MessageFormat.locale.lag = function (n) {
+  if (n === 0) {
+    return 'zero';
+  }
+  if (n > 0 && n < 2) {
+    return 'one';
+  }
+  return 'other';
+};
diff --git a/guacamole/src/main/webapp/lib/messageformat/locale/ln.js b/guacamole/src/main/webapp/lib/messageformat/locale/ln.js
new file mode 100644
index 0000000..562e220
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/messageformat/locale/ln.js
@@ -0,0 +1,6 @@
+MessageFormat.locale.ln = function(n) {
+  if (n === 0 || n == 1) {
+    return 'one';
+  }
+  return 'other';
+};
diff --git a/guacamole/src/main/webapp/lib/messageformat/locale/lt.js b/guacamole/src/main/webapp/lib/messageformat/locale/lt.js
new file mode 100644
index 0000000..82878cf
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/messageformat/locale/lt.js
@@ -0,0 +1,10 @@
+MessageFormat.locale.lt = function (n) {
+  if ((n % 10) == 1 && ((n % 100) < 11 || (n % 100) > 19)) {
+    return 'one';
+  }
+  if ((n % 10) >= 2 && (n % 10) <= 9 &&
+      ((n % 100) < 11 || (n % 100) > 19) && n == Math.floor(n)) {
+    return 'few';
+  }
+  return 'other';
+};
diff --git a/guacamole/src/main/webapp/lib/messageformat/locale/lv.js b/guacamole/src/main/webapp/lib/messageformat/locale/lv.js
new file mode 100644
index 0000000..75beb34
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/messageformat/locale/lv.js
@@ -0,0 +1,9 @@
+MessageFormat.locale.lv = function (n) {
+  if (n === 0) {
+    return 'zero';
+  }
+  if ((n % 10) == 1 && (n % 100) != 11) {
+    return 'one';
+  }
+  return 'other';
+};
diff --git a/guacamole/src/main/webapp/lib/messageformat/locale/mk.js b/guacamole/src/main/webapp/lib/messageformat/locale/mk.js
new file mode 100644
index 0000000..c17aa2e
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/messageformat/locale/mk.js
@@ -0,0 +1,6 @@
+MessageFormat.locale.mk = function (n) {
+  if ((n % 10) == 1 && n != 11) {
+    return 'one';
+  }
+  return 'other';
+};
diff --git a/guacamole/src/main/webapp/lib/messageformat/locale/ml.js b/guacamole/src/main/webapp/lib/messageformat/locale/ml.js
new file mode 100644
index 0000000..f400a5f
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/messageformat/locale/ml.js
@@ -0,0 +1,6 @@
+MessageFormat.locale.ml = function ( n ) {
+  if ( n === 1 ) {
+    return "one";
+  }
+  return "other";
+};
diff --git a/guacamole/src/main/webapp/lib/messageformat/locale/mo.js b/guacamole/src/main/webapp/lib/messageformat/locale/mo.js
new file mode 100644
index 0000000..16d84d9
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/messageformat/locale/mo.js
@@ -0,0 +1,10 @@
+MessageFormat.locale.mo = function (n) {
+  if (n == 1) {
+    return 'one';
+  }
+  if (n === 0 || n != 1 && (n % 100) >= 1 &&
+      (n % 100) <= 19 && n == Math.floor(n)) {
+    return 'few';
+  }
+  return 'other';
+};
diff --git a/guacamole/src/main/webapp/lib/messageformat/locale/mr.js b/guacamole/src/main/webapp/lib/messageformat/locale/mr.js
new file mode 100644
index 0000000..da4d494
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/messageformat/locale/mr.js
@@ -0,0 +1,6 @@
+MessageFormat.locale.mr = function ( n ) {
+  if ( n === 1 ) {
+    return "one";
+  }
+  return "other";
+};
diff --git a/guacamole/src/main/webapp/lib/messageformat/locale/ms.js b/guacamole/src/main/webapp/lib/messageformat/locale/ms.js
new file mode 100644
index 0000000..e635ae7
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/messageformat/locale/ms.js
@@ -0,0 +1,3 @@
+MessageFormat.locale.ms = function ( n ) {
+  return "other";
+};
diff --git a/guacamole/src/main/webapp/lib/messageformat/locale/mt.js b/guacamole/src/main/webapp/lib/messageformat/locale/mt.js
new file mode 100644
index 0000000..6a071a7
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/messageformat/locale/mt.js
@@ -0,0 +1,12 @@
+MessageFormat.locale.mt = function (n) {
+  if (n == 1) {
+    return 'one';
+  }
+  if (n === 0 || ((n % 100) >= 2 && (n % 100) <= 4 && n == Math.floor(n))) {
+    return 'few';
+  }
+  if ((n % 100) >= 11 && (n % 100) <= 19 && n == Math.floor(n)) {
+    return 'many';
+  }
+  return 'other';
+};
diff --git a/guacamole/src/main/webapp/lib/messageformat/locale/nl.js b/guacamole/src/main/webapp/lib/messageformat/locale/nl.js
new file mode 100644
index 0000000..617e907
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/messageformat/locale/nl.js
@@ -0,0 +1,6 @@
+MessageFormat.locale.nl = function ( n ) {
+  if ( n === 1 ) {
+    return "one";
+  }
+  return "other";
+};
diff --git a/guacamole/src/main/webapp/lib/messageformat/locale/no.js b/guacamole/src/main/webapp/lib/messageformat/locale/no.js
new file mode 100644
index 0000000..025d348
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/messageformat/locale/no.js
@@ -0,0 +1,6 @@
+MessageFormat.locale.no = function ( n ) {
+  if ( n === 1 ) {
+    return "one";
+  }
+  return "other";
+};
diff --git a/guacamole/src/main/webapp/lib/messageformat/locale/or.js b/guacamole/src/main/webapp/lib/messageformat/locale/or.js
new file mode 100644
index 0000000..04240a1
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/messageformat/locale/or.js
@@ -0,0 +1,6 @@
+MessageFormat.locale.or = function ( n ) {
+  if ( n === 1 ) {
+    return "one";
+  }
+  return "other";
+};
diff --git a/guacamole/src/main/webapp/lib/messageformat/locale/pl.js b/guacamole/src/main/webapp/lib/messageformat/locale/pl.js
new file mode 100644
index 0000000..a9020d0
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/messageformat/locale/pl.js
@@ -0,0 +1,15 @@
+MessageFormat.locale.pl = function (n) {
+  if (n == 1) {
+    return 'one';
+  }
+  if ((n % 10) >= 2 && (n % 10) <= 4 &&
+      ((n % 100) < 12 || (n % 100) > 14) && n == Math.floor(n)) {
+    return 'few';
+  }
+  if ((n % 10) === 0 || n != 1 && (n % 10) == 1 ||
+      ((n % 10) >= 5 && (n % 10) <= 9 || (n % 100) >= 12 && (n % 100) <= 14) &&
+      n == Math.floor(n)) {
+    return 'many';
+  }
+  return 'other';
+};
diff --git a/guacamole/src/main/webapp/lib/messageformat/locale/pt.js b/guacamole/src/main/webapp/lib/messageformat/locale/pt.js
new file mode 100644
index 0000000..a4b65eb
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/messageformat/locale/pt.js
@@ -0,0 +1,6 @@
+MessageFormat.locale.pt = function ( n ) {
+  if ( n === 1 ) {
+    return "one";
+  }
+  return "other";
+};
diff --git a/guacamole/src/main/webapp/lib/messageformat/locale/ro.js b/guacamole/src/main/webapp/lib/messageformat/locale/ro.js
new file mode 100644
index 0000000..26453ea
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/messageformat/locale/ro.js
@@ -0,0 +1,10 @@
+MessageFormat.locale.ro = function (n) {
+  if (n == 1) {
+    return 'one';
+  }
+  if (n === 0 || n != 1 && (n % 100) >= 1 &&
+      (n % 100) <= 19 && n == Math.floor(n)) {
+    return 'few';
+  }
+  return 'other';
+};
diff --git a/guacamole/src/main/webapp/lib/messageformat/locale/ru.js b/guacamole/src/main/webapp/lib/messageformat/locale/ru.js
new file mode 100644
index 0000000..c34bd63
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/messageformat/locale/ru.js
@@ -0,0 +1,14 @@
+MessageFormat.locale.ru = function (n) {
+  if ((n % 10) == 1 && (n % 100) != 11) {
+    return 'one';
+  }
+  if ((n % 10) >= 2 && (n % 10) <= 4 &&
+      ((n % 100) < 12 || (n % 100) > 14) && n == Math.floor(n)) {
+    return 'few';
+  }
+  if ((n % 10) === 0 || ((n % 10) >= 5 && (n % 10) <= 9) ||
+      ((n % 100) >= 11 && (n % 100) <= 14) && n == Math.floor(n)) {
+    return 'many';
+  }
+  return 'other';
+};
diff --git a/guacamole/src/main/webapp/lib/messageformat/locale/shi.js b/guacamole/src/main/webapp/lib/messageformat/locale/shi.js
new file mode 100644
index 0000000..9e86dca
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/messageformat/locale/shi.js
@@ -0,0 +1,9 @@
+MessageFormat.locale.shi = function(n) {
+  if (n >= 0 && n <= 1) {
+    return 'one';
+  }
+  if (n >= 2 && n <= 10 && n == Math.floor(n)) {
+    return 'few';
+  }
+  return 'other';
+};
diff --git a/guacamole/src/main/webapp/lib/messageformat/locale/sk.js b/guacamole/src/main/webapp/lib/messageformat/locale/sk.js
new file mode 100644
index 0000000..ef041dd
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/messageformat/locale/sk.js
@@ -0,0 +1,9 @@
+MessageFormat.locale.sk = function (n) {
+  if (n == 1) {
+    return 'one';
+  }
+  if (n == 2 || n == 3 || n == 4) {
+    return 'few';
+  }
+  return 'other';
+};
diff --git a/guacamole/src/main/webapp/lib/messageformat/locale/sl.js b/guacamole/src/main/webapp/lib/messageformat/locale/sl.js
new file mode 100644
index 0000000..7dd591b
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/messageformat/locale/sl.js
@@ -0,0 +1,12 @@
+MessageFormat.locale.sl = function (n) {
+  if ((n % 100) == 1) {
+    return 'one';
+  }
+  if ((n % 100) == 2) {
+    return 'two';
+  }
+  if ((n % 100) == 3 || (n % 100) == 4) {
+    return 'few';
+  }
+  return 'other';
+};
diff --git a/guacamole/src/main/webapp/lib/messageformat/locale/sq.js b/guacamole/src/main/webapp/lib/messageformat/locale/sq.js
new file mode 100644
index 0000000..1e68389
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/messageformat/locale/sq.js
@@ -0,0 +1,6 @@
+MessageFormat.locale.sq = function ( n ) {
+  if ( n === 1 ) {
+    return "one";
+  }
+  return "other";
+};
diff --git a/guacamole/src/main/webapp/lib/messageformat/locale/sr.js b/guacamole/src/main/webapp/lib/messageformat/locale/sr.js
new file mode 100644
index 0000000..c2e3d3d
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/messageformat/locale/sr.js
@@ -0,0 +1,14 @@
+MessageFormat.locale.sr = function (n) {
+  if ((n % 10) == 1 && (n % 100) != 11) {
+    return 'one';
+  }
+  if ((n % 10) >= 2 && (n % 10) <= 4 &&
+      ((n % 100) < 12 || (n % 100) > 14) && n == Math.floor(n)) {
+    return 'few';
+  }
+  if ((n % 10) === 0 || ((n % 10) >= 5 && (n % 10) <= 9) ||
+      ((n % 100) >= 11 && (n % 100) <= 14) && n == Math.floor(n)) {
+    return 'many';
+  }
+  return 'other';
+};
diff --git a/guacamole/src/main/webapp/lib/messageformat/locale/sv.js b/guacamole/src/main/webapp/lib/messageformat/locale/sv.js
new file mode 100644
index 0000000..e566a33
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/messageformat/locale/sv.js
@@ -0,0 +1,6 @@
+MessageFormat.locale.sv = function ( n ) {
+  if ( n === 1 ) {
+    return "one";
+  }
+  return "other";
+};
diff --git a/guacamole/src/main/webapp/lib/messageformat/locale/sw.js b/guacamole/src/main/webapp/lib/messageformat/locale/sw.js
new file mode 100644
index 0000000..7dd56c1
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/messageformat/locale/sw.js
@@ -0,0 +1,6 @@
+MessageFormat.locale.sw = function ( n ) {
+  if ( n === 1 ) {
+    return "one";
+  }
+  return "other";
+};
diff --git a/guacamole/src/main/webapp/lib/messageformat/locale/ta.js b/guacamole/src/main/webapp/lib/messageformat/locale/ta.js
new file mode 100644
index 0000000..08a4ae0
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/messageformat/locale/ta.js
@@ -0,0 +1,6 @@
+MessageFormat.locale.ta = function ( n ) {
+  if ( n === 1 ) {
+    return "one";
+  }
+  return "other";
+};
diff --git a/guacamole/src/main/webapp/lib/messageformat/locale/te.js b/guacamole/src/main/webapp/lib/messageformat/locale/te.js
new file mode 100644
index 0000000..61ccb27
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/messageformat/locale/te.js
@@ -0,0 +1,6 @@
+MessageFormat.locale.te = function ( n ) {
+  if ( n === 1 ) {
+    return "one";
+  }
+  return "other";
+};
diff --git a/guacamole/src/main/webapp/lib/messageformat/locale/th.js b/guacamole/src/main/webapp/lib/messageformat/locale/th.js
new file mode 100644
index 0000000..9ef1708
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/messageformat/locale/th.js
@@ -0,0 +1,3 @@
+MessageFormat.locale.th = function ( n ) {
+  return "other";
+};
diff --git a/guacamole/src/main/webapp/lib/messageformat/locale/tl.js b/guacamole/src/main/webapp/lib/messageformat/locale/tl.js
new file mode 100644
index 0000000..bc68843
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/messageformat/locale/tl.js
@@ -0,0 +1,6 @@
+MessageFormat.locale.tl = function(n) {
+  if (n === 0 || n == 1) {
+    return 'one';
+  }
+  return 'other';
+};
diff --git a/guacamole/src/main/webapp/lib/messageformat/locale/tr.js b/guacamole/src/main/webapp/lib/messageformat/locale/tr.js
new file mode 100644
index 0000000..438941a
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/messageformat/locale/tr.js
@@ -0,0 +1,3 @@
+MessageFormat.locale.tr = function(n) {
+  return 'other';
+};
diff --git a/guacamole/src/main/webapp/lib/messageformat/locale/uk.js b/guacamole/src/main/webapp/lib/messageformat/locale/uk.js
new file mode 100644
index 0000000..aad90c7
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/messageformat/locale/uk.js
@@ -0,0 +1,14 @@
+MessageFormat.locale.uk = function (n) {
+  if ((n % 10) == 1 && (n % 100) != 11) {
+    return 'one';
+  }
+  if ((n % 10) >= 2 && (n % 10) <= 4 &&
+      ((n % 100) < 12 || (n % 100) > 14) && n == Math.floor(n)) {
+    return 'few';
+  }
+  if ((n % 10) === 0 || ((n % 10) >= 5 && (n % 10) <= 9) ||
+      ((n % 100) >= 11 && (n % 100) <= 14) && n == Math.floor(n)) {
+    return 'many';
+  }
+  return 'other';
+};
diff --git a/guacamole/src/main/webapp/lib/messageformat/locale/ur.js b/guacamole/src/main/webapp/lib/messageformat/locale/ur.js
new file mode 100644
index 0000000..5a875c9
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/messageformat/locale/ur.js
@@ -0,0 +1,6 @@
+MessageFormat.locale.ur = function ( n ) {
+  if ( n === 1 ) {
+    return "one";
+  }
+  return "other";
+};
diff --git a/guacamole/src/main/webapp/lib/messageformat/locale/vi.js b/guacamole/src/main/webapp/lib/messageformat/locale/vi.js
new file mode 100644
index 0000000..8a5b746
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/messageformat/locale/vi.js
@@ -0,0 +1,3 @@
+MessageFormat.locale.vi = function ( n ) {
+  return "other";
+};
diff --git a/guacamole/src/main/webapp/lib/messageformat/locale/zh.js b/guacamole/src/main/webapp/lib/messageformat/locale/zh.js
new file mode 100644
index 0000000..6ae270c
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/messageformat/locale/zh.js
@@ -0,0 +1,3 @@
+MessageFormat.locale.zh = function ( n ) {
+  return "other";
+};
diff --git a/guacamole/src/main/webapp/lib/messageformat/messageformat.js b/guacamole/src/main/webapp/lib/messageformat/messageformat.js
new file mode 100644
index 0000000..b8ba9e3
--- /dev/null
+++ b/guacamole/src/main/webapp/lib/messageformat/messageformat.js
@@ -0,0 +1,1593 @@
+/**
+ * messageformat.js
+ *
+ * ICU PluralFormat + SelectFormat for JavaScript
+ *
+ * @author Alex Sexton - @SlexAxton
+ * @version 0.1.7
+ * @license WTFPL
+ * @contributor_license Dojo CLA
+*/
+(function ( root ) {
+
+  // Create the contructor function
+  function MessageFormat ( locale, pluralFunc ) {
+    var fallbackLocale;
+
+    if ( locale && pluralFunc ) {
+      MessageFormat.locale[ locale ] = pluralFunc;
+    }
+
+    // Defaults
+    fallbackLocale = locale = locale || "en";
+    pluralFunc = pluralFunc || MessageFormat.locale[ fallbackLocale = MessageFormat.Utils.getFallbackLocale( locale ) ];
+
+    if ( ! pluralFunc ) {
+      throw new Error( "Plural Function not found for locale: " + locale );
+    }
+
+    // Own Properties
+    this.pluralFunc = pluralFunc;
+    this.locale = locale;
+    this.fallbackLocale = fallbackLocale;
+  }
+
+  // Set up the locales object. Add in english by default
+  MessageFormat.locale = {
+    "en" : function ( n ) {
+      if ( n === 1 ) {
+        return "one";
+      }
+      return "other";
+    }
+  };
+
+  // Build out our basic SafeString type
+  // more or less stolen from Handlebars by @wycats
+  MessageFormat.SafeString = function( string ) {
+    this.string = string;
+  };
+
+  MessageFormat.SafeString.prototype.toString = function () {
+    return this.string.toString();
+  };
+
+  MessageFormat.Utils = {
+    numSub : function ( string, key, depth ) {
+      // make sure that it's not an escaped octothorpe
+      return string.replace( /^#|[^\\]#/g, function (m) {
+        var prefix = m && m.length === 2 ? m.charAt(0) : '';
+        return prefix + '" + (function(){ var x = ' +
+        key+';\nif( isNaN(x) ){\nthrow new Error("MessageFormat: `"+lastkey_'+depth+'+"` isnt a number.");\n}\nreturn x;\n})() + "';
+      });
+    },
+    escapeExpression : function (string) {
+      var escape = {
+            "\n": "\\n",
+            "\"": '\\"'
+          },
+          badChars = /[\n"]/g,
+          possible = /[\n"]/,
+          escapeChar = function(chr) {
+            return escape[chr] || "&";
+          };
+
+      // Don't escape SafeStrings, since they're already safe
+      if ( string instanceof MessageFormat.SafeString ) {
+        return string.toString();
+      }
+      else if ( string === null || string === false ) {
+        return "";
+      }
+
+      if ( ! possible.test( string ) ) {
+        return string;
+      }
+      return string.replace( badChars, escapeChar );
+    },
+    getFallbackLocale: function( locale ) {
+      var tagSeparator = locale.indexOf("-") >= 0 ? "-" : "_";
+
+      // Lets just be friends, fallback through the language tags
+      while ( ! MessageFormat.locale.hasOwnProperty( locale ) ) {
+        locale = locale.substring(0, locale.lastIndexOf( tagSeparator ));
+        if (locale.length === 0) {
+          return null;
+        }
+      }
+
+      return locale;
+    }
+  };
+
+  // This is generated and pulled in for browsers.
+  var mparser = (function(){
+    /*
+     * Generated by PEG.js 0.7.0.
+     *
+     * http://pegjs.majda.cz/
+     */
+    
+    function quote(s) {
+      /*
+       * ECMA-262, 5th ed., 7.8.4: All characters may appear literally in a
+       * string literal except for the closing quote character, backslash,
+       * carriage return, line separator, paragraph separator, and line feed.
+       * Any character may appear in the form of an escape sequence.
+       *
+       * For portability, we also escape escape all control and non-ASCII
+       * characters. Note that "\0" and "\v" escape sequences are not used
+       * because JSHint does not like the first and IE the second.
+       */
+       return '"' + s
+        .replace(/\\/g, '\\\\')  // backslash
+        .replace(/"/g, '\\"')    // closing quote character
+        .replace(/\x08/g, '\\b') // backspace
+        .replace(/\t/g, '\\t')   // horizontal tab
+        .replace(/\n/g, '\\n')   // line feed
+        .replace(/\f/g, '\\f')   // form feed
+        .replace(/\r/g, '\\r')   // carriage return
+        .replace(/[\x00-\x07\x0B\x0E-\x1F\x80-\uFFFF]/g, escape)
+        + '"';
+    }
+    
+    var result = {
+      /*
+       * Parses the input with a generated parser. If the parsing is successfull,
+       * returns a value explicitly or implicitly specified by the grammar from
+       * which the parser was generated (see |PEG.buildParser|). If the parsing is
+       * unsuccessful, throws |PEG.parser.SyntaxError| describing the error.
+       */
+      parse: function(input, startRule) {
+        var parseFunctions = {
+          "start": parse_start,
+          "messageFormatPattern": parse_messageFormatPattern,
+          "messageFormatPatternRight": parse_messageFormatPatternRight,
+          "messageFormatElement": parse_messageFormatElement,
+          "elementFormat": parse_elementFormat,
+          "pluralStyle": parse_pluralStyle,
+          "selectStyle": parse_selectStyle,
+          "pluralFormatPattern": parse_pluralFormatPattern,
+          "offsetPattern": parse_offsetPattern,
+          "selectFormatPattern": parse_selectFormatPattern,
+          "pluralForms": parse_pluralForms,
+          "stringKey": parse_stringKey,
+          "string": parse_string,
+          "id": parse_id,
+          "chars": parse_chars,
+          "char": parse_char,
+          "digits": parse_digits,
+          "hexDigit": parse_hexDigit,
+          "_": parse__,
+          "whitespace": parse_whitespace
+        };
+        
+        if (startRule !== undefined) {
+          if (parseFunctions[startRule] === undefined) {
+            throw new Error("Invalid rule name: " + quote(startRule) + ".");
+          }
+        } else {
+          startRule = "start";
+        }
+        
+        var pos = 0;
+        var reportFailures = 0;
+        var rightmostFailuresPos = 0;
+        var rightmostFailuresExpected = [];
+        
+        function padLeft(input, padding, length) {
+          var result = input;
+          
+          var padLength = length - input.length;
+          for (var i = 0; i < padLength; i++) {
+            result = padding + result;
+          }
+          
+          return result;
+        }
+        
+        function escape(ch) {
+          var charCode = ch.charCodeAt(0);
+          var escapeChar;
+          var length;
+          
+          if (charCode <= 0xFF) {
+            escapeChar = 'x';
+            length = 2;
+          } else {
+            escapeChar = 'u';
+            length = 4;
+          }
+          
+          return '\\' + escapeChar + padLeft(charCode.toString(16).toUpperCase(), '0', length);
+        }
+        
+        function matchFailed(failure) {
+          if (pos < rightmostFailuresPos) {
+            return;
+          }
+          
+          if (pos > rightmostFailuresPos) {
+            rightmostFailuresPos = pos;
+            rightmostFailuresExpected = [];
+          }
+          
+          rightmostFailuresExpected.push(failure);
+        }
+        
+        function parse_start() {
+          var result0;
+          var pos0;
+          
+          pos0 = pos;
+          result0 = parse_messageFormatPattern();
+          if (result0 !== null) {
+            result0 = (function(offset, messageFormatPattern) { return { type: "program", program: messageFormatPattern }; })(pos0, result0);
+          }
+          if (result0 === null) {
+            pos = pos0;
+          }
+          return result0;
+        }
+        
+        function parse_messageFormatPattern() {
+          var result0, result1, result2;
+          var pos0, pos1;
+          
+          pos0 = pos;
+          pos1 = pos;
+          result0 = parse_string();
+          if (result0 !== null) {
+            result1 = [];
+            result2 = parse_messageFormatPatternRight();
+            while (result2 !== null) {
+              result1.push(result2);
+              result2 = parse_messageFormatPatternRight();
+            }
+            if (result1 !== null) {
+              result0 = [result0, result1];
+            } else {
+              result0 = null;
+              pos = pos1;
+            }
+          } else {
+            result0 = null;
+            pos = pos1;
+          }
+          if (result0 !== null) {
+            result0 = (function(offset, s1, inner) {
+              var st = [];
+              if ( s1 && s1.val ) {
+                st.push( s1 );
+              }
+              for( var i in inner ){
+                if ( inner.hasOwnProperty( i ) ) {
+                  st.push( inner[ i ] );
+                }
+              }
+              return { type: 'messageFormatPattern', statements: st };
+            })(pos0, result0[0], result0[1]);
+          }
+          if (result0 === null) {
+            pos = pos0;
+          }
+          return result0;
+        }
+        
+        function parse_messageFormatPatternRight() {
+          var result0, result1, result2, result3, result4, result5;
+          var pos0, pos1;
+          
+          pos0 = pos;
+          pos1 = pos;
+          if (input.charCodeAt(pos) === 123) {
+            result0 = "{";
+            pos++;
+          } else {
+            result0 = null;
+            if (reportFailures === 0) {
+              matchFailed("\"{\"");
+            }
+          }
+          if (result0 !== null) {
+            result1 = parse__();
+            if (result1 !== null) {
+              result2 = parse_messageFormatElement();
+              if (result2 !== null) {
+                result3 = parse__();
+                if (result3 !== null) {
+                  if (input.charCodeAt(pos) === 125) {
+                    result4 = "}";
+                    pos++;
+                  } else {
+                    result4 = null;
+                    if (reportFailures === 0) {
+                      matchFailed("\"}\"");
+                    }
+                  }
+                  if (result4 !== null) {
+                    result5 = parse_string();
+                    if (result5 !== null) {
+                      result0 = [result0, result1, result2, result3, result4, result5];
+                    } else {
+                      result0 = null;
+                      pos = pos1;
+                    }
+                  } else {
+                    result0 = null;
+                    pos = pos1;
+                  }
+                } else {
+                  result0 = null;
+                  pos = pos1;
+                }
+              } else {
+                result0 = null;
+                pos = pos1;
+              }
+            } else {
+              result0 = null;
+              pos = pos1;
+            }
+          } else {
+            result0 = null;
+            pos = pos1;
+          }
+          if (result0 !== null) {
+            result0 = (function(offset, mfe, s1) {
+              var res = [];
+              if ( mfe ) {
+                res.push(mfe);
+              }
+              if ( s1 && s1.val ) {
+                res.push( s1 );
+              }
+              return { type: "messageFormatPatternRight", statements : res };
+            })(pos0, result0[2], result0[5]);
+          }
+          if (result0 === null) {
+            pos = pos0;
+          }
+          return result0;
+        }
+        
+        function parse_messageFormatElement() {
+          var result0, result1, result2;
+          var pos0, pos1, pos2;
+          
+          pos0 = pos;
+          pos1 = pos;
+          result0 = parse_id();
+          if (result0 !== null) {
+            pos2 = pos;
+            if (input.charCodeAt(pos) === 44) {
+              result1 = ",";
+              pos++;
+            } else {
+              result1 = null;
+              if (reportFailures === 0) {
+                matchFailed("\",\"");
+              }
+            }
+            if (result1 !== null) {
+              result2 = parse_elementFormat();
+              if (result2 !== null) {
+                result1 = [result1, result2];
+              } else {
+                result1 = null;
+                pos = pos2;
+              }
+            } else {
+              result1 = null;
+              pos = pos2;
+            }
+            result1 = result1 !== null ? result1 : "";
+            if (result1 !== null) {
+              result0 = [result0, result1];
+            } else {
+              result0 = null;
+              pos = pos1;
+            }
+          } else {
+            result0 = null;
+            pos = pos1;
+          }
+          if (result0 !== null) {
+            result0 = (function(offset, argIdx, efmt) {
+              var res = { 
+                type: "messageFormatElement",
+                argumentIndex: argIdx
+              };
+              if ( efmt && efmt.length ) {
+                res.elementFormat = efmt[1];
+              }
+              else {
+                res.output = true;
+              }
+              return res;
+            })(pos0, result0[0], result0[1]);
+          }
+          if (result0 === null) {
+            pos = pos0;
+          }
+          return result0;
+        }
+        
+        function parse_elementFormat() {
+          var result0, result1, result2, result3, result4, result5, result6;
+          var pos0, pos1;
+          
+          pos0 = pos;
+          pos1 = pos;
+          result0 = parse__();
+          if (result0 !== null) {
+            if (input.substr(pos, 6) === "plural") {
+              result1 = "plural";
+              pos += 6;
+            } else {
+              result1 = null;
+              if (reportFailures === 0) {
+                matchFailed("\"plural\"");
+              }
+            }
+            if (result1 !== null) {
+              result2 = parse__();
+              if (result2 !== null) {
+                if (input.charCodeAt(pos) === 44) {
+                  result3 = ",";
+                  pos++;
+                } else {
+                  result3 = null;
+                  if (reportFailures === 0) {
+                    matchFailed("\",\"");
+                  }
+                }
+                if (result3 !== null) {
+                  result4 = parse__();
+                  if (result4 !== null) {
+                    result5 = parse_pluralStyle();
+                    if (result5 !== null) {
+                      result6 = parse__();
+                      if (result6 !== null) {
+                        result0 = [result0, result1, result2, result3, result4, result5, result6];
+                      } else {
+                        result0 = null;
+                        pos = pos1;
+                      }
+                    } else {
+                      result0 = null;
+                      pos = pos1;
+                    }
+                  } else {
+                    result0 = null;
+                    pos = pos1;
+                  }
+                } else {
+                  result0 = null;
+                  pos = pos1;
+                }
+              } else {
+                result0 = null;
+                pos = pos1;
+              }
+            } else {
+              result0 = null;
+              pos = pos1;
+            }
+          } else {
+            result0 = null;
+            pos = pos1;
+          }
+          if (result0 !== null) {
+            result0 = (function(offset, t, s) {
+              return {
+                type : "elementFormat",
+                key  : t,
+                val  : s.val
+              };
+            })(pos0, result0[1], result0[5]);
+          }
+          if (result0 === null) {
+            pos = pos0;
+          }
+          if (result0 === null) {
+            pos0 = pos;
+            pos1 = pos;
+            result0 = parse__();
+            if (result0 !== null) {
+              if (input.substr(pos, 6) === "select") {
+                result1 = "select";
+                pos += 6;
+              } else {
+                result1 = null;
+                if (reportFailures === 0) {
+                  matchFailed("\"select\"");
+                }
+              }
+              if (result1 !== null) {
+                result2 = parse__();
+                if (result2 !== null) {
+                  if (input.charCodeAt(pos) === 44) {
+                    result3 = ",";
+                    pos++;
+                  } else {
+                    result3 = null;
+                    if (reportFailures === 0) {
+                      matchFailed("\",\"");
+                    }
+                  }
+                  if (result3 !== null) {
+                    result4 = parse__();
+                    if (result4 !== null) {
+                      result5 = parse_selectStyle();
+                      if (result5 !== null) {
+                        result6 = parse__();
+                        if (result6 !== null) {
+                          result0 = [result0, result1, result2, result3, result4, result5, result6];
+                        } else {
+                          result0 = null;
+                          pos = pos1;
+                        }
+                      } else {
+                        result0 = null;
+                        pos = pos1;
+                      }
+                    } else {
+                      result0 = null;
+                      pos = pos1;
+                    }
+                  } else {
+                    result0 = null;
+                    pos = pos1;
+                  }
+                } else {
+                  result0 = null;
+                  pos = pos1;
+                }
+              } else {
+                result0 = null;
+                pos = pos1;
+              }
+            } else {
+              result0 = null;
+              pos = pos1;
+            }
+            if (result0 !== null) {
+              result0 = (function(offset, t, s) {
+                return {
+                  type : "elementFormat",
+                  key  : t,
+                  val  : s.val
+                };
+              })(pos0, result0[1], result0[5]);
+            }
+            if (result0 === null) {
+              pos = pos0;
+            }
+          }
+          return result0;
+        }
+        
+        function parse_pluralStyle() {
+          var result0;
+          var pos0;
+          
+          pos0 = pos;
+          result0 = parse_pluralFormatPattern();
+          if (result0 !== null) {
+            result0 = (function(offset, pfp) {
+              return { type: "pluralStyle", val: pfp };
+            })(pos0, result0);
+          }
+          if (result0 === null) {
+            pos = pos0;
+          }
+          return result0;
+        }
+        
+        function parse_selectStyle() {
+          var result0;
+          var pos0;
+          
+          pos0 = pos;
+          result0 = parse_selectFormatPattern();
+          if (result0 !== null) {
+            result0 = (function(offset, sfp) {
+              return { type: "selectStyle", val: sfp };
+            })(pos0, result0);
+          }
+          if (result0 === null) {
+            pos = pos0;
+          }
+          return result0;
+        }
+        
+        function parse_pluralFormatPattern() {
+          var result0, result1, result2;
+          var pos0, pos1;
+          
+          pos0 = pos;
+          pos1 = pos;
+          result0 = parse_offsetPattern();
+          result0 = result0 !== null ? result0 : "";
+          if (result0 !== null) {
+            result1 = [];
+            result2 = parse_pluralForms();
+            while (result2 !== null) {
+              result1.push(result2);
+              result2 = parse_pluralForms();
+            }
+            if (result1 !== null) {
+              result0 = [result0, result1];
+            } else {
+              result0 = null;
+              pos = pos1;
+            }
+          } else {
+            result0 = null;
+            pos = pos1;
+          }
+          if (result0 !== null) {
+            result0 = (function(offset, op, pf) {
+              var res = {
+                type: "pluralFormatPattern",
+                pluralForms: pf
+              };
+              if ( op ) {
+                res.offset = op;
+              }
+              else {
+                res.offset = 0;
+              }
+              return res;
+            })(pos0, result0[0], result0[1]);
+          }
+          if (result0 === null) {
+            pos = pos0;
+          }
+          return result0;
+        }
+        
+        function parse_offsetPattern() {
+          var result0, result1, result2, result3, result4, result5, result6;
+          var pos0, pos1;
+          
+          pos0 = pos;
+          pos1 = pos;
+          result0 = parse__();
+          if (result0 !== null) {
+            if (input.substr(pos, 6) === "offset") {
+              result1 = "offset";
+              pos += 6;
+            } else {
+              result1 = null;
+              if (reportFailures === 0) {
+                matchFailed("\"offset\"");
+              }
+            }
+            if (result1 !== null) {
+              result2 = parse__();
+              if (result2 !== null) {
+                if (input.charCodeAt(pos) === 58) {
+                  result3 = ":";
+                  pos++;
+                } else {
+                  result3 = null;
+                  if (reportFailures === 0) {
+                    matchFailed("\":\"");
+                  }
+                }
+                if (result3 !== null) {
+                  result4 = parse__();
+                  if (result4 !== null) {
+                    result5 = parse_digits();
+                    if (result5 !== null) {
+                      result6 = parse__();
+                      if (result6 !== null) {
+                        result0 = [result0, result1, result2, result3, result4, result5, result6];
+                      } else {
+                        result0 = null;
+                        pos = pos1;
+                      }
+                    } else {
+                      result0 = null;
+                      pos = pos1;
+                    }
+                  } else {
+                    result0 = null;
+                    pos = pos1;
+                  }
+                } else {
+                  result0 = null;
+                  pos = pos1;
+                }
+              } else {
+                result0 = null;
+                pos = pos1;
+              }
+            } else {
+              result0 = null;
+              pos = pos1;
+            }
+          } else {
+            result0 = null;
+            pos = pos1;
+          }
+          if (result0 !== null) {
+            result0 = (function(offset, d) {
+              return d;
+            })(pos0, result0[5]);
+          }
+          if (result0 === null) {
+            pos = pos0;
+          }
+          return result0;
+        }
+        
+        function parse_selectFormatPattern() {
+          var result0, result1;
+          var pos0;
+          
+          pos0 = pos;
+          result0 = [];
+          result1 = parse_pluralForms();
+          while (result1 !== null) {
+            result0.push(result1);
+            result1 = parse_pluralForms();
+          }
+          if (result0 !== null) {
+            result0 = (function(offset, pf) {
+              return {
+                type: "selectFormatPattern",
+                pluralForms: pf
+              };
+            })(pos0, result0);
+          }
+          if (result0 === null) {
+            pos = pos0;
+          }
+          return result0;
+        }
+        
+        function parse_pluralForms() {
+          var result0, result1, result2, result3, result4, result5, result6, result7;
+          var pos0, pos1;
+          
+          pos0 = pos;
+          pos1 = pos;
+          result0 = parse__();
+          if (result0 !== null) {
+            result1 = parse_stringKey();
+            if (result1 !== null) {
+              result2 = parse__();
+              if (result2 !== null) {
+                if (input.charCodeAt(pos) === 123) {
+                  result3 = "{";
+                  pos++;
+                } else {
+                  result3 = null;
+                  if (reportFailures === 0) {
+                    matchFailed("\"{\"");
+                  }
+                }
+                if (result3 !== null) {
+                  result4 = parse__();
+                  if (result4 !== null) {
+                    result5 = parse_messageFormatPattern();
+                    if (result5 !== null) {
+                      result6 = parse__();
+                      if (result6 !== null) {
+                        if (input.charCodeAt(pos) === 125) {
+                          result7 = "}";
+                          pos++;
+                        } else {
+                          result7 = null;
+                          if (reportFailures === 0) {
+                            matchFailed("\"}\"");
+                          }
+                        }
+                        if (result7 !== null) {
+                          result0 = [result0, result1, result2, result3, result4, result5, result6, result7];
+                        } else {
+                          result0 = null;
+                          pos = pos1;
+                        }
+                      } else {
+                        result0 = null;
+                        pos = pos1;
+                      }
+                    } else {
+                      result0 = null;
+                      pos = pos1;
+                    }
+                  } else {
+                    result0 = null;
+                    pos = pos1;
+                  }
+                } else {
+                  result0 = null;
+                  pos = pos1;
+                }
+              } else {
+                result0 = null;
+                pos = pos1;
+              }
+            } else {
+              result0 = null;
+              pos = pos1;
+            }
+          } else {
+            result0 = null;
+            pos = pos1;
+          }
+          if (result0 !== null) {
+            result0 = (function(offset, k, mfp) {
+              return {
+                type: "pluralForms",
+                key: k,
+                val: mfp
+              };
+            })(pos0, result0[1], result0[5]);
+          }
+          if (result0 === null) {
+            pos = pos0;
+          }
+          return result0;
+        }
+        
+        function parse_stringKey() {
+          var result0, result1;
+          var pos0, pos1;
+          
+          pos0 = pos;
+          result0 = parse_id();
+          if (result0 !== null) {
+            result0 = (function(offset, i) {
+              return i;
+            })(pos0, result0);
+          }
+          if (result0 === null) {
+            pos = pos0;
+          }
+          if (result0 === null) {
+            pos0 = pos;
+            pos1 = pos;
+            if (input.charCodeAt(pos) === 61) {
+              result0 = "=";
+              pos++;
+            } else {
+              result0 = null;
+              if (reportFailures === 0) {
+                matchFailed("\"=\"");
+              }
+            }
+            if (result0 !== null) {
+              result1 = parse_digits();
+              if (result1 !== null) {
+                result0 = [result0, result1];
+              } else {
+                result0 = null;
+                pos = pos1;
+              }
+            } else {
+              result0 = null;
+              pos = pos1;
+            }
+            if (result0 !== null) {
+              result0 = (function(offset, d) {
+                return d;
+              })(pos0, result0[1]);
+            }
+            if (result0 === null) {
+              pos = pos0;
+            }
+          }
+          return result0;
+        }
+        
+        function parse_string() {
+          var result0, result1, result2, result3, result4;
+          var pos0, pos1, pos2;
+          
+          pos0 = pos;
+          pos1 = pos;
+          result0 = parse__();
+          if (result0 !== null) {
+            result1 = [];
+            pos2 = pos;
+            result2 = parse__();
+            if (result2 !== null) {
+              result3 = parse_chars();
+              if (result3 !== null) {
+                result4 = parse__();
+                if (result4 !== null) {
+                  result2 = [result2, result3, result4];
+                } else {
+                  result2 = null;
+                  pos = pos2;
+                }
+              } else {
+                result2 = null;
+                pos = pos2;
+              }
+            } else {
+              result2 = null;
+              pos = pos2;
+            }
+            while (result2 !== null) {
+              result1.push(result2);
+              pos2 = pos;
+              result2 = parse__();
+              if (result2 !== null) {
+                result3 = parse_chars();
+                if (result3 !== null) {
+                  result4 = parse__();
+                  if (result4 !== null) {
+                    result2 = [result2, result3, result4];
+                  } else {
+                    result2 = null;
+                    pos = pos2;
+                  }
+                } else {
+                  result2 = null;
+                  pos = pos2;
+                }
+              } else {
+                result2 = null;
+                pos = pos2;
+              }
+            }
+            if (result1 !== null) {
+              result0 = [result0, result1];
+            } else {
+              result0 = null;
+              pos = pos1;
+            }
+          } else {
+            result0 = null;
+            pos = pos1;
+          }
+          if (result0 !== null) {
+            result0 = (function(offset, ws, s) {
+              var tmp = [];
+              for( var i = 0; i < s.length; ++i ) {
+                for( var j = 0; j < s[ i ].length; ++j ) {
+                  tmp.push(s[i][j]);
+                }
+              }
+              return {
+                type: "string",
+                val: ws + tmp.join('')
+              };
+            })(pos0, result0[0], result0[1]);
+          }
+          if (result0 === null) {
+            pos = pos0;
+          }
+          return result0;
+        }
+        
+        function parse_id() {
+          var result0, result1, result2, result3;
+          var pos0, pos1;
+          
+          pos0 = pos;
+          pos1 = pos;
+          result0 = parse__();
+          if (result0 !== null) {
+            if (/^[a-zA-Z$_]/.test(input.charAt(pos))) {
+              result1 = input.charAt(pos);
+              pos++;
+            } else {
+              result1 = null;
+              if (reportFailures === 0) {
+                matchFailed("[a-zA-Z$_]");
+              }
+            }
+            if (result1 !== null) {
+              result2 = [];
+              if (/^[^ \t\n\r,.+={}]/.test(input.charAt(pos))) {
+                result3 = input.charAt(pos);
+                pos++;
+              } else {
+                result3 = null;
+                if (reportFailures === 0) {
+                  matchFailed("[^ \\t\\n\\r,.+={}]");
+                }
+              }
+              while (result3 !== null) {
+                result2.push(result3);
+                if (/^[^ \t\n\r,.+={}]/.test(input.charAt(pos))) {
+                  result3 = input.charAt(pos);
+                  pos++;
+                } else {
+                  result3 = null;
+                  if (reportFailures === 0) {
+                    matchFailed("[^ \\t\\n\\r,.+={}]");
+                  }
+                }
+              }
+              if (result2 !== null) {
+                result3 = parse__();
+                if (result3 !== null) {
+                  result0 = [result0, result1, result2, result3];
+                } else {
+                  result0 = null;
+                  pos = pos1;
+                }
+              } else {
+                result0 = null;
+                pos = pos1;
+              }
+            } else {
+              result0 = null;
+              pos = pos1;
+            }
+          } else {
+            result0 = null;
+            pos = pos1;
+          }
+          if (result0 !== null) {
+            result0 = (function(offset, s1, s2) {
+              return s1 + (s2 ? s2.join('') : '');
+            })(pos0, result0[1], result0[2]);
+          }
+          if (result0 === null) {
+            pos = pos0;
+          }
+          return result0;
+        }
+        
+        function parse_chars() {
+          var result0, result1;
+          var pos0;
+          
+          pos0 = pos;
+          result1 = parse_char();
+          if (result1 !== null) {
+            result0 = [];
+            while (result1 !== null) {
+              result0.push(result1);
+              result1 = parse_char();
+            }
+          } else {
+            result0 = null;
+          }
+          if (result0 !== null) {
+            result0 = (function(offset, chars) { return chars.join(''); })(pos0, result0);
+          }
+          if (result0 === null) {
+            pos = pos0;
+          }
+          return result0;
+        }
+        
+        function parse_char() {
+          var result0, result1, result2, result3, result4;
+          var pos0, pos1;
+          
+          pos0 = pos;
+          if (/^[^{}\\\0-\x1F \t\n\r]/.test(input.charAt(pos))) {
+            result0 = input.charAt(pos);
+            pos++;
+          } else {
+            result0 = null;
+            if (reportFailures === 0) {
+              matchFailed("[^{}\\\\\\0-\\x1F \\t\\n\\r]");
+            }
+          }
+          if (result0 !== null) {
+            result0 = (function(offset, x) {
+              return x;
+            })(pos0, result0);
+          }
+          if (result0 === null) {
+            pos = pos0;
+          }
+          if (result0 === null) {
+            pos0 = pos;
+            if (input.substr(pos, 2) === "\\#") {
+              result0 = "\\#";
+              pos += 2;
+            } else {
+              result0 = null;
+              if (reportFailures === 0) {
+                matchFailed("\"\\\\#\"");
+              }
+            }
+            if (result0 !== null) {
+              result0 = (function(offset) {
+                return "\\#";
+              })(pos0);
+            }
+            if (result0 === null) {
+              pos = pos0;
+            }
+            if (result0 === null) {
+              pos0 = pos;
+              if (input.substr(pos, 2) === "\\{") {
+                result0 = "\\{";
+                pos += 2;
+              } else {
+                result0 = null;
+                if (reportFailures === 0) {
+                  matchFailed("\"\\\\{\"");
+                }
+              }
+              if (result0 !== null) {
+                result0 = (function(offset) {
+                  return "\u007B";
+                })(pos0);
+              }
+              if (result0 === null) {
+                pos = pos0;
+              }
+              if (result0 === null) {
+                pos0 = pos;
+                if (input.substr(pos, 2) === "\\}") {
+                  result0 = "\\}";
+                  pos += 2;
+                } else {
+                  result0 = null;
+                  if (reportFailures === 0) {
+                    matchFailed("\"\\\\}\"");
+                  }
+                }
+                if (result0 !== null) {
+                  result0 = (function(offset) {
+                    return "\u007D";
+                  })(pos0);
+                }
+                if (result0 === null) {
+                  pos = pos0;
+                }
+                if (result0 === null) {
+                  pos0 = pos;
+                  pos1 = pos;
+                  if (input.substr(pos, 2) === "\\u") {
+                    result0 = "\\u";
+                    pos += 2;
+                  } else {
+                    result0 = null;
+                    if (reportFailures === 0) {
+                      matchFailed("\"\\\\u\"");
+                    }
+                  }
+                  if (result0 !== null) {
+                    result1 = parse_hexDigit();
+                    if (result1 !== null) {
+                      result2 = parse_hexDigit();
+                      if (result2 !== null) {
+                        result3 = parse_hexDigit();
+                        if (result3 !== null) {
+                          result4 = parse_hexDigit();
+                          if (result4 !== null) {
+                            result0 = [result0, result1, result2, result3, result4];
+                          } else {
+                            result0 = null;
+                            pos = pos1;
+                          }
+                        } else {
+                          result0 = null;
+                          pos = pos1;
+                        }
+                      } else {
+                        result0 = null;
+                        pos = pos1;
+                      }
+                    } else {
+                      result0 = null;
+                      pos = pos1;
+                    }
+                  } else {
+                    result0 = null;
+                    pos = pos1;
+                  }
+                  if (result0 !== null) {
+                    result0 = (function(offset, h1, h2, h3, h4) {
+                        return String.fromCharCode(parseInt("0x" + h1 + h2 + h3 + h4));
+                    })(pos0, result0[1], result0[2], result0[3], result0[4]);
+                  }
+                  if (result0 === null) {
+                    pos = pos0;
+                  }
+                }
+              }
+            }
+          }
+          return result0;
+        }
+        
+        function parse_digits() {
+          var result0, result1;
+          var pos0;
+          
+          pos0 = pos;
+          if (/^[0-9]/.test(input.charAt(pos))) {
+            result1 = input.charAt(pos);
+            pos++;
+          } else {
+            result1 = null;
+            if (reportFailures === 0) {
+              matchFailed("[0-9]");
+            }
+          }
+          if (result1 !== null) {
+            result0 = [];
+            while (result1 !== null) {
+              result0.push(result1);
+              if (/^[0-9]/.test(input.charAt(pos))) {
+                result1 = input.charAt(pos);
+                pos++;
+              } else {
+                result1 = null;
+                if (reportFailures === 0) {
+                  matchFailed("[0-9]");
+                }
+              }
+            }
+          } else {
+            result0 = null;
+          }
+          if (result0 !== null) {
+            result0 = (function(offset, ds) {
+              return parseInt((ds.join('')), 10);
+            })(pos0, result0);
+          }
+          if (result0 === null) {
+            pos = pos0;
+          }
+          return result0;
+        }
+        
+        function parse_hexDigit() {
+          var result0;
+          
+          if (/^[0-9a-fA-F]/.test(input.charAt(pos))) {
+            result0 = input.charAt(pos);
+            pos++;
+          } else {
+            result0 = null;
+            if (reportFailures === 0) {
+              matchFailed("[0-9a-fA-F]");
+            }
+          }
+          return result0;
+        }
+        
+        function parse__() {
+          var result0, result1;
+          var pos0;
+          
+          reportFailures++;
+          pos0 = pos;
+          result0 = [];
+          result1 = parse_whitespace();
+          while (result1 !== null) {
+            result0.push(result1);
+            result1 = parse_whitespace();
+          }
+          if (result0 !== null) {
+            result0 = (function(offset, w) { return w.join(''); })(pos0, result0);
+          }
+          if (result0 === null) {
+            pos = pos0;
+          }
+          reportFailures--;
+          if (reportFailures === 0 && result0 === null) {
+            matchFailed("whitespace");
+          }
+          return result0;
+        }
+        
+        function parse_whitespace() {
+          var result0;
+          
+          if (/^[ \t\n\r]/.test(input.charAt(pos))) {
+            result0 = input.charAt(pos);
+            pos++;
+          } else {
+            result0 = null;
+            if (reportFailures === 0) {
+              matchFailed("[ \\t\\n\\r]");
+            }
+          }
+          return result0;
+        }
+        
+        
+        function cleanupExpected(expected) {
+          expected.sort();
+          
+          var lastExpected = null;
+          var cleanExpected = [];
+          for (var i = 0; i < expected.length; i++) {
+            if (expected[i] !== lastExpected) {
+              cleanExpected.push(expected[i]);
+              lastExpected = expected[i];
+            }
+          }
+          return cleanExpected;
+        }
+        
+        function computeErrorPosition() {
+          /*
+           * The first idea was to use |String.split| to break the input up to the
+           * error position along newlines and derive the line and column from
+           * there. However IE's |split| implementation is so broken that it was
+           * enough to prevent it.
+           */
+          
+          var line = 1;
+          var column = 1;
+          var seenCR = false;
+          
+          for (var i = 0; i < Math.max(pos, rightmostFailuresPos); i++) {
+            var ch = input.charAt(i);
+            if (ch === "\n") {
+              if (!seenCR) { line++; }
+              column = 1;
+              seenCR = false;
+            } else if (ch === "\r" || ch === "\u2028" || ch === "\u2029") {
+              line++;
+              column = 1;
+              seenCR = true;
+            } else {
+              column++;
+              seenCR = false;
+            }
+          }
+          
+          return { line: line, column: column };
+        }
+        
+        
+        var result = parseFunctions[startRule]();
+        
+        /*
+         * The parser is now in one of the following three states:
+         *
+         * 1. The parser successfully parsed the whole input.
+         *
+         *    - |result !== null|
+         *    - |pos === input.length|
+         *    - |rightmostFailuresExpected| may or may not contain something
+         *
+         * 2. The parser successfully parsed only a part of the input.
+         *
+         *    - |result !== null|
+         *    - |pos < input.length|
+         *    - |rightmostFailuresExpected| may or may not contain something
+         *
+         * 3. The parser did not successfully parse any part of the input.
+         *
+         *   - |result === null|
+         *   - |pos === 0|
+         *   - |rightmostFailuresExpected| contains at least one failure
+         *
+         * All code following this comment (including called functions) must
+         * handle these states.
+         */
+        if (result === null || pos !== input.length) {
+          var offset = Math.max(pos, rightmostFailuresPos);
+          var found = offset < input.length ? input.charAt(offset) : null;
+          var errorPosition = computeErrorPosition();
+          
+          throw new this.SyntaxError(
+            cleanupExpected(rightmostFailuresExpected),
+            found,
+            offset,
+            errorPosition.line,
+            errorPosition.column
+          );
+        }
+        
+        return result;
+      },
+      
+      /* Returns the parser source code. */
+      toSource: function() { return this._source; }
+    };
+    
+    /* Thrown when a parser encounters a syntax error. */
+    
+    result.SyntaxError = function(expected, found, offset, line, column) {
+      function buildMessage(expected, found) {
+        var expectedHumanized, foundHumanized;
+        
+        switch (expected.length) {
+          case 0:
+            expectedHumanized = "end of input";
+            break;
+          case 1:
+            expectedHumanized = expected[0];
+            break;
+          default:
+            expectedHumanized = expected.slice(0, expected.length - 1).join(", ")
+              + " or "
+              + expected[expected.length - 1];
+        }
+        
+        foundHumanized = found ? quote(found) : "end of input";
+        
+        return "Expected " + expectedHumanized + " but " + foundHumanized + " found.";
+      }
+      
+      this.name = "SyntaxError";
+      this.expected = expected;
+      this.found = found;
+      this.message = buildMessage(expected, found);
+      this.offset = offset;
+      this.line = line;
+      this.column = column;
+    };
+    
+    result.SyntaxError.prototype = Error.prototype;
+    
+    return result;
+  })();
+
+  MessageFormat.prototype.parse = function () {
+    // Bind to itself so error handling works
+    return mparser.parse.apply( mparser, arguments );
+  };
+
+  MessageFormat.prototype.precompile = function ( ast ) {
+    var self = this,
+        needOther = false,
+        fp = {
+      begin: 'function(d){\nvar r = "";\n',
+      end  : "return r;\n}"
+    };
+
+    function interpMFP ( ast, data ) {
+      // Set some default data
+      data = data || {};
+      var s = '', i, tmp, lastkeyname;
+
+      switch ( ast.type ) {
+        case 'program':
+          return interpMFP( ast.program );
+        case 'messageFormatPattern':
+          for ( i = 0; i < ast.statements.length; ++i ) {
+            s += interpMFP( ast.statements[i], data );
+          }
+          return fp.begin + s + fp.end;
+        case 'messageFormatPatternRight':
+          for ( i = 0; i < ast.statements.length; ++i ) {
+            s += interpMFP( ast.statements[i], data );
+          }
+          return s;
+        case 'messageFormatElement':
+          data.pf_count = data.pf_count || 0;
+          s += 'if(!d){\nthrow new Error("MessageFormat: No data passed to function.");\n}\n';
+          if ( ast.output ) {
+            s += 'r += d["' + ast.argumentIndex + '"];\n';
+          }
+          else {
+            lastkeyname = 'lastkey_'+(data.pf_count+1);
+            s += 'var '+lastkeyname+' = "'+ast.argumentIndex+'";\n';
+            s += 'var k_'+(data.pf_count+1)+'=d['+lastkeyname+'];\n';
+            s += interpMFP( ast.elementFormat, data );
+          }
+          return s;
+        case 'elementFormat':
+          if ( ast.key === 'select' ) {
+            s += interpMFP( ast.val, data );
+            s += 'r += (pf_' +
+                 data.pf_count +
+                 '[ k_' + (data.pf_count+1) + ' ] || pf_'+data.pf_count+'[ "other" ])( d );\n';
+          }
+          else if ( ast.key === 'plural' ) {
+            s += interpMFP( ast.val, data );
+            s += 'if ( pf_'+(data.pf_count)+'[ k_'+(data.pf_count+1)+' + "" ] ) {\n';
+            s += 'r += pf_'+data.pf_count+'[ k_'+(data.pf_count+1)+' + "" ]( d ); \n';
+            s += '}\nelse {\n';
+            s += 'r += (pf_' +
+                 data.pf_count +
+                 '[ MessageFormat.locale["' +
+                 self.fallbackLocale +
+                 '"]( k_'+(data.pf_count+1)+' - off_'+(data.pf_count)+' ) ] || pf_'+data.pf_count+'[ "other" ] )( d );\n';
+            s += '}\n';
+          }
+          return s;
+        /* // Unreachable cases.
+        case 'pluralStyle':
+        case 'selectStyle':*/
+        case 'pluralFormatPattern':
+          data.pf_count = data.pf_count || 0;
+          s += 'var off_'+data.pf_count+' = '+ast.offset+';\n';
+          s += 'var pf_' + data.pf_count + ' = { \n';
+          needOther = true;
+          // We're going to simultaneously check to make sure we hit the required 'other' option.
+
+          for ( i = 0; i < ast.pluralForms.length; ++i ) {
+            if ( ast.pluralForms[ i ].key === 'other' ) {
+              needOther = false;
+            }
+            if ( tmp ) {
+              s += ',\n';
+            }
+            else{
+              tmp = 1;
+            }
+            s += '"' + ast.pluralForms[ i ].key + '" : ' + interpMFP( ast.pluralForms[ i ].val,
+          (function(){ var res = JSON.parse(JSON.stringify(data)); res.pf_count++; return res; })() );
+          }
+          s += '\n};\n';
+          if ( needOther ) {
+            throw new Error("No 'other' form found in pluralFormatPattern " + data.pf_count);
+          }
+          return s;
+        case 'selectFormatPattern':
+
+          data.pf_count = data.pf_count || 0;
+          s += 'var off_'+data.pf_count+' = 0;\n';
+          s += 'var pf_' + data.pf_count + ' = { \n';
+          needOther = true;
+
+          for ( i = 0; i < ast.pluralForms.length; ++i ) {
+            if ( ast.pluralForms[ i ].key === 'other' ) {
+              needOther = false;
+            }
+            if ( tmp ) {
+              s += ',\n';
+            }
+            else{
+              tmp = 1;
+            }
+            s += '"' + ast.pluralForms[ i ].key + '" : ' + interpMFP( ast.pluralForms[ i ].val,
+              (function(){
+                var res = JSON.parse( JSON.stringify( data ) );
+                res.pf_count++;
+                return res;
+              })()
+            );
+          }
+          s += '\n};\n';
+          if ( needOther ) {
+            throw new Error("No 'other' form found in selectFormatPattern " + data.pf_count);
+          }
+          return s;
+        /* // Unreachable
+        case 'pluralForms':
+        */
+        case 'string':
+          return 'r += "' + MessageFormat.Utils.numSub(
+            MessageFormat.Utils.escapeExpression( ast.val ),
+            'k_' + data.pf_count + ' - off_' + ( data.pf_count - 1 ),
+            data.pf_count
+          ) + '";\n';
+        default:
+          throw new Error( 'Bad AST type: ' + ast.type );
+      }
+    }
+    return interpMFP( ast );
+  };
+
+  MessageFormat.prototype.compile = function ( message ) {
+    return (new Function( 'MessageFormat',
+      'return ' +
+        this.precompile(
+          this.parse( message )
+        )
+    ))(MessageFormat);
+  };
+
+
+  if (typeof exports !== 'undefined') {
+    if (typeof module !== 'undefined' && module.exports) {
+      exports = module.exports = MessageFormat;
+    }
+    exports.MessageFormat = MessageFormat;
+  }
+  else if (typeof define === 'function' && define.amd) {
+    define(function() {
+      return MessageFormat;
+    });
+  }
+  else {
+    root['MessageFormat'] = MessageFormat;
+  }
+
+})( this );
diff --git a/guacamole/src/main/webapp/license.txt b/guacamole/src/main/webapp/license.txt
new file mode 100644
index 0000000..be7f59d
--- /dev/null
+++ b/guacamole/src/main/webapp/license.txt
@@ -0,0 +1,21 @@
+/*!
+ * Copyright (C) 2014 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
diff --git a/guacamole/src/main/webapp/scripts/admin-ui.js b/guacamole/src/main/webapp/scripts/admin-ui.js
deleted file mode 100644
index 4370cf1..0000000
--- a/guacamole/src/main/webapp/scripts/admin-ui.js
+++ /dev/null
@@ -1,1472 +0,0 @@
-/*
- *  Guacamole - Clientless Remote Desktop
- *  Copyright (C) 2010  Michael Jumper
- *
- *  This program is free software: you can redistribute it and/or modify
- *  it under the terms of the GNU Affero General Public License as published by
- *  the Free Software Foundation, either version 3 of the License, or
- *  (at your option) any later version.
- *
- *  This program is distributed in the hope that it will be useful,
- *  but WITHOUT ANY WARRANTY; without even the implied warranty of
- *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- *  GNU Affero General Public License for more details.
- *
- *  You should have received a copy of the GNU Affero General Public License
- *  along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-/**
- * General set of UI elements and UI-related functions regarding
- * administration.
- */
-var GuacAdmin = {
-
-    "containers" : {
-        "connection_list"         : document.getElementById("connection-list"),
-        "user_list"               : document.getElementById("user-list"),
-        "user_list_buttons"       : document.getElementById("user-list-buttons"),
-    },
-
-    "buttons" : {
-        "back"                 : document.getElementById("back"),
-        "logout"               : document.getElementById("logout"),
-        "add_connection"       : document.getElementById("add-connection"),
-        "add_connection_group" : document.getElementById("add-connection-group"),
-        "add_user"             : document.getElementById("add-user")
-    },
-
-    "fields" : {
-        "username"        : document.getElementById("username")
-    },
-
-    "cached_permissions" : null,
-    "cached_protocols"   : null,
-    "cached_root_group"  : null
-
-};
-
-/**
- * An arbitrary input field.
- * 
- * @constructor
- */
-GuacAdmin.Field = function() {
-
-    /**
-     * Returns the DOM Element representing this field.
-     * 
-     * @return {Element} The DOM Element representing this field.
-     */
-    this.getElement = function() {};
-
-    /**
-     * Returns the value of this field.
-     * 
-     * @return {String} The value of this field.
-     */
-    this.getValue = function() {};
-
-    /**
-     * Sets the value of this field.
-     * 
-     * @param {String} value The value of this field.
-     */
-    this.setValue = function(value) {};
-
-};
-
-
-/**
- * Simple HTML input field.
- * 
- * @augments GuacAdmin.Field
- * @param {String} type The type of HTML field.
- */
-GuacAdmin.Field._HTML_INPUT = function(type) {
-
-    // Call parent constructor
-    GuacAdmin.Field.apply(this);
-
-    // Create backing element
-    var element = GuacUI.createElement("input");
-    element.setAttribute("type", type);
-
-    this.getValue = function() {
-        return element.value;
-    };
-
-    this.getElement = function() {
-        return element;
-    };
-
-    this.setValue = function(value) {
-        element.value = value;
-    };
-
-};
-
-GuacAdmin.Field._HTML_INPUT.prototype = new GuacAdmin.Field();
-
-
-/**
- * A basic text field.
- * 
- * @augments GuacAdmin.Field._HTML_INPUT
- */
-GuacAdmin.Field.TEXT = function() {
-    GuacAdmin.Field._HTML_INPUT.apply(this, ["text"]);
-};
-
-GuacAdmin.Field.TEXT.prototype = new GuacAdmin.Field._HTML_INPUT();
-
-
-/**
- * A basic password field.
- * 
- * @augments GuacAdmin.Field._HTML_INPUT
- */
-GuacAdmin.Field.PASSWORD = function() {
-    GuacAdmin.Field._HTML_INPUT.apply(this, ["password"]);
-};
-
-GuacAdmin.Field.PASSWORD.prototype = new GuacAdmin.Field._HTML_INPUT();
-
-
-/**
- * A basic numeric field, leveraging the new HTML5 field types.
- * 
- * @augments GuacAdmin.Field._HTML_INPUT
- */
-GuacAdmin.Field.NUMERIC = function() {
-    GuacAdmin.Field._HTML_INPUT.apply(this, ["number"]);
-};
-
-GuacAdmin.Field.NUMERIC.prototype = new GuacAdmin.Field._HTML_INPUT();
-
-
-/**
- * Simple checkbox.
- * 
- * @augments GuacAdmin.Field
- */
-GuacAdmin.Field.CHECKBOX = function(value) {
-
-    // Call parent constructor
-    GuacAdmin.Field.apply(this);
-
-    // Create backing element
-    var element = GuacUI.createElement("input");
-    element.setAttribute("type", "checkbox");
-    element.setAttribute("value", value);
-
-    this.getValue = function() {
-        if (element.checked)
-            return value;
-        else
-            return "";
-    };
-
-    this.getElement = function() {
-        return element;
-    };
-
-    this.setValue = function(new_value) {
-        if (new_value == value)
-            element.checked = true;
-        else
-            element.checked = false;
-    };
-
-};
-
-GuacAdmin.Field.CHECKBOX.prototype = new GuacAdmin.Field();
-
-/**
- * Enumerated field type.
- * 
- * @augments GuacAdmin.Field
- */
-GuacAdmin.Field.ENUM = function(values) {
-
-    // Call parent constructor
-    GuacAdmin.Field.apply(this);
-
-    // Create backing element
-    var element = GuacUI.createElement("select");
-    for (var i=0; i<values.length; i++) {
-        var option = GuacUI.createChildElement(element, "option");
-        option.textContent = values[i].title;
-        option.value = values[i].value;
-    }
-
-    this.getValue = function() {
-        return element.value;
-    };
-
-    this.getElement = function() {
-        return element;
-    };
-
-    this.setValue = function(value) {
-        element.value = value;
-    };
-
-};
-
-GuacAdmin.Field.ENUM.prototype = new GuacAdmin.Field();
-
-
-/**
- * An arbitrary button.
- * 
- * @constructor
- * @param {String} title A human-readable title for the button.
- */
-GuacAdmin.Button = function(title) {
-
-    /**
-     * A human-readable title describing this button.
-     */
-    this.title = title;
-
-    // Button element
-    var element = GuacUI.createElement("button");
-    element.textContent = title;
-
-    /**
-     * Returns the DOM element associated with this button.
-     */
-    this.getElement = function() {
-        return element;
-    };
-
-};
-
-/**
- * An arbitrary list item with an icon and caption.
- */
-GuacAdmin.ListItem = function(type, title) {
-
-    // Create connection display elements
-    var element = GuacUI.createElement("div",  "list-item");
-    var caption = GuacUI.createChildElement(element, "div", "caption");
-    var icon    = GuacUI.createChildElement(caption, "div",  "icon");
-    var name    = GuacUI.createChildElement(caption, "span", "name");
-    GuacUI.addClass(icon, type);
-
-    // Set name
-    name.textContent = title;
-
-    /**
-     * Returns the DOM element representing this connection.
-     */
-    this.getElement = function() {
-        return element;
-    };
-
-};
-
-/*
- * Set handler for logout
- */
-
-GuacAdmin.buttons.logout.onclick = function() {
-    window.location.href = "logout";
-};
-
-/*
- * Set handler for back button 
- */
-
-GuacAdmin.buttons.back.onclick = function() {
-    window.location.href = "index.xhtml";
-};
-
-/**
- * Returns whether the given object has at least one property.
- */
-GuacAdmin.hasEntry = function(object) {
-    for (var name in object)
-        return true;
-    return false;
-};
-
-/**
- * Given a Date, returns a formatted String.
- * 
- * @param {Date} date The date tor format.
- * @return {String} A formatted String.
- */
-GuacAdmin.formatDate = function(date) {
-
-    var month = date.getMonth() + 1;
-    var day   = date.getDate();
-    var year  = date.getFullYear();
-
-    var hour   = date.getHours();
-    var minute = date.getMinutes();
-    var second = date.getSeconds();
-
-    return      ("00" +  month).slice(-2)
-        + "/" + ("00" +    day).slice(-2)
-        + "/" + year
-        + " " + ("00" +   hour).slice(-2)
-        + ":" + ("00" + minute).slice(-2)
-        + ":" + ("00" + second).slice(-2);
-
-};
-
-/**
- * Given a number of seconds, returns a String representing that length
- * of time in a human-readable format.
- * 
- * @param {Number} seconds The number of seconds.
- * @return {String} A human-readable description of the duration specified.
- */
-GuacAdmin.formatSeconds = function(seconds) {
-
-    function round(value) {
-        return Math.round(value * 10) / 10;
-    }
-
-    if (seconds < 60)    return round(seconds)        + " seconds";
-    if (seconds < 3600)  return round(seconds / 60)   + " minutes";
-    if (seconds < 86400) return round(seconds / 3600) + " hours";
-    return round(seconds / 86400) + " days";
-
-};
-
-/**
- * Currently-defined pager for users, if any.
- */
-GuacAdmin.userPager = null;
-
-/**
- * Adds the user with the given name to the displayed user list.
- */
-GuacAdmin.addUser = function(name, parameters) {
-
-    // Create user list item
-    var item = new GuacAdmin.ListItem("user", name);
-    var item_element = item.getElement();
-    GuacAdmin.userPager.addElement(item_element);
-
-    // When clicked, build and display property form
-    item_element.onclick = function() {
-
-        // Open user editor
-        var user_dialog = new GuacAdmin.UserEditor(name, parameters);
-        document.body.appendChild(user_dialog.getElement());
-
-    };
-
-};
-
-
-/**
- * User edit dialog which allows editing of the user's password and connection
- * access level.
- * 
- * @param {String} name The name of the user to edit.
- * @param {String} parameters Any parameters to add to service requests for sake
- *                            of authentication.
- */
-GuacAdmin.UserEditor = function(name, parameters) {
-
-    /**
-     * Dialog containing the user editor.
-     */
-    var dialog = new GuacUI.Dialog();
-
-    // Get user permissions
-    var user_perms = GuacamoleService.Permissions.list(name, parameters);
-
-    // Permission deltas
-    var added_perms   = new GuacamoleService.PermissionSet();
-    var removed_perms = new GuacamoleService.PermissionSet();
-
-    // Create form base elements
-    var user_header = GuacUI.createChildElement(dialog.getHeader(), "h2");
-    var form_element = GuacUI.createChildElement(dialog.getBody(), "div", "form");
-    var sections = GuacUI.createChildElement(
-        GuacUI.createChildElement(form_element, "div", "settings section"),
-        "dl");
-
-    var field_header = GuacUI.createChildElement(sections, "dt");
-    var field_table  = GuacUI.createChildElement(
-        GuacUI.createChildElement(sections, "dd"),
-        "table", "fields section");
-
-    user_header.textContent = name;
-    field_header.textContent = "Properties:";
-
-    // Add password field
-    var password_field = GuacUI.createChildElement(
-            GuacUI.createTabulatedContainer(field_table, "Password:"),
-            "input");
-    password_field.setAttribute("type",  "password");
-    password_field.setAttribute("value", "password");
-        
-    // Add password re-entry field
-    var reenter_password_field = GuacUI.createChildElement(
-            GuacUI.createTabulatedContainer(field_table, "Re-enter Password:"),
-            "input");
-    reenter_password_field.setAttribute("type",  "password");
-    reenter_password_field.setAttribute("value", "password");
-
-    // Update password if changed
-    var password_modified = false;
-    password_field.onchange =
-    reenter_password_field.onchange = function() {
-        password_modified = true;
-    };
-
-    // If administrator, allow manipulation of admin permissions on users
-    if (GuacAdmin.cached_permissions.administer) {
-
-        var permission_header = GuacUI.createChildElement(sections, "dt");
-        var permission_table  = GuacUI.createChildElement(
-            GuacUI.createChildElement(sections, "dd"),
-            "table", "permissions section");
-
-        permission_header.textContent = "Permissions:";
-
-        // Add system administration checkbox
-        var is_admin = GuacUI.createChildElement(
-                GuacUI.createTabulatedContainer(permission_table, "Administer system:"),
-                "input");
-        is_admin.setAttribute("type", "checkbox");
-        is_admin.setAttribute("value", "administer");
-
-        // Check if set
-        if (user_perms.administer)
-            is_admin.checked = true;
-
-        // Add create user permission checkbox
-        var create_users = GuacUI.createChildElement(
-                GuacUI.createTabulatedContainer(permission_table, "Create new users:"),
-                "input");
-        create_users.setAttribute("type", "checkbox");
-        create_users.setAttribute("value",  "create_user");
-
-        // Check if set
-        if (user_perms.create_user)
-            create_users.checked = true;
-
-        // Add create connection permission checkbox
-        var create_connections = GuacUI.createChildElement(
-                GuacUI.createTabulatedContainer(permission_table, "Create new connections:"),
-                "input");
-        create_connections.setAttribute("type", "checkbox");
-        create_connections.setAttribute("value", "create_connection");
-
-        // Check if set
-        if (user_perms.create_connection)
-            create_connections.checked = true;
-
-        // Add create connection group permission checkbox
-        var create_connection_groups = GuacUI.createChildElement(
-                GuacUI.createTabulatedContainer(permission_table, "Create new connection groups:"),
-                "input");
-        create_connection_groups.setAttribute("type", "checkbox");
-        create_connection_groups.setAttribute("value", "create_connection_group");
-
-        // Check if set
-        if (user_perms.create_connection_group)
-            create_connection_groups.checked = true;
-
-        // Update system permissions when changed
-        is_admin.onclick =
-        create_users.onclick =
-        create_connections.onclick =
-        create_connection_groups.onclick =
-            function() {
-
-                // Update permission deltas for ADDED permission
-                if (this.checked) {
-                    added_perms[this.value]   = true;
-                    removed_perms[this.value] = false;
-                }
-
-                // Update permission deltas for REMOVED permission
-                else {
-                    added_perms[this.value]   = false;
-                    removed_perms[this.value] = true;
-                }
-
-            };
-
-    }
-
-    // If administrable connections/groups exist, list them
-    if (GuacAdmin.cached_permissions.administer
-        || GuacAdmin.hasEntry(GuacAdmin.cached_permissions.administer_connection)
-        || GuacAdmin.hasEntry(GuacAdmin.cached_permissions.administer_connection_group)) {
-
-        // Add fields for per-connection checkboxes
-        var connections_header = GuacUI.createChildElement(sections, "dt");
-        connections_header.textContent = "Connections:";
-
-        var connections_section = GuacUI.createChildElement(sections, "dd");
-
-        // Construct group view for all readable connections
-        var group_view = new GuacUI.GroupView(GuacAdmin.cached_root_group,
-           GuacUI.GroupView.SHOW_CONNECTIONS | GuacUI.GroupView.MULTISELECT);
-        connections_section.appendChild(group_view.getElement());
-
-        // Update connection permissions when changed
-        group_view.onconnectionchange = function(connection, selected) {
-
-            var id = connection.id;
-
-            // Update permission deltas for ADDED permission
-            if (selected) {
-                added_perms.read_connection[id] = true;
-                if (removed_perms.read_connection[id])
-                    delete removed_perms.read_connection[id];
-            }
-
-            // Update permission deltas for REMOVED permission
-            else {
-                removed_perms.read_connection[id] = true;
-                if (added_perms.read_connection[id])
-                    delete added_perms.read_connection[id];
-            }
-
-        };
-
-        // Update group permissions when changed
-        group_view.ongroupchange = function(group, selected) {
-
-            var id = group.id;
-
-            // Update permission deltas for ADDED permission
-            if (selected) {
-                added_perms.read_connection_group[id] = true;
-                if (removed_perms.read_connection_group[id])
-                    delete removed_perms.read_connection_group[id];
-            }
-
-            // Update permission deltas for REMOVED permission
-            else {
-                removed_perms.read_connection_group[id] = true;
-                if (added_perms.read_connection_group[id])
-                    delete added_perms.read_connection_group[id];
-            }
-
-        };
-
-        // Set selectable and selected states based on current permissions
-        for (var conn_id in group_view.connections) {
-
-            // Pre-select connection if readable by chosen user
-            if (conn_id in user_perms.read_connection)
-                group_view.setConnectionValue(conn_id, true);
-
-            // If we lack permissions to admin this connection, disable it
-            if (!GuacAdmin.cached_permissions.administer &&
-                    !(conn_id in GuacAdmin.cached_permissions.administer_connection))
-                group_view.setConnectionEnabled(conn_id, false);
-
-        }
-
-        for (var group_id in group_view.groups) {
-
-            // Pre-select connection if readable by chosen user
-            if (group_id in user_perms.read_connection_group)
-                group_view.setGroupValue(group_id, true);
-
-            // If we lack permissions to admin this connection, disable it
-            if (!GuacAdmin.cached_permissions.administer &&
-                    !(group_id in GuacAdmin.cached_permissions.administer_connection_group))
-                group_view.setGroupEnabled(group_id, false);
-
-        }
-
-    }
-
-    // Add save button
-    var save_button = GuacUI.createChildElement(dialog.getFooter(), "button");
-    save_button.textContent = "Save";
-    save_button.onclick = function(e) {
-
-        e.stopPropagation();
-
-        try {
-
-            // If password modified, use password given
-            var password;
-            if (password_modified) {
-
-                // Get passwords
-                password = password_field.value;
-                var reentered_password = reenter_password_field.value;
-
-                // Check that passwords match
-                if (password != reentered_password)
-                    throw new Error("Passwords do not match.");
-
-            }
-
-            // Otherwise, do not change password
-            else
-                password = null;
-
-            // Save user
-            GuacamoleService.Users.update(name, password, added_perms, removed_perms, parameters);
-            dialog.getElement().parentNode.removeChild(dialog.getElement());
-            GuacAdmin.reset();
-
-        }
-        catch (e) {
-            alert(e.message);
-        }
-
-    };
-
-    // Add cancel button
-    var cancel_button = GuacUI.createChildElement(dialog.getFooter(), "button");
-    cancel_button.textContent = "Cancel";
-    cancel_button.onclick = function(e) {
-        e.stopPropagation();
-        dialog.getElement().parentNode.removeChild(dialog.getElement());
-    };
-
-    // Add delete button if permission available
-    if (GuacAdmin.cached_permissions.administer ||
-        name in GuacAdmin.cached_permissions.remove_user) {
-        
-        // Create button
-        var delete_button = GuacUI.createChildElement(dialog.getFooter(), "button", "danger");
-        delete_button.textContent = "Delete";
-        
-        // Remove selected user when clicked
-        delete_button.onclick = function(e) {
-
-            e.stopPropagation();
-
-            // Delete user upon confirmation
-            if (confirm("Are you sure you want to delete the user \""
-                        + name + "\"?")) {
-
-                // Attempt to delete user
-                try {
-                    GuacamoleService.Users.remove(name, parameters);
-                    dialog.getElement().parentNode.removeChild(dialog.getElement());
-                    GuacAdmin.reset();
-                }
-
-                // Alert on failure
-                catch (e) {
-                    alert(e.message);
-                }
-
-            }
-
-        };
-
-    }
-
-    /**
-     * Returns the DOM Element representing this dialog.
-     * 
-     * @return {Element} The DOM Element representing this dialog.
-     */
-    this.getElement = function() {
-        return dialog.getElement();
-    };
-
-};
-
-/**
- * Connection edit dialog which allows editing of the connection parameters.
- * 
- * @param {GuacamoleService.Connection} connection The connection to edit. This
- *                                                 must be a connection without
- *                                                 an id, if the connection is
- *                                                 to be created.
- * @param {String} parameters Any parameters to add to service requests for sake
- *                            of authentication.
- */
-GuacAdmin.ConnectionEditor = function(connection, parameters) {
-
-    /**
-     * Dialog containing the user editor.
-     */
-    var dialog = new GuacUI.Dialog();
-
-    var i;
-
-    // Create form base elements
-    var connection_header = GuacUI.createChildElement(dialog.getHeader(), "h2");
-    var form_element = GuacUI.createChildElement(dialog.getBody(), "div", "form");
-
-    var sections = GuacUI.createChildElement(
-        GuacUI.createChildElement(form_element, "div", "settings section"),
-        "dl");
-
-    // Header section
-    var header_table  = GuacUI.createChildElement(
-        GuacUI.createChildElement(sections, "dt"),
-        "table", "fields section");
-
-    // Header parameter containers
-    var name_container     = GuacUI.createTabulatedContainer(header_table, "Name:");
-    var location_container = GuacUI.createTabulatedContainer(header_table, "Location:");
-    var protocol_container = GuacUI.createTabulatedContainer(header_table, "Protocol:");
-
-    var name_field     = GuacUI.createChildElement(name_container, "input");
-    var location       = GuacUI.createChildElement(location_container, "div", "location");
-    var protocol_field = GuacUI.createChildElement(protocol_container, "select");
-    name_field.setAttribute("type", "text");
-
-    var location_value = connection.parent;
-    location.textContent = connection.parent.name;
-    location.onclick = function() {
-
-        // Show group selector
-        var group_select = new GuacAdmin.ConnectionGroupSelect(GuacAdmin.cached_root_group);
-        location_container.appendChild(group_select.getElement());
-
-        // Pre-select current value
-        group_select.select(location_value);
-
-        // Update location when chosen
-        group_select.onselect = function(group) {
-            location_value = group;
-            location.textContent = group.name;
-        };
-
-    };
-
-    // Set header
-    name_field.value =
-    connection_header.textContent = connection.name;
-
-    // Associative set of protocols
-    var available_protocols = {};
-
-    // All form fields by parameter name
-    var fields = {};
-
-    // Add protocols
-    for (i=0; i<GuacAdmin.cached_protocols.length; i++) {
-
-        // Get protocol and store in associative set
-        var protocol = GuacAdmin.cached_protocols[i];
-        available_protocols[protocol.name] = protocol;
-
-        // List protocol in select
-        var protocol_title = GuacUI.createChildElement(protocol_field, "option");
-        protocol_title.textContent = protocol.title;
-        protocol_title.value = protocol.name;
-
-    }
-
-    // Parameter section
-    var field_table  = GuacUI.createChildElement(
-        GuacUI.createChildElement(sections, "dd"),
-        "table", "fields section");
-
-    // History header
-    var history_header = GuacUI.createChildElement(sections, "dt");
-    history_header.textContent = "Usage History:";
-
-    // If history present, display as table
-    if (connection.history.length > 0) {
-
-        // History section
-        var history_section = GuacUI.createChildElement(sections, "dd");
-        var history_table  = GuacUI.createChildElement(history_section,
-            "table", "history section");
-
-        var history_table_header = GuacUI.createChildElement(
-            history_table, "tr");
-
-        GuacUI.createChildElement(history_table_header, "th").textContent =
-            "Username";
-
-        GuacUI.createChildElement(history_table_header, "th").textContent =
-            "Start Time";
-
-        GuacUI.createChildElement(history_table_header, "th").textContent =
-            "Duration";
-
-        // Paginated body of history
-        var history_buttons = GuacUI.createChildElement(history_section, "div",
-            "list-pager-buttons");
-        var history_body = GuacUI.createChildElement(history_table, "tbody");
-        var history_pager = new GuacUI.Pager(history_body);
-
-        // Add history
-        for (i=0; i<connection.history.length; i++) {
-
-            // Get record
-            var record = connection.history[i];
-
-            // Create record elements
-            var row = GuacUI.createElement("tr");
-            var user = GuacUI.createChildElement(row, "td", "username");
-            var start = GuacUI.createChildElement(row, "td", "start");
-            var duration = GuacUI.createChildElement(row, "td", "duration");
-
-            // Display record
-            user.textContent = record.username;
-            start.textContent = GuacAdmin.formatDate(record.start);
-            if (record.duration !== null)
-                duration.textContent = GuacAdmin.formatSeconds(record.duration);
-            else
-                duration.textContent = "Active now";
-
-            // Add record to pager
-            history_pager.addElement(row);
-
-        }
-
-        // Init pager
-        history_pager.setPage(0);
-
-        // Add pager if more than one page
-        if (history_pager.last_page !== 0)
-            history_buttons.appendChild(history_pager.getElement());
-
-    }
-    else
-        GuacUI.createChildElement(
-            GuacUI.createChildElement(sections, "dd"), "p").textContent =
-                "This connection has not yet been used.";
-
-    // Display fields for the given protocol name 
-    function setFields(protocol_name) {
-
-        // Clear fields
-        field_table.innerHTML = "";
-
-        // Get protocol
-        var protocol = available_protocols[protocol_name];
-
-        // For each parameter
-        for (var i=0; i<protocol.parameters.length; i++) {
-
-            // Get parameter
-            var parameter = protocol.parameters[i];
-            var name = parameter.name;
-
-            // Create corresponding field
-            var field;
-            switch (parameter.type) {
-
-                // Text field
-                case GuacamoleService.Protocol.Parameter.TEXT:
-                    field = new GuacAdmin.Field.TEXT();
-                    break;
-
-                // Password field
-                case GuacamoleService.Protocol.Parameter.PASSWORD:
-                    field = new GuacAdmin.Field.PASSWORD();
-                    break;
-
-                // Numeric field
-                case GuacamoleService.Protocol.Parameter.NUMERIC:
-                    field = new GuacAdmin.Field.NUMERIC();
-                    break;
-
-                // Checkbox
-                case GuacamoleService.Protocol.Parameter.BOOLEAN:
-                    field = new GuacAdmin.Field.CHECKBOX(parameter.value);
-                    break;
-
-                // Select field
-                case GuacamoleService.Protocol.Parameter.ENUM:
-                    field = new GuacAdmin.Field.ENUM(parameter.options);
-                    break;
-
-                default:
-                    continue;
-
-            }
-
-            // Create container for field
-            var container = 
-                GuacUI.createTabulatedContainer(field_table, parameter.title + ":");
-
-            // Set initial value, if available
-            if (connection.parameters[name])
-                field.setValue(connection.parameters[name]);
-
-            // Add field
-            container.appendChild(field.getElement());
-            fields[name] = field;
-
-        } // end foreach parameter
-
-    }
-
-    // Set initially selected protocol
-    if (connection.protocol) protocol_field.value = connection.protocol;
-    setFields(protocol_field.value);
-
-    protocol_field.onchange = protocol_field.onclick = function() {
-        setFields(this.value);
-    };
-
-    // Add save button
-    var save_button = GuacUI.createChildElement(dialog.getFooter(), "button");
-    save_button.textContent = "Save";
-    save_button.onclick = function(e) {
-
-        e.stopPropagation();
-
-        try {
-
-            // Build connection
-            var updated_connection = new GuacamoleService.Connection(
-                protocol_field.value,
-                connection.id,
-                name_field.value
-            );
-
-            // Populate parameters
-            for (var name in fields) {
-                var field = fields[name];
-                if (field)
-                    updated_connection.parameters[name] = field.getValue();
-            }
-
-            // Update connection if it exists
-            if (connection.id) {
-                GuacamoleService.Connections.update(updated_connection, parameters);
-                if (location_value.id !== connection.parent.id)
-                    GuacamoleService.Connections.move(updated_connection, location_value, parameters);
-            }
-
-            // Otherwise, create
-            else {
-                updated_connection.parent = location_value;
-                GuacamoleService.Connections.create(updated_connection, parameters);
-            }
-
-            // Hide dialog and reset UI
-            dialog.getElement().parentNode.removeChild(dialog.getElement());
-            GuacAdmin.reset();
-
-        }
-        catch (e) {
-            alert(e.message);
-        }
-
-    };
-
-    // Add cancel button
-    var cancel_button = GuacUI.createChildElement(dialog.getFooter(), "button");
-    cancel_button.textContent = "Cancel";
-    cancel_button.onclick = function(e) {
-        e.stopPropagation();
-        dialog.getElement().parentNode.removeChild(dialog.getElement());
-    };
-
-    // Add delete button if permission available
-    if (GuacAdmin.cached_permissions.administer ||
-        connection.id in GuacAdmin.cached_permissions.remove_connection) {
-        
-        // Create button
-        var delete_button = GuacUI.createChildElement(dialog.getFooter(), "button", "danger");
-        delete_button.textContent = "Delete";
-        
-        // Remove selected connection when clicked
-        delete_button.onclick = function(e) {
-
-            e.stopPropagation();
-
-            // Delete connection upon confirmation
-            if (confirm("Are you sure you want to delete the connection \""
-                        + connection.name + "\"?")) {
-
-                // Attempt to delete connection
-                try {
-                    GuacamoleService.Connections.remove(connection.id, parameters);
-                    dialog.getElement().parentNode.removeChild(dialog.getElement());
-                    GuacAdmin.reset();
-                }
-
-                // Alert on failure
-                catch (e) {
-                    alert(e.message);
-                }
-
-            }
-
-        };
-
-    }
-
-    /**
-     * Returns the DOM Element representing this dialog.
-     * 
-     * @return {Element} The DOM Element representing this dialog.
-     */
-    this.getElement = function() {
-        return dialog.getElement();
-    };
-
-};
-
-/**
- * Connection group edit dialog which allows editing of the group parameters.
- * 
- * @param {GuacamoleService.ConnectionGroup} group The group to edit. This must
- *                                                 be a group without an ID for
- *                                                 group creation.
- * @param {String} parameters Any parameters to add to service requests for sake
- *                            of authentication.
- */
-GuacAdmin.ConnectionGroupEditor = function(group, parameters) {
-
-    /**
-     * Dialog containing the user editor.
-     */
-    var dialog = new GuacUI.Dialog();
-
-    var i;
-
-    // Create form base elements
-    var group_header = GuacUI.createChildElement(dialog.getHeader(), "h2");
-    var form_element = GuacUI.createChildElement(dialog.getBody(), "div", "form");
-
-    var sections = GuacUI.createChildElement(
-        GuacUI.createChildElement(form_element, "div", "settings section"),
-        "dl");
-
-    // Header section
-    var header_table  = GuacUI.createChildElement(
-        GuacUI.createChildElement(sections, "dt"),
-        "table", "fields section");
-
-    // Header parameter containers
-    var name_container     = GuacUI.createTabulatedContainer(header_table, "Name:");
-    var location_container = GuacUI.createTabulatedContainer(header_table, "Location:");
-    var type_container     = GuacUI.createTabulatedContainer(header_table, "Type:");
-
-    var name_field = GuacUI.createChildElement(name_container, "input");
-    var location   = GuacUI.createChildElement(location_container, "div", "location");
-    var type_field = GuacUI.createChildElement(type_container, "select");
-    name_field.setAttribute("type", "text");
-
-    var location_value = group.parent;
-    location.textContent = group.parent.name;
-    location.onclick = function() {
-
-        // Show group selector
-        var group_select = new GuacAdmin.ConnectionGroupSelect(GuacAdmin.cached_root_group);
-        location_container.appendChild(group_select.getElement());
-
-        // Pre-select current value
-        group_select.select(location_value);
-
-        // Update location when chosen
-        group_select.onselect = function(selected_group) {
-
-            // Prevent selecting a situation that would produce a cycle
-            var current = selected_group;
-            while (current !== null) {
-                
-                if (current.id === group.id) {
-                    alert("Cannot move a group into a subgroup of itself.");
-                    return;
-                }
-                
-                current = current.parent;
-            }
-
-            location_value = selected_group;
-            location.textContent = selected_group.name;
-        };
-
-    };
-
-    // Set title
-    name_field.value =
-    group_header.textContent = group.name;
-
-    // Organizational type
-    var org_type = GuacUI.createChildElement(type_field, "option");
-    org_type.textContent = "Organizational";
-    org_type.value = "organizational";
-
-    // Balancing type
-    var bal_type = GuacUI.createChildElement(type_field, "option");
-    bal_type.textContent = "Balancing";
-    bal_type.value = "balancing";
-
-    // Read type from group
-    if (group.type === GuacamoleService.ConnectionGroup.Type.ORGANIZATIONAL)
-        type_field.value = "organizational";
-    else if (group.type === GuacamoleService.ConnectionGroup.Type.BALANCING)
-        type_field.value = "balancing";
-
-    // Add save button
-    var save_button = GuacUI.createChildElement(dialog.getFooter(), "button");
-    save_button.textContent = "Save";
-    save_button.onclick = function(e) {
-
-        e.stopPropagation();
-
-        try {
-
-            // Parse type
-            var type;
-            if (type_field.value === "organizational")
-                type = GuacamoleService.ConnectionGroup.Type.ORGANIZATIONAL;
-            else if (type_field.value === "balancing")
-                type = GuacamoleService.ConnectionGroup.Type.BALANCING;
-
-            // Build group 
-            var updated_group = new GuacamoleService.ConnectionGroup(
-                type,
-                group.id,
-                name_field.value
-            );
-
-            // Update group if provided
-            if (group.id) {
-                GuacamoleService.ConnectionGroups.update(updated_group, parameters);
-                if (location_value.id !== group.parent.id)
-                    GuacamoleService.ConnectionGroups.move(updated_group, location_value, parameters);
-            }
-
-            // Otherwise, create
-            else {
-                updated_group.parent = location_value;
-                GuacamoleService.ConnectionGroups.create(updated_group, parameters);
-            }
-
-            dialog.getElement().parentNode.removeChild(dialog.getElement());
-            GuacAdmin.reset();
-
-        }
-        catch (e) {
-            alert(e.message);
-        }
-
-    };
-
-    // Add cancel button
-    var cancel_button = GuacUI.createChildElement(dialog.getFooter(), "button");
-    cancel_button.textContent = "Cancel";
-    cancel_button.onclick = function(e) {
-        e.stopPropagation();
-        dialog.getElement().parentNode.removeChild(dialog.getElement());
-    };
-
-    // Add delete button if permission available
-    if (GuacAdmin.cached_permissions.administer ||
-        group.id in GuacAdmin.cached_permissions.remove_connection_group) {
-        
-        // Create button
-        var delete_button = GuacUI.createChildElement(dialog.getFooter(), "button", "danger");
-        delete_button.textContent = "Delete";
-        
-        // Remove selected group when clicked
-        delete_button.onclick = function(e) {
-
-            e.stopPropagation();
-
-            // Delete group upon confirmation
-            if (confirm("Are you sure you want to delete the group \""
-                        + group.name + "\"?")) {
-
-                // Attempt to delete group 
-                try {
-                    GuacamoleService.ConnectionGroups.remove(group.id, parameters);
-                    dialog.getElement().parentNode.removeChild(dialog.getElement());
-                    GuacAdmin.reset();
-                }
-
-                // Alert on failure
-                catch (e) {
-                    alert(e.message);
-                }
-
-            }
-
-        };
-
-    }
-
-    /**
-     * Returns the DOM Element representing this dialog.
-     * 
-     * @return {Element} The DOM Element representing this dialog.
-     */
-    this.getElement = function() {
-        return dialog.getElement();
-    };
-
-};
-
-/**
- * Connection group dialog which allows selection of a single group.
- * 
- * @param {GuacamoleService.ConnectionGroup} group The group to view.
- */
-GuacAdmin.ConnectionGroupSelect = function(group) {
-
-    /**
-     * Reference to this group selector.
-     * @private
-     */
-    var group_select = this;
-
-    // Add section with group view
-    var container = GuacUI.createElement("div");
-    var group_outside = GuacUI.createChildElement(container, "div", "overlay");
-    var group_section = GuacUI.createChildElement(container, "div", "dropdown");
-
-    var view = new GuacUI.GroupView(group, GuacUI.GroupView.SHOW_ROOT_GROUP,
-
-        // Only show organizational groups or balancing groups we can administer
-        function(group) {
-
-            if (group.type === GuacamoleService.ConnectionGroup.Type.ORGANIZATIONAL)
-                return true;
-
-            return    GuacAdmin.cached_permissions.administer
-                   || GuacAdmin.cached_permissions.administer_connection_group[group.id];
-
-        });
-
-    group_section.appendChild(view.getElement());
-
-    // Hide when clicked outside
-    group_outside.addEventListener("click", function(e) {
-        e.stopPropagation();
-        container.parentNode.removeChild(container);
-    }, false);
-
-    // Handle select
-    view.ongroupclick = function(group) {
-
-        // Fire event if defined
-        if (group_select.onselect)
-            group_select.onselect(group);
-
-        // Hide dialog
-        container.parentNode.removeChild(container);
-
-    };
-
-    /**
-     * Fired when a group is selected.
-     * 
-     * @event
-     * @param {GuacamoleService.ConnectionGroup} group The selected group.
-     */
-    this.onselect = null;
-
-    /**
-     * Returns the DOM Element representing this dialog.
-     * 
-     * @return {Element} The DOM Element representing this dialog.
-     */
-    this.getElement = function() {
-        return container;
-    };
-
-    /**
-     * Pre-selects the given group.
-     * 
-     * @param {GuacamoleService.ConnectionGroup} group The group to select.
-     */
-    this.select = function(group) {
-        view.expand(group);
-    };
-
-};
-
-GuacAdmin.reset = function() {
-
-    // Get parameters from query string
-    var parameters = window.location.search.substring(1);
-
-    /*
-     * Show admin elements if admin permissions available
-     */
-
-    // Query service for permissions, protocols, and connections
-    GuacAdmin.cached_permissions = GuacamoleService.Permissions.list(null, parameters);
-    GuacAdmin.cached_protocols   = GuacamoleService.Protocols.list(parameters);
-    GuacAdmin.cached_root_group  = GuacamoleService.Connections.list(parameters);
-
-    // Connection management
-    if (GuacAdmin.cached_permissions.administer
-        || GuacAdmin.cached_permissions.create_connection
-        || GuacAdmin.hasEntry(GuacAdmin.cached_permissions.update_connection)
-        || GuacAdmin.hasEntry(GuacAdmin.cached_permissions.remove_connection)
-        || GuacAdmin.hasEntry(GuacAdmin.cached_permissions.administer_connection)
-        || GuacAdmin.hasEntry(GuacAdmin.cached_permissions.update_connection_group)
-        || GuacAdmin.hasEntry(GuacAdmin.cached_permissions.remove_connection_group)
-        || GuacAdmin.hasEntry(GuacAdmin.cached_permissions.administer_connection_group))
-            GuacUI.addClass(document.body, "manage-connections");
-        else
-            GuacUI.removeClass(document.body, "manage-connections");
-
-    // User management
-    if (GuacAdmin.cached_permissions.administer
-        || GuacAdmin.cached_permissions.create_user
-        || GuacAdmin.hasEntry(GuacAdmin.cached_permissions.update_user)
-        || GuacAdmin.hasEntry(GuacAdmin.cached_permissions.remove_user)
-        || GuacAdmin.hasEntry(GuacAdmin.cached_permissions.administer_user))
-            GuacUI.addClass(document.body, "manage-users");
-        else
-            GuacUI.removeClass(document.body, "manage-users");
-
-    // Connection creation 
-    if (GuacAdmin.cached_permissions.administer
-        || GuacAdmin.cached_permissions.create_connection) {
-        GuacUI.addClass(document.body, "add-connections");
-
-        GuacAdmin.buttons.add_connection.onclick = function() {
-
-            // Create stub base connection
-            var connection = new GuacamoleService.Connection(null, null, "New Connection");
-            connection.parent = GuacAdmin.cached_root_group;
-
-            // Open connection creation dialog
-            var connection_dialog = new GuacAdmin.ConnectionEditor(connection, parameters);
-            document.body.appendChild(connection_dialog.getElement());
-
-        };
-
-    }
-
-    // Connection group creation 
-    if (GuacAdmin.cached_permissions.administer
-        || GuacAdmin.cached_permissions.create_connection_group) {
-        GuacUI.addClass(document.body, "add-connection-groups");
-
-        GuacAdmin.buttons.add_connection_group.onclick = function() {
-
-            // Create stub base group 
-            var group = new GuacamoleService.ConnectionGroup(
-                    GuacamoleService.ConnectionGroup.Type.ORGANIZATIONAL, null, "New Group");
-            group.parent = GuacAdmin.cached_root_group;
-
-            // Open group creation dialog
-            var group_dialog = new GuacAdmin.ConnectionGroupEditor(group, parameters);
-            document.body.appendChild(group_dialog.getElement());
-
-        };
-
-    }
-
-    // User creation
-    if (GuacAdmin.cached_permissions.administer
-       || GuacAdmin.cached_permissions.create_user) {
-        GuacUI.addClass(document.body, "add-users");
-
-        GuacAdmin.buttons.add_user.onclick = function() {
-
-            // Attempt to create user
-            try {
-                GuacamoleService.Users.create(GuacAdmin.fields.username.value, parameters);
-                GuacAdmin.fields.username.value = "";
-                GuacAdmin.reset();
-            }
-
-            // Alert on failure
-            catch (e) {
-                alert(e.message);
-            }
-
-        };
-
-    }
-
-    var i;
-
-    /*
-     * Add readable users.
-     */
-
-    // Get previous page, if any
-    var user_previous_page = 0;
-    if (GuacAdmin.userPager)
-        user_previous_page = GuacAdmin.userPager.current_page;
-
-    // Add new pager
-    GuacAdmin.containers.user_list.innerHTML = "";
-    GuacAdmin.userPager = new GuacUI.Pager(GuacAdmin.containers.user_list);
-
-    // Add users to pager
-    var usernames = GuacamoleService.Users.list(parameters);
-    for (i=0; i<usernames.length; i++) {
-        if (GuacAdmin.cached_permissions.administer
-            || usernames[i] in GuacAdmin.cached_permissions.update_user)
-            GuacAdmin.addUser(usernames[i], parameters);
-    }
-
-    // If more than one page, add navigation buttons
-    GuacAdmin.containers.user_list_buttons.innerHTML = "";
-    if (GuacAdmin.userPager.last_page != 0)
-        GuacAdmin.containers.user_list_buttons.appendChild(GuacAdmin.userPager.getElement());
-
-    // Set starting page
-    GuacAdmin.userPager.setPage(Math.min(GuacAdmin.userPager.last_page,
-            user_previous_page));
-
-    /*
-     * Add readable connections.
-     */
-
-    // Add new group view
-    GuacAdmin.containers.connection_list.innerHTML = "";
-    var group_view = new GuacUI.GroupView(GuacAdmin.cached_root_group, GuacUI.GroupView.SHOW_CONNECTIONS,
-    
-        // Show all organizational groups and balancing groups we have admin
-        // for
-        function(group) {
-
-            if (group.type === GuacamoleService.ConnectionGroup.Type.ORGANIZATIONAL)
-                return true;
-
-            return    GuacAdmin.cached_permissions.administer
-                   || GuacAdmin.cached_permissions.administer_connection_group[group.id];
-
-        },
-
-        // Only show connections we can update/administer
-        function(connection) {
-            return    GuacAdmin.cached_permissions.administer
-                   || GuacAdmin.cached_permissions.update_connection[connection.id]
-                   || GuacAdmin.cached_permissions.administer_connection[connection.id];
-        });
-
-    GuacAdmin.containers.connection_list.appendChild(group_view.getElement());
-
-    // Show connection editor when connections are clicked
-    group_view.onconnectionclick = function(connection) {
-        var connection_dialog = new GuacAdmin.ConnectionEditor(connection, parameters);
-        document.body.appendChild(connection_dialog.getElement());
-    };
-
-    // Show group editor when groups are clicked
-    group_view.ongroupclick = function(group) {
-
-        // Only show group editor if we can actually update/admin this group
-        if (GuacAdmin.cached_permissions.administer
-               || GuacAdmin.cached_permissions.update_connection_group[group.id]
-               || GuacAdmin.cached_permissions.administer_connection_group[group.id]) {
-
-            var group_dialog = new GuacAdmin.ConnectionGroupEditor(group, parameters);
-            document.body.appendChild(group_dialog.getElement());
-
-       }
-
-    };
-
-};
-
-// Initial load
-GuacAdmin.reset();
-
diff --git a/guacamole/src/main/webapp/scripts/client-ui.js b/guacamole/src/main/webapp/scripts/client-ui.js
deleted file mode 100644
index ede0e33..0000000
--- a/guacamole/src/main/webapp/scripts/client-ui.js
+++ /dev/null
@@ -1,1046 +0,0 @@
-
-/**
- * Client UI root object.
- */
-GuacUI.Client = {
-
-    /**
-     * Collection of all Guacamole client UI states.
-     */
-    "states": {
-
-        /**
-         * The normal default Guacamole client UI mode
-         */
-        "INTERACTIVE"      : 0,
-
-        /**
-         * Same as INTERACTIVE except with visible on-screen keyboard.
-         */
-        "OSK"              : 1,
-
-        /**
-         * No on-screen keyboard, but a visible magnifier.
-         */
-        "MAGNIFIER"        : 2,
-
-        /**
-         * Arrows and a draggable view.
-         */
-        "PAN"              : 3,
-
-        /**
-         * Same as PAN, but with visible native OSK.
-         */
-        "PAN_TYPING"       : 4,
-
-        /**
-         * Precursor to PAN_TYPING, like PAN, except does not pan the
-         * screen, but rather hints at how to start typing.
-         */
-        "WAIT_TYPING"      : 5
-
-    },
-
-    /* Constants */
-    
-    "LONG_PRESS_DETECT_TIMEOUT"     : 800, /* milliseconds */
-    "LONG_PRESS_MOVEMENT_THRESHOLD" : 10,  /* pixels */    
-    "KEYBOARD_AUTO_RESIZE_INTERVAL" : 30,  /* milliseconds */
-
-    /* UI Components */
-
-    "viewport"          : document.getElementById("viewportClone"),
-    "display"           : document.getElementById("display"),
-    "notification_area" : document.getElementById("notificationArea"),
-
-    /* Expected Input Rectangle */
-
-    "expected_input_x"      : 0,
-    "expected_input_y"      : 0,
-    "expected_input_width"  : 1,
-    "expected_input_height" : 1,
-
-    "connectionName"  : "Guacamole",
-    "overrideAutoFit" : false,
-    "attachedClient"  : null
-
-};
-
-/**
- * Component which displays a magnified (100% zoomed) client display.
- * 
- * @constructor
- * @augments GuacUI.DraggableComponent
- */
-GuacUI.Client.Magnifier = function() {
-
-    /**
-     * Reference to this magnifier.
-     * @private
-     */
-    var guac_magnifier = this;
-
-    /**
-     * Large background div which will block touch events from reaching the
-     * client while also providing a click target to deactivate the
-     * magnifier.
-     * @private
-     */
-    var magnifier_background = GuacUI.createElement("div", "magnifier-background");
-
-    /**
-     * Container div for the magnifier, providing a clipping rectangle.
-     * @private
-     */
-    var magnifier = GuacUI.createChildElement(magnifier_background,
-        "div", "magnifier");
-
-    /**
-     * Canvas which will contain the static image copy of the display at time
-     * of show.
-     * @private
-     */
-    var magnifier_display = GuacUI.createChildElement(magnifier, "canvas");
-
-    /**
-     * Context of magnifier display.
-     * @private
-     */
-    var magnifier_context = magnifier_display.getContext("2d");
-
-    /*
-     * This component is draggable.
-     */
-    GuacUI.DraggableComponent.apply(this, [magnifier]);
-
-    // Ensure transformations on display originate at 0,0
-    magnifier.style.transformOrigin =
-    magnifier.style.webkitTransformOrigin =
-    magnifier.style.MozTransformOrigin =
-    magnifier.style.OTransformOrigin =
-    magnifier.style.msTransformOrigin =
-        "0 0";
-
-    /*
-     * Reposition magnifier display relative to own position on screen.
-     */
-
-    this.onmove = function(x, y) {
-
-        var width = magnifier.offsetWidth;
-        var height = magnifier.offsetHeight;
-
-        // Update contents relative to new position
-        var clip_x = x
-            / (window.innerWidth - width) * (GuacUI.Client.attachedClient.getWidth() - width);
-        var clip_y = y
-            / (window.innerHeight - height) * (GuacUI.Client.attachedClient.getHeight() - height);
-       
-        magnifier_display.style.WebkitTransform =
-        magnifier_display.style.MozTransform =
-        magnifier_display.style.OTransform =
-        magnifier_display.style.msTransform =
-        magnifier_display.style.transform = "translate("
-            + (-clip_x) + "px, " + (-clip_y) + "px)";
-
-        /* Update expected input rectangle */
-        GuacUI.Client.expected_input_x = clip_x;
-        GuacUI.Client.expected_input_y = clip_y;
-        GuacUI.Client.expected_input_width  = width;
-        GuacUI.Client.expected_input_height = height;
-
-    };
-
-    /*
-     * Copy display and add self to body on show.
-     */
-
-    this.show = function() {
-
-        // Copy displayed image
-        magnifier_display.width = GuacUI.Client.attachedClient.getWidth();
-        magnifier_display.height = GuacUI.Client.attachedClient.getHeight();
-        magnifier_context.drawImage(GuacUI.Client.attachedClient.flatten(), 0, 0);
-
-        // Show magnifier container
-        document.body.appendChild(magnifier_background);
-
-    };
-
-    /*
-     * Remove self from body on hide.
-     */
-
-    this.hide = function() {
-
-        // Hide magnifier container
-        document.body.removeChild(magnifier_background);
-
-    };
-
-    /*
-     * If the user clicks on the background, switch to INTERACTIVE mode.
-     */
-
-    magnifier_background.addEventListener("click", function() {
-        GuacUI.StateManager.setState(GuacUI.Client.states.INTERACTIVE);
-    }, true);
-
-    /*
-     * If the user clicks on the magnifier, switch to PAN_TYPING mode.
-     */
-
-    magnifier.addEventListener("click", function(e) {
-        GuacUI.StateManager.setState(GuacUI.Client.states.PAN_TYPING);
-        e.stopPropagation();
-    }, true);
-
-};
-
-/*
- * We inherit from GuacUI.DraggableComponent.
- */
-GuacUI.Client.Magnifier.prototype = new GuacUI.DraggableComponent();
-
-GuacUI.StateManager.registerComponent(
-    new GuacUI.Client.Magnifier(),
-    GuacUI.Client.states.MAGNIFIER
-);
-
-/**
- * Zoomed Display, a pseudo-component.
- * 
- * @constructor
- * @augments GuacUI.Component
- */
-GuacUI.Client.ZoomedDisplay = function() {
-
-    this.show = function() {
-        GuacUI.Client.overrideAutoFit = true;
-        GuacUI.Client.updateDisplayScale();
-    };
-
-    this.hide = function() {
-        GuacUI.Client.overrideAutoFit = false;
-        GuacUI.Client.updateDisplayScale();
-    };
-
-};
-
-GuacUI.Client.ZoomedDisplay.prototype = new GuacUI.Component();
-
-/*
- * Zoom the main display during PAN and PAN_TYPING modes.
- */
-
-GuacUI.StateManager.registerComponent(
-    new GuacUI.Client.ZoomedDisplay(),
-    GuacUI.Client.states.PAN,
-    GuacUI.Client.states.PAN_TYPING
-);
-
-/**
- * Type overlay UI. This component functions to provide a means of activating
- * the keyboard, when neither panning nor magnification make sense.
- * 
- * @constructor
- * @augments GuacUI.Component
- */
-GuacUI.Client.TypeOverlay = function() {
-
-    /**
-     * Overlay which will provide the means of scrolling the screen.
-     */
-    var type_overlay = GuacUI.createElement("div", "type-overlay");
-
-    /*
-     * Add exit button
-     */
-
-    var start = GuacUI.createChildElement(type_overlay, "p", "hint");
-    start.textContent = "Tap here to type, or tap the screen to cancel.";
-
-    // Begin typing when user clicks hint
-    start.addEventListener("click", function(e) {
-        GuacUI.StateManager.setState(GuacUI.Client.states.PAN_TYPING);
-        e.stopPropagation();
-    }, false);
-
-    this.show = function() {
-        document.body.appendChild(type_overlay);
-    };
-
-    this.hide = function() {
-        document.body.removeChild(type_overlay);
-    };
-
-    /*
-     * Cancel when user taps screen
-     */
-
-    type_overlay.addEventListener("click", function(e) {
-        GuacUI.StateManager.setState(GuacUI.Client.states.INTERACTIVE);
-        e.stopPropagation();
-    }, false);
-
-};
-
-GuacUI.Client.TypeOverlay.prototype = new GuacUI.Component();
-
-/*
- * Show the type overlay during WAIT_TYPING mode only
- */
-
-GuacUI.StateManager.registerComponent(
-    new GuacUI.Client.TypeOverlay(),
-    GuacUI.Client.states.WAIT_TYPING
-);
-
-/**
- * Pan overlay UI. This component functions to receive touch events and
- * translate them into scrolling of the main UI.
- * 
- * @constructor
- * @augments GuacUI.Component
- */
-GuacUI.Client.PanOverlay = function() {
-
-    /**
-     * Overlay which will provide the means of scrolling the screen.
-     */
-    var pan_overlay = GuacUI.createElement("div", "pan-overlay");
-
-    /*
-     * Add arrows
-     */
-
-    GuacUI.createChildElement(pan_overlay, "div", "indicator up");
-    GuacUI.createChildElement(pan_overlay, "div", "indicator down");
-    GuacUI.createChildElement(pan_overlay, "div", "indicator right");
-    GuacUI.createChildElement(pan_overlay, "div", "indicator left");
-
-    /*
-     * Add exit button
-     */
-
-    var back = GuacUI.createChildElement(pan_overlay, "p", "hint");
-    back.textContent = "Tap here to exit panning mode";
-
-    // Return to interactive when back is clicked
-    back.addEventListener("click", function() {
-        GuacUI.StateManager.setState(GuacUI.Client.states.INTERACTIVE);
-    }, false);
-
-    this.show = function() {
-        document.body.appendChild(pan_overlay);
-    };
-
-    this.hide = function() {
-        document.body.removeChild(pan_overlay);
-    };
-
-    /*
-     * Transition to PAN_TYPING when the user taps on the overlay.
-     */
-
-    pan_overlay.addEventListener("click", function(e) {
-        GuacUI.StateManager.setState(GuacUI.Client.states.PAN_TYPING);
-        e.stopPropagation();
-    }, true);
-
-};
-
-GuacUI.Client.PanOverlay.prototype = new GuacUI.Component();
-
-/*
- * Show the pan overlay during PAN or PAN_TYPING modes.
- */
-
-GuacUI.StateManager.registerComponent(
-    new GuacUI.Client.PanOverlay(),
-    GuacUI.Client.states.PAN,
-    GuacUI.Client.states.PAN_TYPING
-);
-
-/**
- * Native Keyboard. This component uses a hidden textarea field to show the
- * platforms native on-screen keyboard (if any) or otherwise enable typing,
- * should the platform require a text field with focus for keyboard events to
- * register.
- * 
- * @constructor
- * @augments GuacUI.Component
- */
-GuacUI.Client.NativeKeyboard = function() {
-
-    /**
-     * Event target. This is a hidden textarea element which will receive
-     * key events.
-     * @private
-     */
-    var eventTarget = GuacUI.createElement("textarea", "event-target");
-    eventTarget.setAttribute("autocorrect", "off");
-    eventTarget.setAttribute("autocapitalize", "off");
-
-    this.show = function() {
-
-        // Move to location of expected input
-        eventTarget.style.left   = GuacUI.Client.expected_input_x + "px";
-        eventTarget.style.top    = GuacUI.Client.expected_input_y + "px";
-        eventTarget.style.width  = GuacUI.Client.expected_input_width + "px";
-        eventTarget.style.height = GuacUI.Client.expected_input_height + "px";
-
-        // Show and focus target
-        document.body.appendChild(eventTarget);
-        eventTarget.focus();
-
-    };
-
-    this.hide = function() {
-
-        // Hide and blur target
-        eventTarget.blur();
-        document.body.removeChild(eventTarget);
-
-    };
-
-    /*
-     * Automatically switch to INTERACTIVE mode after target loses focus
-     */
-
-    eventTarget.addEventListener("blur", function() {
-        GuacUI.StateManager.setState(GuacUI.Client.states.INTERACTIVE);
-    }, false);
-
-};
-
-GuacUI.Client.NativeKeyboard.prototype = new GuacUI.Component();
-
-/*
- * Show native keyboard during PAN_TYPING mode only.
- */
-
-GuacUI.StateManager.registerComponent(
-    new GuacUI.Client.NativeKeyboard(),
-    GuacUI.Client.states.PAN_TYPING
-);
-
-/**
- * On-screen Keyboard. This component provides a clickable/touchable keyboard
- * which sends key events to the Guacamole client.
- * 
- * @constructor
- * @augments GuacUI.Component
- */
-GuacUI.Client.OnScreenKeyboard = function() {
-
-    /**
-     * Event target. This is a hidden textarea element which will receive
-     * key events.
-     * @private
-     */
-    var keyboard_container = GuacUI.createElement("div", "keyboard-container");
-
-    var keyboard_resize_interval = null;
-
-    // On-screen keyboard
-    var keyboard = new Guacamole.OnScreenKeyboard("layouts/en-us-qwerty.xml");
-    keyboard_container.appendChild(keyboard.getElement());
-
-    var last_keyboard_width = 0;
-
-    // Function for automatically updating keyboard size
-    function updateKeyboardSize() {
-        var currentSize = keyboard.getElement().offsetWidth;
-        if (last_keyboard_width != currentSize) {
-            keyboard.resize(currentSize);
-            last_keyboard_width = currentSize;
-        }
-    }
-
-    keyboard.onkeydown = function(keysym) {
-        GuacUI.Client.attachedClient.sendKeyEvent(1, keysym);
-    };
-
-    keyboard.onkeyup = function(keysym) {
-        GuacUI.Client.attachedClient.sendKeyEvent(0, keysym);
-    };
-
-
-    this.show = function() {
-
-        // Show keyboard
-        document.body.appendChild(keyboard_container);
-
-        // Start periodic update of keyboard size
-        keyboard_resize_interval = window.setInterval(
-            updateKeyboardSize,
-            GuacUI.Client.KEYBOARD_AUTO_RESIZE_INTERVAL);
-
-        // Resize on window resize
-        window.addEventListener("resize", updateKeyboardSize, true);
-
-        // Initialize size
-        updateKeyboardSize();
-
-    };
-
-    this.hide = function() {
-
-        // Hide keyboard
-        document.body.removeChild(keyboard_container);
-        window.clearInterval(keyboard_resize_interval);
-        window.removeEventListener("resize", updateKeyboardSize, true);
-
-    };
-
-};
-
-GuacUI.Client.OnScreenKeyboard.prototype = new GuacUI.Component();
-
-/*
- * Show on-screen keyboard during OSK mode only.
- */
-
-GuacUI.StateManager.registerComponent(
-    new GuacUI.Client.OnScreenKeyboard(),
-    GuacUI.Client.states.OSK
-);
-
-
-/*
- * Set initial state
- */
-
-GuacUI.StateManager.setState(GuacUI.Client.states.INTERACTIVE);
-
-/**
- * Modal status display. Displays a message to the user, covering the entire
- * screen.
- * 
- * Normally, this should only be used when user interaction with other
- * components is impossible.
- * 
- * @constructor
- * @augments GuacUI.Component
- */
-GuacUI.Client.ModalStatus = function(text, classname) {
-
-    // Create element hierarchy
-    var outer  = GuacUI.createElement("div", "dialogOuter");
-    var middle = GuacUI.createChildElement(outer, "div", "dialogMiddle");
-    var dialog = GuacUI.createChildElement(middle, "div", "dialog");
-    var status = GuacUI.createChildElement(dialog, "p", "status");
-
-    // Set classname if given
-    if (classname)
-        GuacUI.addClass(outer, classname);
-
-    // Set status text
-    status.textContent = text;
-
-    this.show = function() {
-        document.body.appendChild(outer);
-    };
-
-    this.hide = function() {
-        document.body.removeChild(outer);
-    };
-
-};
-
-GuacUI.Client.ModalStatus.prototype = new GuacUI.Component();
-
-/**
- * Flattens the attached Guacamole.Client, storing the result within the
- * connection history.
- */
-GuacUI.Client.updateThumbnail = function() {
-
-    // Get screenshot
-    var canvas = GuacUI.Client.attachedClient.flatten();
-
-    // Calculate scale of thumbnail (max 320x240, max zoom 100%)
-    var scale = Math.min(
-        320 / canvas.width,
-        240 / canvas.height,
-        1
-    );
-
-    // Create thumbnail canvas
-    var thumbnail = document.createElement("canvas");
-    thumbnail.width  = canvas.width*scale;
-    thumbnail.height = canvas.height*scale;
-
-    // Scale screenshot to thumbnail
-    var context = thumbnail.getContext("2d");
-    context.drawImage(canvas,
-        0, 0, canvas.width, canvas.height,
-        0, 0, thumbnail.width, thumbnail.height
-    );
-
-    // Save thumbnail to history
-    var id = decodeURIComponent(window.location.search.substring(4));
-    GuacamoleHistory.update(id, thumbnail.toDataURL());
-
-};
-
-/**
- * Updates the scale of the attached Guacamole.Client based on current window
- * size and "auto-fit" setting.
- */
-GuacUI.Client.updateDisplayScale = function() {
-
-    // Currently attacched client
-    var guac = GuacUI.Client.attachedClient;
-
-    // If auto-fit is enabled, scale display
-    if (!GuacUI.Client.overrideAutoFit
-         && GuacUI.sessionState.getProperty("auto-fit")) {
-
-        // Calculate scale to fit screen
-        var fit_scale = Math.min(
-            window.innerWidth  / guac.getWidth(),
-            window.innerHeight / guac.getHeight()
-        );
-          
-        // Scale client
-        if (fit_scale != guac.getScale())
-            guac.scale(fit_scale);
-
-    }
-
-    // Otherwise, scale to 100%
-    else if (guac.getScale() != 1.0)
-        guac.scale(1.0);
-
-};
-
-/**
- * Updates the document title based on the connection name.
- */
-GuacUI.Client.updateTitle = function () {
-    
-    if (GuacUI.Client.titlePrefix)
-        document.title = GuacUI.Client.titlePrefix + " " + GuacUI.Client.connectionName;
-    else
-        document.title = GuacUI.Client.connectionName;
-
-};
-
-/**
- * Hides the currently-visible status overlay, if any.
- */
-GuacUI.Client.hideStatus = function() {
-    if (GuacUI.Client.visibleStatus)
-        GuacUI.Client.visibleStatus.hide();
-    GuacUI.Client.visibleStatus = null;
-};
-
-/**
- * Displays a status overlay with the given text.
- */
-GuacUI.Client.showStatus = function(status) {
-    GuacUI.Client.hideStatus();
-
-    GuacUI.Client.visibleStatus = new GuacUI.Client.ModalStatus(status);
-    GuacUI.Client.visibleStatus.show();
-};
-
-/**
- * Displays an error status overlay with the given text.
- */
-GuacUI.Client.showError = function(status) {
-    GuacUI.Client.hideStatus();
-
-    GuacUI.Client.visibleStatus =
-        new GuacUI.Client.ModalStatus(status, "guac-error");
-    GuacUI.Client.visibleStatus.show();
-}
-
-/**
- * Attaches a Guacamole.Client to the client UI, such that Guacamole events
- * affect the UI, and local events affect the Guacamole.Client.
- * 
- * @param {Guacamole.Client} guac The Guacamole.Client to attach to the UI.
- */
-GuacUI.Client.attach = function(guac) {
-
-    // Store attached client
-    GuacUI.Client.attachedClient = guac;
-
-    // Get display element
-    var guac_display = guac.getDisplay();
-
-    /*
-     * Update the scale of the display when the client display size changes.
-     */
-
-    guac.onresize = function(width, height) {
-        GuacUI.Client.updateDisplayScale();
-    }
-
-    /*
-     * Update UI when the state of the Guacamole.Client changes.
-     */
-
-    guac.onstatechange = function(clientState) {
-
-        switch (clientState) {
-
-            // Idle
-            case 0:
-                GuacUI.Client.showStatus("Idle.");
-                GuacUI.Client.titlePrefix = "[Idle]";
-                break;
-
-            // Connecting
-            case 1:
-                GuacUI.Client.showStatus("Connecting...");
-                GuacUI.Client.titlePrefix = "[Connecting...]";
-                break;
-
-            // Connected + waiting
-            case 2:
-                GuacUI.Client.showStatus("Connected, waiting for first update...");
-                GuacUI.Client.titlePrefix = "[Waiting...]";
-                break;
-
-            // Connected
-            case 3:
-
-                GuacUI.Client.hideStatus();
-                GuacUI.Client.titlePrefix = null;
-
-                // Update clipboard with current data
-                if (GuacUI.sessionState.getProperty("clipboard"))
-                    guac.setClipboard(GuacUI.sessionState.getProperty("clipboard"));
-
-                break;
-
-            // Disconnecting
-            case 4:
-                GuacUI.Client.showStatus("Disconnecting...");
-                GuacUI.Client.titlePrefix = "[Disconnecting...]";
-                break;
-
-            // Disconnected
-            case 5:
-                GuacUI.Client.showStatus("Disconnected.");
-                GuacUI.Client.titlePrefix = "[Disconnected]";
-                break;
-
-            // Unknown status code
-            default:
-                GuacUI.Client.showStatus("[UNKNOWN STATUS]");
-
-        }
-
-        GuacUI.Client.updateTitle();
-
-    };
-
-    /*
-     * Change UI to reflect the connection name
-     */
-
-    guac.onname = function(name) {
-        GuacUI.Client.connectionName = name;
-        GuacUI.Client.updateTitle();
-    };
-
-    /*
-     * Disconnect and display an error message when the Guacamole.Client
-     * receives an error.
-     */
-
-    guac.onerror = function(error) {
-
-        // Disconnect, if connected
-        guac.disconnect();
-
-        // Display error message
-        GuacUI.Client.showError(error);
-        
-    };
-
-    // Server copy handler
-    guac.onclipboard = function(data) {
-        GuacUI.sessionState.setProperty("clipboard", data);
-    };
-
-    /*
-     * Prompt to download file when file received.
-     */
-
-    function getSizeString(bytes) {
-
-        if (bytes > 1000000000)
-            return (bytes / 1000000000).toFixed(1) + " GB";
-
-        else if (bytes > 1000000)
-            return (bytes / 1000000).toFixed(1) + " MB";
-
-        else if (bytes > 1000)
-            return (bytes / 1000).toFixed(1) + " KB";
-
-        else
-            return bytes + " B";
-
-    }
-
-    guac.onblob = function(blob) {
-
-        var download = new GuacUI.Download(blob.name);
-        download.updateProgress(getSizeString(0));
-
-        GuacUI.Client.notification_area.appendChild(download.getElement());
-
-        // Update progress as data is received
-        blob.ondata = function() {
-            download.updateProgress(getSizeString(blob.getLength()));
-        };
-
-        // When complete, prompt for download
-        blob.oncomplete = function() {
-
-            download.ondownload = function() {
-                saveAs(blob.getBlob(), blob.name);
-            };
-
-            download.complete();
-
-        };
-
-        // When close clicked, remove from notification area
-        download.onclose = function() {
-            GuacUI.Client.notification_area.removeChild(download.getElement());
-        };
-
-    };
-
-    /*
-     * Do nothing when the display element is clicked on.
-     */
-
-    guac_display.onclick = function(e) {
-        e.preventDefault();
-        return false;
-    };
-
-    /*
-     * Handle mouse and touch events relative to the display element.
-     */
-
-    // Mouse
-    var mouse = new Guacamole.Mouse(guac_display);
-    var touch = new Guacamole.Mouse.Touchpad(guac_display);
-    touch.onmousedown = touch.onmouseup = touch.onmousemove =
-    mouse.onmousedown = mouse.onmouseup = mouse.onmousemove =
-        function(mouseState) {
-       
-            // Determine mouse position within view
-            var mouse_view_x = mouseState.x + guac_display.offsetLeft - window.pageXOffset;
-            var mouse_view_y = mouseState.y + guac_display.offsetTop  - window.pageYOffset;
-
-            // Determine viewport dimensioins
-            var view_width  = GuacUI.Client.viewport.offsetWidth;
-            var view_height = GuacUI.Client.viewport.offsetHeight;
-
-            // Determine scroll amounts based on mouse position relative to document
-
-            var scroll_amount_x;
-            if (mouse_view_x > view_width)
-                scroll_amount_x = mouse_view_x - view_width;
-            else if (mouse_view_x < 0)
-                scroll_amount_x = mouse_view_x;
-            else
-                scroll_amount_x = 0;
-
-            var scroll_amount_y;
-            if (mouse_view_y > view_height)
-                scroll_amount_y = mouse_view_y - view_height;
-            else if (mouse_view_y < 0)
-                scroll_amount_y = mouse_view_y;
-            else
-                scroll_amount_y = 0;
-
-            // Scroll (if necessary) to keep mouse on screen.
-            window.scrollBy(scroll_amount_x, scroll_amount_y);
-
-            // Scale event by current scale
-            var scaledState = new Guacamole.Mouse.State(
-                    mouseState.x / guac.getScale(),
-                    mouseState.y / guac.getScale(),
-                    mouseState.left,
-                    mouseState.middle,
-                    mouseState.right,
-                    mouseState.up,
-                    mouseState.down);
-
-            // Send mouse event
-            guac.sendMouseState(scaledState);
-            
-        };
-
-    /*
-     * Route document-level keyboard events to the client.
-     */
-
-
-    var keyboard = new Guacamole.Keyboard(document);
-    var show_keyboard_gesture_possible = true;
-
-    keyboard.onkeydown = function (keysym) {
-        guac.sendKeyEvent(1, keysym);
-
-        // If key is NOT one of the expected keys, gesture not possible
-        if (keysym != 0xFFE3 && keysym != 0xFFE9 && keysym != 0xFFE1)
-            show_keyboard_gesture_possible = false;
-
-    };
-
-    keyboard.onkeyup = function (keysym) {
-        guac.sendKeyEvent(0, keysym);
-
-        // If lifting up on shift, toggle keyboard if rest of gesture
-        // conditions satisfied
-        if (show_keyboard_gesture_possible && keysym == 0xFFE1) {
-            if (keyboard.pressed[0xFFE3] && keyboard.pressed[0xFFE9]) {
-
-                // If in INTERACTIVE mode, switch to OSK
-                if (GuacUI.StateManager.getState() == GuacUI.Client.states.INTERACTIVE)
-                    GuacUI.StateManager.setState(GuacUI.Client.states.OSK);
-
-                // If in OSK mode, switch to INTERACTIVE 
-                else if (GuacUI.StateManager.getState() == GuacUI.Client.states.OSK)
-                    GuacUI.StateManager.setState(GuacUI.Client.states.INTERACTIVE);
-
-            }
-        }
-
-        // Detect if no keys are pressed
-        var reset_gesture = true;
-        for (var pressed in keyboard.pressed) {
-            reset_gesture = false;
-            break;
-        }
-
-        // Reset gesture state if possible
-        if (reset_gesture)
-            show_keyboard_gesture_possible = true;
-
-    };
-
-    var thumbnail_update_interval = null;
-
-    window.onblur = function() {
-
-        // Regularly update screenshot if window not visible
-        if (!thumbnail_update_interval)
-            thumbnail_update_interval =
-                window.setInterval(GuacUI.Client.updateThumbnail, 1000);
-
-    };
-
-    window.onfocus = function() {
-        if (thumbnail_update_interval) {
-            window.clearInterval(thumbnail_update_interval);
-            thumbnail_update_interval = null;
-        }
-    };
-
-    /*
-     * Disconnect and update thumbnail on close
-     */
-    window.onunload = function() {
-
-        GuacUI.Client.updateThumbnail();
-        guac.disconnect();
-
-    };
-
-    /*
-     * Send size events on resize
-     */
-    window.onresize = function() {
-
-        guac.sendSize(window.innerWidth, window.innerHeight);
-        GuacUI.Client.updateDisplayScale();
-
-    };
-
-    GuacUI.sessionState.onchange = function(old_state, new_state, name) {
-        if (name == "clipboard")
-            guac.setClipboard(new_state[name]);
-        else if (name == "auto-fit")
-            GuacUI.Client.updateDisplayScale();
-    };
-
-    var long_press_start_x = 0;
-    var long_press_start_y = 0;
-    var longPressTimeout = null;
-
-    GuacUI.Client.startLongPressDetect = function() {
-
-        if (!longPressTimeout) {
-
-            longPressTimeout = window.setTimeout(function() {
-                longPressTimeout = null;
-
-                // If screen shrunken, show magnifier
-                if (GuacUI.Client.attachedClient.getScale() < 1.0)
-                    GuacUI.StateManager.setState(GuacUI.Client.states.MAGNIFIER);
-
-                // Otherwise, if screen too big to fit, use panning mode
-                else if (
-                       GuacUI.Client.attachedClient.getWidth() > window.innerWidth
-                    || GuacUI.Client.attachedClient.getHeight() > window.innerHeight
-                )
-                    GuacUI.StateManager.setState(GuacUI.Client.states.PAN);
-
-                // Otherwise, just show a hint
-                else
-                    GuacUI.StateManager.setState(GuacUI.Client.states.WAIT_TYPING);
-            }, GuacUI.Client.LONG_PRESS_DETECT_TIMEOUT);
-
-        }
-    };
-
-    GuacUI.Client.stopLongPressDetect = function() {
-        window.clearTimeout(longPressTimeout);
-        longPressTimeout = null;
-    };
-
-    // Detect long-press at bottom of screen
-    GuacUI.Client.display.addEventListener('touchstart', function(e) {
-        
-        // Record touch location
-        if (e.touches.length == 1) {
-            var touch = e.touches[0];
-            long_press_start_x = touch.screenX;
-            long_press_start_y = touch.screenY;
-        }
-        
-        // Start detection
-        GuacUI.Client.startLongPressDetect();
-        
-    }, true);
-
-    // Stop detection if touch moves significantly
-    GuacUI.Client.display.addEventListener('touchmove', function(e) {
-        
-        // If touch distance from start exceeds threshold, cancel long press
-        var touch = e.touches[0];
-        if (Math.abs(touch.screenX - long_press_start_x) >= GuacUI.Client.LONG_PRESS_MOVEMENT_THRESHOLD
-            || Math.abs(touch.screenY - long_press_start_y) >= GuacUI.Client.LONG_PRESS_MOVEMENT_THRESHOLD)
-            GuacUI.Client.stopLongPressDetect();
-        
-    }, true);
-
-    // Stop detection if press stops
-    GuacUI.Client.display.addEventListener('touchend', GuacUI.Client.stopLongPressDetect, true);
-
-};
-
diff --git a/guacamole/src/main/webapp/scripts/guac-ui.js b/guacamole/src/main/webapp/scripts/guac-ui.js
deleted file mode 100644
index d43a9e4..0000000
--- a/guacamole/src/main/webapp/scripts/guac-ui.js
+++ /dev/null
@@ -1,1425 +0,0 @@
-/*
- *  Guacamole - Clientless Remote Desktop
- *  Copyright (C) 2010  Michael Jumper
- *
- *  This program is free software: you can redistribute it and/or modify
- *  it under the terms of the GNU Affero General Public License as published by
- *  the Free Software Foundation, either version 3 of the License, or
- *  (at your option) any later version.
- *
- *  This program is distributed in the hope that it will be useful,
- *  but WITHOUT ANY WARRANTY; without even the implied warranty of
- *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- *  GNU Affero General Public License for more details.
- *
- *  You should have received a copy of the GNU Affero General Public License
- *  along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-/**
- * Main Guacamole UI namespace.
- * @namespace
- */
-var GuacUI = GuacUI || {};
-
-/**
- * Current session state, including settings.
- */
-GuacUI.sessionState = new GuacamoleSessionState();
-
-/**
- * Creates a new element having the given tagname and CSS class.
- */
-GuacUI.createElement = function(tagname, classname) {
-    var new_element = document.createElement(tagname);
-    if (classname) new_element.className = classname;
-    return new_element;
-};
-
-/**
- * Creates a new element having the given tagname, CSS class, and specified
- * parent element.
- */
-GuacUI.createChildElement = function(parent, tagname, classname) {
-    var element = GuacUI.createElement(tagname, classname);
-    parent.appendChild(element);
-    return element;
-};
-
-/**
- * Creates a new row within the given table having a single header cell
- * with the given title, and a single value cell. The value cell is returned.
- */
-GuacUI.createTabulatedContainer = function(table, title) {
-
-    // Create elements
-    var row    = GuacUI.createChildElement(table, "tr");
-    var header = GuacUI.createChildElement(row, "th");
-    var cell   = GuacUI.createChildElement(row, "td");
-
-    // Set title, return cell
-    header.textContent = title;
-    return cell;
-
-};
-
-/**
- * Adds the given CSS class to the given element.
- */
-GuacUI.addClass = function(element, classname) {
-
-    // If supported, use native classlist for addClass()
-    if (Node.classlist)
-        element.classList.add(classname);
-
-    // Otherwise, simply add new class via string manipulation
-    else
-        element.className += " " + classname;
-
-};
-
-/**
- * Removes the given CSS class from the given element.
- */
-GuacUI.removeClass = function(element, classname) {
-
-    // If supported, use native classlist for removeClass()
-    if (Node.classlist)
-        element.classList.remove(classname);
-
-    // Otherwise, remove class via string manipulation
-    else {
-
-        // Filter out classes with given name
-        element.className = element.className.replace(/([^ ]+)[ ]*/g,
-            function(match, testClassname, spaces, offset, string) {
-
-                // If same class, remove
-                if (testClassname == classname)
-                    return "";
-
-                // Otherwise, allow
-                return match;
-                
-            }
-        );
-
-    } // end if no classlist support
-
-};
-
-/**
- * Opens the connection group having the given ID in a new tab/window.
- * 
- * @param {String} id The ID of the connection group to open.
- * @param {String} parameters Any parameters that should be added to the URL,
- *                            for sake of authentication.
- */
-GuacUI.openConnectionGroup = function(id, parameters) {
-    GuacUI.openObject("g/" + id, parameters);
-};
-
-/**
- * Opens the connection having the given ID in a new tab/window.
- * 
- * @param {String} id The ID of the connection to open.
- * @param {String} parameters Any parameters that should be added to the URL,
- *                            for sake of authentication.
- */
-GuacUI.openConnection = function(id, parameters) {
-    GuacUI.openObject("c/" + id, parameters);
-};
-
-/**
- * Opens the object having the given ID in a new tab/window. The ID must
- * include the relevant prefix.
- * 
- * @param {String} id The ID of the object to open, including prefix.
- * @param {String} parameters Any parameters that should be added to the URL,
- *                            for sake of authentication.
- */
-GuacUI.openObject = function(id, parameters) {
-
-    // Get URL
-    var url = "client.xhtml?id=" + encodeURIComponent(id);
-
-    // Add parameters, if given
-    if (parameters)
-        url += "&" + parameters;
-
-    // Attempt to focus existing window
-    var current = window.open(null, id);
-
-    // If window did not already exist, set up as
-    // Guacamole client
-    if (!current.GuacUI)
-        window.open(url, id);
-
-};
-
-/**
- * Object describing the UI's level of audio support. If the user has request
- * that audio be disabled, this object will pretend that audio is not
- * supported.
- */
-GuacUI.Audio = new (function() {
-
-    var codecs = [
-        'audio/ogg; codecs="vorbis"',
-        'audio/mp4; codecs="mp4a.40.5"',
-        'audio/mpeg; codecs="mp3"',
-        'audio/webm; codecs="vorbis"',
-        'audio/wav; codecs=1'
-    ];
-
-    var probably_supported = [];
-    var maybe_supported = [];
-
-    /**
-     * Array of all supported audio mimetypes, ordered by liklihood of
-     * working.
-     */
-    this.supported = [];
-
-    // If sound disabled, we're done now.
-    if (GuacUI.sessionState.getProperty("disable-sound"))
-        return;
-    
-    // Build array of supported audio formats
-    codecs.forEach(function(mimetype) {
-
-        var audio = new Audio();
-        var support_level = audio.canPlayType(mimetype);
-
-        // Trim semicolon and trailer
-        var semicolon = mimetype.indexOf(";");
-        if (semicolon != -1)
-            mimetype = mimetype.substring(0, semicolon);
-
-        // Partition by probably/maybe
-        if (support_level == "probably")
-            probably_supported.push(mimetype);
-        else if (support_level == "maybe")
-            maybe_supported.push(mimetype);
-
-    });
-
-    // Add probably supported types first
-    Array.prototype.push.apply(
-        this.supported, probably_supported);
-
-    // Prioritize "maybe" supported types second
-    Array.prototype.push.apply(
-        this.supported, maybe_supported);
-
-})();
-
-/**
- * Object describing the UI's level of video support.
- */
-GuacUI.Video = new (function() {
-
-    var codecs = [
-        'video/ogg; codecs="theora, vorbis"',
-        'video/mp4; codecs="avc1.4D401E, mp4a.40.5"',
-        'video/webm; codecs="vp8.0, vorbis"'
-    ];
-
-    var probably_supported = [];
-    var maybe_supported = [];
-
-    /**
-     * Array of all supported video mimetypes, ordered by liklihood of
-     * working.
-     */
-    this.supported = [];
-    
-    // Build array of supported audio formats
-    codecs.forEach(function(mimetype) {
-
-        var video = document.createElement("video");
-        var support_level = video.canPlayType(mimetype);
-
-        // Trim semicolon and trailer
-        var semicolon = mimetype.indexOf(";");
-        if (semicolon != -1)
-            mimetype = mimetype.substring(0, semicolon);
-
-        // Partition by probably/maybe
-        if (support_level == "probably")
-            probably_supported.push(mimetype);
-        else if (support_level == "maybe")
-            maybe_supported.push(mimetype);
-
-    });
-
-    // Add probably supported types first
-    Array.prototype.push.apply(
-        this.supported, probably_supported);
-
-    // Prioritize "maybe" supported types second
-    Array.prototype.push.apply(
-        this.supported, maybe_supported);
-
-})();
-
-
-/**
- * Central registry of all components for all states.
- */
-GuacUI.StateManager = new (function() {
-
-    /**
-     * The current state.
-     */
-    var current_state = null;
-
-    /**
-     * Array of arrays of components, indexed by the states they are in.
-     */
-    var components = [];
-
-    /**
-     * Registers the given component with this state manager, to be shown
-     * during the given states.
-     * 
-     * @param {GuacUI.Component} component The component to register.
-     * @param {Number} [...] The list of states this component should be
-     *                       visible during.
-     */
-    this.registerComponent = function(component) {
-
-        // For each state specified, add the given component
-        for (var i=1; i<arguments.length; i++) {
-
-            // Get specified state
-            var state = arguments[i];
-
-            // Get array of components in that state
-            var component_array = components[state];
-            if (!component_array)
-                component_array = components[state] = [];
-
-            // Add component
-            component_array.push(component);
-
-        }
-
-    };
-
-    function allComponents(components, name) {
-
-        // Invoke given function on all components in array
-        for (var i=0; i<components.length; i++)
-            components[i][name]();
-
-    }
-
-    /**
-     * Sets the current visible state.
-     */
-    this.setState = function(state) {
-
-        // Hide components in current state
-        if (current_state && components[current_state])
-            allComponents(components[current_state], "hide");
-
-        // Show all components in new state
-        current_state = state;
-        if (components[state])
-            allComponents(components[state], "show");
-
-    };
-
-    /**
-     * Returns the current visible state.
-     */
-    this.getState = function() {
-        return current_state;
-    };
-
-})();
-
-
-/**
- * Abstract component which can be registered with GuacUI and shown or hidden
- * dynamically based on interface mode.
- * 
- * @constructor
- */
-GuacUI.Component = function() {
-
-    /**
-     * Called whenever this component needs to be shown and activated.
-     * @event
-     */
-    this.onshow = null;
-
-    /**
-     * Called whenever this component needs to be hidden and deactivated.
-     * @event
-     */
-    this.onhide = null;
-
-};
-
-/**
- * A Guacamole UI component which can be repositioned by dragging.
- * 
- * @constructor
- * @augments GuacUI.Component
- */
-GuacUI.DraggableComponent = function(element) {
-
-    var draggable_component = this;
-
-    var position_x = 0;
-    var position_y = 0;
-
-    var start_x = 0;
-    var start_y = 0;
-
-    /*
-     * Record drag start when finger hits element
-     */
-    if (element)
-        element.addEventListener("touchstart", function(e) {
-            
-            if (e.touches.length == 1) {
-
-                start_x = e.touches[0].screenX;
-                start_y = e.touches[0].screenY;
-
-            }
-       
-            e.stopPropagation();
-       
-        }, true);
-
-    /*
-     * Update position based on last touch
-     */
-    if (element)
-        element.addEventListener("touchmove", function(e) {
-            
-            if (e.touches.length == 1) {
-                
-                var new_x = e.touches[0].screenX;
-                var new_y = e.touches[0].screenY;
-
-                position_x += new_x - start_x;
-                position_y += new_y - start_y;
-
-                start_x = new_x;
-                start_y = new_y;
-
-                // Move magnifier to new position
-                draggable_component.move(position_x, position_y);
-
-            }
-            
-            e.preventDefault();
-            e.stopPropagation();
-
-        }, true);
-
-    if (element)
-        element.addEventListener("touchend", function(e) {
-            e.stopPropagation();
-        }, true);
-            
-    /**
-     * Moves this component to the specified location relative to its normal
-     * position.
-     * 
-     * @param {Number} x The X coordinate in pixels.
-     * @param {Number} y The Y coordinate in pixels.
-     */
-    this.move = function(x, y) {
-
-        element.style.WebkitTransform =
-        element.style.MozTransform =
-        element.style.OTransform =
-        element.style.msTransform =
-        element.style.transform = "translate("
-            + x + "px, " + y + "px)";
-
-        if (draggable_component.onmove)
-            draggable_component.onmove(x, y);
-
-    };
-
-    /**
-     * Trigered whenever this element is moved.
-     * 
-     * @event
-     * @param {Number} x The new X coordinate.
-     * @param {Number} y The new Y coordinate.
-     */
-    this.onmove = null;
-
-};
-
-/**
- * A connection UI object which can be easily added to a list of connections
- * for sake of display.
- */
-GuacUI.ListConnection = function(connection) {
-
-    /**
-     * Reference to this connection.
-     * @private
-     */
-    var guac_connection = this;
-
-    /**
-     * The actual connection associated with this connection UI element.
-     */
-    this.connection = connection;
-
-    /**
-     * Fired when this connection is clicked.
-     * @event
-     */
-    this.onclick = null;
-
-    // Create connection display elements
-    var element   = GuacUI.createElement("div",  "connection");
-    var caption   = GuacUI.createChildElement(element, "div",  "caption");
-    var protocol  = GuacUI.createChildElement(caption, "div",  "protocol");
-    var name      = GuacUI.createChildElement(caption, "span", "name");
-    GuacUI.createChildElement(protocol, "div",  "icon " + connection.protocol);
-
-    element.addEventListener("click", function(e) {
-
-        // Prevent click from affecting parent
-        e.stopPropagation();
-        e.preventDefault();
-
-        // Fire event if defined
-        if (guac_connection.onclick)
-            guac_connection.onclick();
-
-    }, false);
-
-    // Set name
-    name.textContent = connection.name;
-
-    // Add active usages (if any)
-    var active_users = connection.currentUsage();
-    if (active_users > 0) {
-        var usage = GuacUI.createChildElement(caption, "span", "usage");
-        usage.textContent = "Currently in use by " + active_users + " user(s)";
-        GuacUI.addClass(element, "in-use");
-    }
-
-    /**
-     * Returns the DOM element representing this connection.
-     */
-    this.getElement = function() {
-        return element;
-    };
-
-};
-
-/**
- * A paging component. Elements can be added via the addElement() function,
- * and will only be shown if they are on the current page, set via setPage().
- * 
- * Beware that all elements will be added to the given container element, and
- * all children of the container element will be removed when the page is
- * changed.
- */
-GuacUI.Pager = function(container) {
-
-    var guac_pager = this;
-
-    /**
-     * A container for all pager control buttons.
-     */
-    var element = GuacUI.createElement("div", "pager");
-
-    /**
-     * All displayable elements.
-     */
-    var elements = [];
-
-    /**
-     * The number of elements to display per page.
-     */
-    this.page_capacity = 10;
-
-    /**
-     * The number of pages to generate a window for.
-     */
-    this.window_size = 11;
-
-    /**
-     * The current page, where 0 is the first page.
-     */
-    this.current_page = 0;
-
-    /**
-     * The last existing page.
-     */
-    this.last_page = 0;
-
-    function update_display() {
-
-        var i;
-
-        // Calculate first and last elements of page (where the last element
-        // is actually the first element of the next page)
-        var first_element = guac_pager.current_page * guac_pager.page_capacity;
-        var last_element  = Math.min(elements.length,
-                first_element + guac_pager.page_capacity);
-
-        // Clear contents, add elements
-        container.innerHTML = "";
-        for (i=first_element; i < last_element; i++)
-            container.appendChild(elements[i]);
-
-        // Update buttons
-        element.innerHTML = "";
-
-        // Create first and prev buttons
-        var first = GuacUI.createChildElement(element, "div", "first-page icon");
-        var prev = GuacUI.createChildElement(element, "div", "prev-page icon");
-
-        // Handle prev/first
-        if (guac_pager.current_page > 0) {
-            first.onclick = function() {
-                guac_pager.setPage(0);
-            };
-
-            prev.onclick = function() {
-                guac_pager.setPage(guac_pager.current_page - 1);
-            };
-        }
-        else {
-            GuacUI.addClass(first, "disabled");
-            GuacUI.addClass(prev, "disabled");
-        }
-
-        // Calculate page jump window start/end
-        var window_start = guac_pager.current_page - (guac_pager.window_size - 1) / 2;
-        var window_end = window_start + guac_pager.window_size - 1;
-
-        // Shift window as necessary
-        if (window_start < 0) {
-            window_end = Math.min(guac_pager.last_page, window_end - window_start);
-            window_start = 0;
-        }
-        else if (window_end > guac_pager.last_page) {
-            window_start = Math.max(0, window_start - window_end + guac_pager.last_page);
-            window_end = guac_pager.last_page;
-        }
-        
-        // Add ellipsis if window after beginning
-        if (window_start != 0)
-            GuacUI.createChildElement(element, "div", "more-pages").textContent = "...";
-        
-        // Add page jumps
-        for (i=window_start; i<=window_end; i++) {
-
-            // Create clickable element containing page number
-            var jump = GuacUI.createChildElement(element, "div", "set-page");
-            jump.textContent = i+1;
-            
-            // Mark current page
-            if (i == guac_pager.current_page)
-                GuacUI.addClass(jump, "current");
-
-            // If not current, add click event
-            else
-                (function(page_number) {
-                    jump.onclick = function() {
-                        guac_pager.setPage(page_number);
-                    };
-                })(i);
-
-        }
-
-        // Add ellipsis if window before end
-        if (window_end != guac_pager.last_page)
-            GuacUI.createChildElement(element, "div", "more-pages").textContent = "...";
-        
-        // Create next and last buttons
-        var next = GuacUI.createChildElement(element, "div", "next-page icon");
-        var last = GuacUI.createChildElement(element, "div", "last-page icon");
-
-        // Handle next/last
-        if (guac_pager.current_page < guac_pager.last_page) {
-            next.onclick = function() {
-                guac_pager.setPage(guac_pager.current_page + 1);
-            };
-            
-            last.onclick = function() {
-                guac_pager.setPage(guac_pager.last_page);
-            };
-        }
-        else {
-            GuacUI.addClass(next, "disabled");
-            GuacUI.addClass(last, "disabled");
-        }
-
-    }
-
-    /**
-     * Adds the given element to the set of displayable elements.
-     */
-    this.addElement = function(element) {
-        elements.push(element);
-        guac_pager.last_page = Math.max(0,
-            Math.floor((elements.length - 1) / guac_pager.page_capacity));
-    };
-
-    /**
-     * Sets the current page, where 0 is the first page.
-     */
-    this.setPage = function(number) {
-        guac_pager.current_page = number;
-        update_display();
-    };
-
-    /**
-     * Returns the element representing the buttons of this pager.
-     */
-    this.getElement = function() {
-        return element;
-    };
-
-};
-
-
-/**
- * Interface object which displays the progress of a download, ultimately
- * becoming a download link once complete.
- * 
- * @constructor
- * @param {String} filename The name the file will have once complete.
- */
-GuacUI.Download = function(filename) {
-
-    /**
-     * Reference to this GuacUI.Download.
-     * @private
-     */
-    var guac_download = this;
-
-    /**
-     * The outer div representing the notification.
-     * @private
-     */
-    var element = GuacUI.createElement("div", "download notification");
-
-    /**
-     * Title bar describing the notification.
-     * @private
-     */
-    var title = GuacUI.createChildElement(element, "div", "title-bar");
-
-    /**
-     * Close button for removing the notification.
-     * @private
-     */
-    var close_button = GuacUI.createChildElement(title, "div", "close");
-    close_button.onclick = function() {
-        if (guac_download.onclose)
-            guac_download.onclose();
-    };
-
-    GuacUI.createChildElement(title, "div", "title").textContent =
-        "File Transfer";
-
-    GuacUI.createChildElement(element, "div", "caption").textContent =
-        filename + " ";
-
-    /**
-     * Progress bar and status.
-     * @private
-     */
-    var progress = GuacUI.createChildElement(element, "div", "progress");
-
-    /**
-     * Updates the content of the progress indicator with the given text.
-     * 
-     * @param {String} text The text to assign to the progress indicator.
-     */
-    this.updateProgress = function(text) {
-        progress.textContent = text;
-    };
-
-    /**
-     * Removes the progress indicator and replaces it with a download button.
-     */
-    this.complete = function() {
-
-        element.removeChild(progress);
-        GuacUI.addClass(element, "complete");
-
-        var download = GuacUI.createChildElement(element, "div", "download");
-        download.textContent = "Download";
-        download.onclick = function() {
-            if (guac_download.ondownload)
-                guac_download.ondownload();
-        };
-
-    };
-
-    /**
-     * Returns the element representing this notification.
-     */
-    this.getElement = function() {
-        return element;
-    };
-
-    /**
-     * Called when the close button of this notification is clicked.
-     * @event
-     */
-    this.onclose = null;
-
-    /**
-     * Called when the download button of this notification is clicked.
-     * @event
-     */
-    this.ondownload = null;
-
-};
-
-/**
- * A grouping component. Child elements can be added via the addElement()
- * function. By default, groups display as collapsed.
- */
-GuacUI.ListGroup = function(caption) {
-
-    /**
-     * Reference to this group.
-     * @private
-     */
-    var guac_group = this;
-
-    /**
-     * Whether this group is empty.
-     * @private
-     */
-    var empty = true;
-
-    /**
-     * A container for for the list group itself.
-     */
-    var element = GuacUI.createElement("div", "group empty");
-
-    // Create connection display elements
-    var caption_element = GuacUI.createChildElement(element, "div",  "caption");
-    var caption_icon    = GuacUI.createChildElement(caption_element, "div",  "icon group");
-    GuacUI.createChildElement(caption_element, "div",  "icon type");
-    GuacUI.createChildElement(caption_element, "span", "name").textContent = caption;
-
-    /**
-     * A container for all children of this list group.
-     */
-    var elements = GuacUI.createChildElement(element, "div", "children");
-
-    /**
-     * Whether this group is expanded.
-     * 
-     * @type Boolean
-     */
-    this.expanded = false;
-
-    /**
-     * Fired when this group is clicked.
-     * @event
-     */
-    this.onclick = null;
-
-    /**
-     * Returns the element representing this notification.
-     */
-    this.getElement = function() {
-        return element;
-    };
-
-    /**
-     * Adds an element as a child of this group.
-     */
-    this.addElement = function(child) {
-
-        // Mark as non-empty
-        if (empty) {
-            GuacUI.removeClass(element, "empty");
-            empty = false;
-        }
-
-        elements.appendChild(child);
-
-    };
-
-    /**
-     * Expands the list group, revealing all children of the group. This
-     * functionality requires supporting CSS.
-     */
-    this.expand = function() {
-        GuacUI.addClass(element, "expanded");
-        guac_group.expanded = true;
-    };
-
-    /**
-     * Collapses the list group, hiding all children of the group. This
-     * functionality requires supporting CSS.
-     */
-    this.collapse = function() {
-        GuacUI.removeClass(element, "expanded");
-        guac_group.expanded = false;
-    };
-
-    // Toggle when icon is clicked
-    caption_icon.addEventListener("click", function(e) {
-
-        // Prevent click from affecting parent
-        e.stopPropagation();
-        e.preventDefault();
-
-        if (guac_group.expanded)
-            guac_group.collapse();
-        else
-            guac_group.expand();
-
-    }, false);
-
-    // Fire event when any other part is clicked
-    element.addEventListener("click", function(e) {
-
-        // Prevent click from affecting parent
-        e.stopPropagation();
-        e.preventDefault();
-
-        // Fire event if defined
-        if (guac_group.onclick)
-            guac_group.onclick();
-
-    }, false);
-
-}
-
-/**
- * Component which displays a paginated tree view of all groups and their
- * connections.
- * 
- * @constructor
- * @param {GuacamoleService.ConnectionGroup} root_group The group to display
- *                                                      within the view.
- * @param {Number} flags Any flags (such as MULTISELECT or SHOW_CONNECTIONS)
- *                       for modifying the behavior of this group view.
- * @param {Function} group_filter Function which returns true if the given
- *                                group should be displayed and false otherwise.
- * @param {Function} connection_filter Function which returns true if the given
- *                                     connection should be displayed and false
- *                                     otherwise.
- */
-GuacUI.GroupView = function(root_group, flags,
-    group_filter, connection_filter) {
-
-    /**
-     * Reference to this GroupView.
-     * @private
-     */
-    var group_view = this;
-
-    // Group view components
-    var element = GuacUI.createElement("div", "group-view");
-    var list = GuacUI.createChildElement(element, "div", "list");
-
-    /**
-     * Whether multiselect is enabled.
-     */
-    var multiselect = flags & GuacUI.GroupView.MULTISELECT;
-
-    /**
-     * Whether connections should be included in the view.
-     */
-    var show_connections = flags & GuacUI.GroupView.SHOW_CONNECTIONS;
-
-    /**
-     * Whether the root group should be included in the view.
-     */
-    var show_root = flags & GuacUI.GroupView.SHOW_ROOT_GROUP;
-
-    /**
-     * Set of all group checkboxes, indexed by ID. Only applicable when
-     * multiselect is enabled.
-     * @private
-     */
-    var group_checkboxes = {};
-
-    /**
-     * Set of all connection checkboxes, indexed by ID. Only applicable when
-     * multiselect is enabled.
-     * @private
-     */
-    var connection_checkboxes = {};
-
-    /**
-     * Set of all list groups, indexed by associated group ID.
-     * @private
-     */
-    var list_groups = {};
-
-    /**
-     * Set of all connection groups, indexed by ID.
-     */
-    this.groups = {};
-
-    /**
-     * Set of all connections, indexed by ID.
-     */
-    this.connections = {};
-
-    /**
-     * Fired when a connection is clicked.
-     *
-     * @event
-     * @param {GuacamolService.Connection} connection The connection which was
-     *                                                clicked.
-     */
-    this.onconnectionclick = null;
-
-    /**
-     * Fired when a connection group is clicked.
-     *
-     * @event
-     * @param {GuacamolService.ConnectionGroup} group The connection group which 
-     *                                                was clicked.
-     */
-    this.ongroupclick = null;
-
-    /**
-     * Fired when a connection's selected status changes.
-     *
-     * @event
-     * @param {GuacamolService.Connection} connection The connection whose
-     *                                                status changed.
-     * @param {Boolean} selected The new status of the connection.
-     */
-    this.onconnectionchange = null;
-
-    /**
-     * Fired when a connection group's selected status changes.
-     *
-     * @event
-     * @param {GuacamolService.ConnectionGroup} group The connection group whose
-     *                                                status changed.
-     * @param {Boolean} selected The new status of the connection group.
-     */
-    this.ongroupchange = null;
-
-    /**
-     * Returns the element representing this group view.
-     */
-    this.getElement = function() {
-        return element;
-    };
-
-    /**
-     * Sets whether the group with the given ID can be selected. This function
-     * only has an effect when multiselect is enabled.
-     * 
-     * @param {String} id The ID of the group to alter.
-     * @param {Boolean} value Whether the group should be selected.
-     */
-    this.setGroupEnabled = function(id, value) {
-
-        var checkbox = group_checkboxes[id];
-        if (!checkbox)
-            return;
-
-        // If enabled, show checkbox, allow select
-        if (value) {
-            checkbox.style.visibility = "";
-            checkbox.disabled = false;
-        }
-
-        // Otherwise, hide checkbox
-        else {
-            checkbox.style.visibility = "hidden";
-            checkbox.disabled = true;
-        }
-
-    };
-
-    /**
-     * Sets whether the connection with the given ID can be selected. This
-     * function only has an effect when multiselect is enabled.
-     * 
-     * @param {String} id The ID of the connection to alter.
-     * @param {Boolean} value Whether the connection can be selected.
-     */
-    this.setConnectionEnabled = function(id, value) {
-
-        var checkbox = connection_checkboxes[id];
-        if (!checkbox)
-            return;
-
-        // If enabled, show checkbox, allow select
-        if (value) {
-            checkbox.style.visibility = "";
-            checkbox.disabled = false;
-        }
-
-        // Otherwise, hide checkbox
-        else {
-            checkbox.style.visibility = "hidden";
-            checkbox.disabled = true;
-        }
-
-    };
-
-    /**
-     * Sets the current value of the group with the given ID. This function
-     * only has an effect when multiselect is enabled.
-     * 
-     * @param {String} id The ID of the group to change.
-     * @param {Boolean} value Whether the group should be selected.
-     */
-    this.setGroupValue = function(id, value) {
-
-        var checkbox = group_checkboxes[id];
-        if (!checkbox)
-            return;
-
-        checkbox.checked = value;
-
-    };
-
-    /**
-     * Sets the current value of the connection with the given ID. This function
-     * only has an effect when multiselect is enabled.
-     * 
-     * @param {String} id The ID of the connection to change.
-     * @param {Boolean} value Whether the connection should be selected.
-     */
-    this.setConnectionValue = function(id, value) {
-
-        var checkbox = connection_checkboxes[id];
-        if (!checkbox)
-            return;
-
-        checkbox.checked = value;
-
-    };
-
-    /**
-     * Expands the given group and all parent groups all the way up to root.
-     * 
-     * @param {GuacamoleService.ConnectionGroup} group The group that should
-     *                                                 be expanded.
-     */
-    this.expand = function(group) {
-
-        // Skip current group - only need to expand parents
-        group = group.parent;
-
-        // For each group all the way to root
-        while (group !== null) {
-
-            // If list group exists, expand it
-            var list_group = list_groups[group.id];
-            if (list_group)
-                list_group.expand();
-
-            group = group.parent;
-        }
-
-    }
-
-    // Create pager for contents 
-    var pager = new GuacUI.Pager(list);
-    pager.page_capacity = 20;
-
-    /**
-     * Adds the contents of the given group via the given appendChild()
-     * function, but not the given group itself.
-     * 
-     * @param {GuacamoleService.ConnectionGroup} group The group whose contents
-     *                                                 should be added.
-     * @param {Function} appendChild A function which, given an element, will
-     *                               add that element the the display as
-     *                               desired.
-     */
-    function addGroupContents(group, appendChild) {
-
-        var i;
-
-        // Add all contained connections
-        if (show_connections) {
-            for (i=0; i<group.connections.length; i++)
-                addConnection(group.connections[i], appendChild);
-        }
-
-        // Add all contained groups 
-        for (i=0; i<group.groups.length; i++)
-            addGroup(group.groups[i], appendChild);
-
-    }
-
-    /**
-     * Adds the given connection via the given appendChild() function.
-     * 
-     * @param {GuacamoleService.Connection} connection The connection to add.
-     * @param {Function} appendChild A function which, given an element, will
-     *                               add that element the the display as
-     *                               desired.
-     */
-    function addConnection(connection, appendChild) {
-
-        // Do not add connection if filter says "no"
-        if (connection_filter && !connection_filter(connection))
-            return;
-
-        group_view.connections[connection.id] = connection;
-
-        // Add connection to connection list or parent group
-        var guacui_connection = new GuacUI.ListConnection(connection);
-        GuacUI.addClass(guacui_connection.getElement(), "list-item");
-
-        // If multiselect, add checkbox for each connection
-        if (multiselect) {
-
-            var connection_choice = GuacUI.createElement("div", "choice");
-            var connection_checkbox = GuacUI.createChildElement(connection_choice, "input");
-            connection_checkbox.setAttribute("type", "checkbox");
-            
-            connection_choice.appendChild(guacui_connection.getElement());
-            appendChild(connection_choice);
-
-            function fire_connection_change(e) {
-
-                // Prevent click from affecting parent
-                e.stopPropagation();
-
-                // Fire event if handler defined
-                if (group_view.onconnectionchange)
-                    group_view.onconnectionchange(connection, this.checked);
-
-            }
-
-            // Fire change events when checkbox modified
-            connection_checkbox.addEventListener("click",  fire_connection_change, false);
-            connection_checkbox.addEventListener("change", fire_connection_change, false);
-
-            // Add checbox to set of connection checkboxes
-            connection_checkboxes[connection.id] = connection_checkbox;
-
-        }
-        else
-            appendChild(guacui_connection.getElement());
-
-        // Fire click events when connection clicked
-        guacui_connection.onclick = function() {
-            if (group_view.onconnectionclick)
-                group_view.onconnectionclick(connection);
-        };
-
-    }
-
-    /**
-     * Adds the given group via the given appendChild() function.
-     * 
-     * @param {GuacamoleService.ConnectionGroup} group The group to add.
-     * @param {Function} appendChild A function which, given an element, will
-     *                               add that element the the display as
-     *                               desired.
-     */
-    function addGroup(group, appendChild) {
-
-        // Do not add group if filter says "no"
-        if (group_filter && !group_filter(group))
-            return;
-
-        // Add group to groups collection
-        group_view.groups[group.id] = group;
-
-        // Create element for group
-        var list_group = new GuacUI.ListGroup(group.name);
-        list_groups[group.id] = list_group;
-        GuacUI.addClass(list_group.getElement(), "list-item");
-
-        // Mark group as balancer if appropriate
-        if (group.type === GuacamoleService.ConnectionGroup.Type.BALANCING)
-            GuacUI.addClass(list_group.getElement(), "balancer");
-
-        // Recursively add all children to the new element
-        addGroupContents(group, list_group.addElement);
-
-        // If multiselect, add checkbox for each group
-        if (multiselect) {
-
-            var group_choice = GuacUI.createElement("div", "choice");
-            var group_checkbox = GuacUI.createChildElement(group_choice, "input");
-            group_checkbox.setAttribute("type", "checkbox");
-            
-            group_choice.appendChild(list_group.getElement());
-            appendChild(group_choice);
-
-            function fire_group_change(e) {
-
-                // Prevent click from affecting parent
-                e.stopPropagation();
-
-                // Fire event if handler defined
-                if (group_view.ongroupchange)
-                    group_view.ongroupchange(group, this.checked);
-
-            }
-
-            // Fire change events when checkbox modified
-            group_checkbox.addEventListener("click",  fire_group_change, false);
-            group_checkbox.addEventListener("change", fire_group_change, false);
-
-            // Add checbox to set of group checkboxes
-            group_checkboxes[group.id] = group_checkbox;
-
-        }
-        else
-            appendChild(list_group.getElement());
-
-        // Fire click events when group clicked
-        list_group.onclick = function() {
-            if (group_view.ongroupclick)
-                group_view.ongroupclick(group);
-        };
-
-    }
-
-    // If requested, include the root group as an item
-    if (show_root) {
-        addGroup(root_group, pager.addElement);
-        list_groups[root_group.id].expand();
-    }
-
-    // Otherwise, only add contents of root group
-    else
-        addGroupContents(root_group, pager.addElement);
-
-    // Add buttons if more than one page
-    if (pager.last_page !== 0) {
-        var list_buttons = GuacUI.createChildElement(element, "div", "buttons");
-        list_buttons.appendChild(pager.getElement());
-    }
-
-    // Start at page 0
-    pager.setPage(0);
-
-};
-
-/**
- * When set, allows multiple groups (or connections to be selected).
- */
-GuacUI.GroupView.MULTISELECT = 0x1;
-
-/**
- * When set, also displays connections within the visible groups.
- */
-GuacUI.GroupView.SHOW_CONNECTIONS = 0x2;
-
-/**
- * When set, also displays the root group. By default the root group is hidden.
- */
-GuacUI.GroupView.SHOW_ROOT_GROUP = 0x4;
-
-/**
- * Simple modal dialog providing a header, body, and footer. No other
- * functionality is provided other than a reasonable hierarchy of divs and
- * easy access to their corresponding elements.
- */
-GuacUI.Dialog = function() {
-
-    /**
-     * The container of the entire dialog. Adding this element to the DOM
-     * displays the dialog, while removing this element hides the dialog.
-     * 
-     * @private
-     * @type Element
-     */
-    var element = GuacUI.createElement("div", "dialog-container");
-
-    /**
-     * The dialog itself. This element is not exposed outside this object,
-     * but rather contains the header, body, and footer sections which are
-     * exposed.
-     * 
-     * @private
-     * @type Element
-     */
-    var dialog = GuacUI.createChildElement(element, "div", "dialog");
-
-    /**
-     * The header section of the dialog. This section would normally contain
-     * the title.
-     * 
-     * @private
-     * @type Element
-     */
-    var header = GuacUI.createChildElement(dialog, "div", "header");
-
-    /**
-     * The body section of the dialog. This section would normally contain any
-     * form fields and content.
-     * 
-     * @private
-     * @type Element
-     */
-    var body = GuacUI.createChildElement(dialog, "div", "body");
-
-    /**
-     * The footer section of the dialog. This section would normally contain
-     * the buttons.
-     * 
-     * @private
-     * @type Element
-     */
-    var footer = GuacUI.createChildElement(dialog, "div", "footer");
-
-    /**
-     * Returns the header section of this dialog. This section normally
-     * contains the title of the dialog.
-     * 
-     * @return {Element} The header section of this dialog.
-     */
-    this.getHeader = function() {
-        return header;
-    };
-
-    /**
-     * Returns the body section of this dialog. This section normally contains
-     * the form fields, etc. of a dialog.
-     * 
-     * @return {Element} The body section of this dialog.
-     */
-    this.getBody = function() {
-        return body;
-    };
-
-    /**
-     * Returns the footer section of this dialog. This section is normally
-     * used to contain the buttons of the dialog.
-     * 
-     * @return {Element} The footer section of this dialog.
-     */
-    this.getFooter = function() {
-        return footer;
-    };
-
-    /**
-     * Returns the element representing this dialog. Adding this element to
-     * the DOM shows the dialog, while removing this element hides the dialog.
-     * 
-     * @return {Element} The element representing this dialog.
-     */
-    this.getElement = function() {
-        return element;
-    };
-
-};
diff --git a/guacamole/src/main/webapp/scripts/history.js b/guacamole/src/main/webapp/scripts/history.js
deleted file mode 100644
index f3bbb22..0000000
--- a/guacamole/src/main/webapp/scripts/history.js
+++ /dev/null
@@ -1,175 +0,0 @@
-/**
- * Set of thumbnails for each connection, indexed by ID.
- */
-GuacamoleHistory = new (function() {
-
-    /**
-     * Reference to this GuacamoleHistory.
-     */
-    var guac_history = this;
-
-    /**
-     * The number of entries to allow before removing old entries based on the
-     * cutoff.
-     */
-    var IDEAL_LENGTH = 6;
-
-    /**
-     * The maximum age of a history entry before it is removed, in
-     * milliseconds.
-     */
-    var CUTOFF_AGE = 900000;
-
-    var history = {};
-
-    function truncate() {
-
-        // Build list of entries
-        var entries = [];
-        for (var old_id in history)
-            entries.push(history[old_id]);
-
-        // Avoid history growth beyond defined number of entries
-        if (entries.length > IDEAL_LENGTH) {
-
-            // Sort list
-            entries.sort(GuacamoleHistory.Entry.compare);
-
-            // Remove entries until length is ideal or all are recent
-            var now = new Date().getTime();
-            while (entries.length > IDEAL_LENGTH 
-                    && now - entries[0].accessed > CUTOFF_AGE) {
-
-                // Remove entry
-                var removed = entries.shift();
-                delete history[removed.id];
-
-            }
-
-        }
-
-    }
-
-
-    /**
-     * Returns the URL for the thumbnail of the connection with the given ID,
-     * or undefined if no thumbnail is associated with that connection.
-     */
-    this.get = function(id) {
-        return history[id] || new GuacamoleHistory.Entry();
-    };
-
-    /**
-     * Updates the thumbnail and access time of the history entry for the
-     * connection with the given ID.
-     */
-    this.update = function(id, thumbnail) {
-
-        // Create updated entry
-        var entry = new GuacamoleHistory.Entry(
-            id,
-            thumbnail,
-            new Date().getTime()
-        );
-
-        // Store entry in history
-        history[id] = entry;
-        truncate();
-
-        // Save updated history
-        localStorage.setItem("GUAC_HISTORY", JSON.stringify(history));
-
-    };
-
-    /**
-     * Reloads all history data.
-     */
-    this.reload = function() {
-
-        // Get old and new for comparison
-        var old_history = history;
-        var new_history = JSON.parse(localStorage.getItem("GUAC_HISTORY") || "{}");
-
-        // Update history
-        history = new_history;
-
-        // Call onchange handler as necessary
-        if (guac_history.onchange) {
-
-            // Produce union of all known IDs
-            var known_ids = {};
-            for (var new_id in new_history) known_ids[new_id] = true;
-            for (var old_id in old_history) known_ids[old_id] = true;
-
-            // For each known ID
-            for (var id in known_ids) {
-
-                // Get entries
-                var old_entry = old_history[id];    
-                var new_entry = new_history[id];    
-
-                // Call handler for all changed 
-                if (!old_entry || !new_entry
-                        || old_entry.accessed != new_entry.accessed)
-                    guac_history.onchange(id, old_entry, new_entry);
-
-            }
-
-        } // end onchange
-
-    };
-
-    /**
-     * Event handler called whenever a history entry is changed.
-     * 
-     * @event
-     * @param {String} id The ID of the connection whose history entry is
-     *                    changing.
-     * @param {GuacamoleHistory.Entry} old_entry The old value of the entry, if
-     *                                           any.
-     * @param {GuacamoleHistory.Entry} new_entry The new value of the entry, if
-     *                                           any.
-     */
-    this.onchange = null;
-
-    // Reload when modified
-    window.addEventListener("storage", guac_history.reload, false);
-
-    // Initial load
-    guac_history.reload();
-
-})();
-
-/**
- * A single entry in the indexed connection usage history.
- * 
- * @constructor
- * @param {String} id The ID of this connection.
- * @param {String} thumbnail The URL of the thumbnail to use to represent this
- *                           connection.
- * @param {Number} last_access The time this connection was last accessed, in
- *                             seconds.
- */
-GuacamoleHistory.Entry = function(id, thumbnail, last_access) {
-
-    /**
-     * The ID of the connection associated with this history entry.
-     */
-    this.id = id;
-
-    /**
-     * The thumbnail associated with the connection associated with this history
-     * entry.
-     */
-    this.thumbnail = thumbnail;
-
-    /**
-     * The time the connection associated with this entry was last accessed.
-     */
-    this.accessed = last_access;
-
-};
- 
-GuacamoleHistory.Entry.compare = function(a, b) {
-    return a.accessed - b.accessed;
-};
diff --git a/guacamole/src/main/webapp/scripts/lib/blob/blob.js b/guacamole/src/main/webapp/scripts/lib/blob/blob.js
deleted file mode 100644
index 6d48b39..0000000
--- a/guacamole/src/main/webapp/scripts/lib/blob/blob.js
+++ /dev/null
@@ -1,178 +0,0 @@
-/* Blob.js
- * A Blob implementation.
- * 2013-06-20
- * 
- * By Eli Grey, http://eligrey.com
- * By Devin Samarin, https://github.com/eboyjr
- * License: X11/MIT
- *   See LICENSE.md
- */
-
-/*global self, unescape */
-/*jslint bitwise: true, regexp: true, confusion: true, es5: true, vars: true, white: true,
-  plusplus: true */
-
-/*! @source http://purl.eligrey.com/github/Blob.js/blob/master/Blob.js */
-
-if (typeof Blob !== "function" || typeof URL === "undefined")
-if (typeof Blob === "function" && typeof webkitURL !== "undefined") var URL = webkitURL;
-else var Blob = (function (view) {
-	"use strict";
-
-	var BlobBuilder = view.BlobBuilder || view.WebKitBlobBuilder || view.MozBlobBuilder || view.MSBlobBuilder || (function(view) {
-		var
-			  get_class = function(object) {
-				return Object.prototype.toString.call(object).match(/^\[object\s(.*)\]$/)[1];
-			}
-			, FakeBlobBuilder = function BlobBuilder() {
-				this.data = [];
-			}
-			, FakeBlob = function Blob(data, type, encoding) {
-				this.data = data;
-				this.size = data.length;
-				this.type = type;
-				this.encoding = encoding;
-			}
-			, FBB_proto = FakeBlobBuilder.prototype
-			, FB_proto = FakeBlob.prototype
-			, FileReaderSync = view.FileReaderSync
-			, FileException = function(type) {
-				this.code = this[this.name = type];
-			}
-			, file_ex_codes = (
-				  "NOT_FOUND_ERR SECURITY_ERR ABORT_ERR NOT_READABLE_ERR ENCODING_ERR "
-				+ "NO_MODIFICATION_ALLOWED_ERR INVALID_STATE_ERR SYNTAX_ERR"
-			).split(" ")
-			, file_ex_code = file_ex_codes.length
-			, real_URL = view.URL || view.webkitURL || view
-			, real_create_object_URL = real_URL.createObjectURL
-			, real_revoke_object_URL = real_URL.revokeObjectURL
-			, URL = real_URL
-			, btoa = view.btoa
-			, atob = view.atob
-			, can_apply_typed_arrays = false
-			, can_apply_typed_arrays_test = function(pass) {
-				can_apply_typed_arrays = !pass;
-			}
-			
-			, ArrayBuffer = view.ArrayBuffer
-			, Uint8Array = view.Uint8Array
-		;
-		FakeBlob.fake = FB_proto.fake = true;
-		while (file_ex_code--) {
-			FileException.prototype[file_ex_codes[file_ex_code]] = file_ex_code + 1;
-		}
-		try {
-			if (Uint8Array) {
-				can_apply_typed_arrays_test.apply(0, new Uint8Array(1));
-			}
-		} catch (ex) {}
-		if (!real_URL.createObjectURL) {
-			URL = view.URL = {};
-		}
-		URL.createObjectURL = function(blob) {
-			var
-				  type = blob.type
-				, data_URI_header
-			;
-			if (type === null) {
-				type = "application/octet-stream";
-			}
-			if (blob instanceof FakeBlob) {
-				data_URI_header = "data:" + type;
-				if (blob.encoding === "base64") {
-					return data_URI_header + ";base64," + blob.data;
-				} else if (blob.encoding === "URI") {
-					return data_URI_header + "," + decodeURIComponent(blob.data);
-				} if (btoa) {
-					return data_URI_header + ";base64," + btoa(blob.data);
-				} else {
-					return data_URI_header + "," + encodeURIComponent(blob.data);
-				}
-			} else if (real_create_object_URL) {
-				return real_create_object_URL.call(real_URL, blob);
-			}
-		};
-		URL.revokeObjectURL = function(object_URL) {
-			if (object_URL.substring(0, 5) !== "data:" && real_revoke_object_URL) {
-				real_revoke_object_URL.call(real_URL, object_URL);
-			}
-		};
-		FBB_proto.append = function(data/*, endings*/) {
-			var bb = this.data;
-			// decode data to a binary string
-			if (Uint8Array && (data instanceof ArrayBuffer || data instanceof Uint8Array)) {
-				if (can_apply_typed_arrays) {
-					bb.push(String.fromCharCode.apply(String, new Uint8Array(data)));
-				} else {
-					var
-						  str = ""
-						, buf = new Uint8Array(data)
-						, i = 0
-						, buf_len = buf.length
-					;
-					for (; i < buf_len; i++) {
-						str += String.fromCharCode(buf[i]);
-					}
-				}
-			} else if (get_class(data) === "Blob" || get_class(data) === "File") {
-				if (FileReaderSync) {
-					var fr = new FileReaderSync;
-					bb.push(fr.readAsBinaryString(data));
-				} else {
-					// async FileReader won't work as BlobBuilder is sync
-					throw new FileException("NOT_READABLE_ERR");
-				}
-			} else if (data instanceof FakeBlob) {
-				if (data.encoding === "base64" && atob) {
-					bb.push(atob(data.data));
-				} else if (data.encoding === "URI") {
-					bb.push(decodeURIComponent(data.data));
-				} else if (data.encoding === "raw") {
-					bb.push(data.data);
-				}
-			} else {
-				if (typeof data !== "string") {
-					data += ""; // convert unsupported types to strings
-				}
-				// decode UTF-16 to binary string
-				bb.push(unescape(encodeURIComponent(data)));
-			}
-		};
-		FBB_proto.getBlob = function(type) {
-			if (!arguments.length) {
-				type = null;
-			}
-			return new FakeBlob(this.data.join(""), type, "raw");
-		};
-		FBB_proto.toString = function() {
-			return "[object BlobBuilder]";
-		};
-		FB_proto.slice = function(start, end, type) {
-			var args = arguments.length;
-			if (args < 3) {
-				type = null;
-			}
-			return new FakeBlob(
-				  this.data.slice(start, args > 1 ? end : this.data.length)
-				, type
-				, this.encoding
-			);
-		};
-		FB_proto.toString = function() {
-			return "[object Blob]";
-		};
-		return FakeBlobBuilder;
-	}(view));
-
-	return function Blob(blobParts, options) {
-		var type = options ? (options.type || "") : "";
-		var builder = new BlobBuilder();
-		if (blobParts) {
-			for (var i = 0, len = blobParts.length; i < len; i++) {
-				builder.append(blobParts[i]);
-			}
-		}
-		return builder.getBlob(type);
-	};
-}(self));
diff --git a/guacamole/src/main/webapp/scripts/lib/filesaver/LICENSE.md b/guacamole/src/main/webapp/scripts/lib/filesaver/LICENSE.md
deleted file mode 100644
index 7eb56b9..0000000
--- a/guacamole/src/main/webapp/scripts/lib/filesaver/LICENSE.md
+++ /dev/null
@@ -1,30 +0,0 @@
-This software is licensed under the MIT/X11 license.
-
-MIT/X11 license
----------------
-
-Copyright © 2011 [Eli Grey][1].
-
-Permission is hereby granted, free of charge, to any person
-obtaining a copy of this software and associated documentation
-files (the "Software"), to deal in the Software without
-restriction, including without limitation the rights to use,
-copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the
-Software is furnished to do so, subject to the following
-conditions:
-
-The above copyright notice and this permission notice shall be
-included in all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
-EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
-OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
-NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
-HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
-WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
-FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
-OTHER DEALINGS IN THE SOFTWARE.
-
-
-  [1]: http://eligrey.com
\ No newline at end of file
diff --git a/guacamole/src/main/webapp/scripts/root-ui.js b/guacamole/src/main/webapp/scripts/root-ui.js
deleted file mode 100644
index 3815d8b..0000000
--- a/guacamole/src/main/webapp/scripts/root-ui.js
+++ /dev/null
@@ -1,516 +0,0 @@
-
-/*
- *  Guacamole - Clientless Remote Desktop
- *  Copyright (C) 2010  Michael Jumper
- *
- *  This program is free software: you can redistribute it and/or modify
- *  it under the terms of the GNU Affero General Public License as published by
- *  the Free Software Foundation, either version 3 of the License, or
- *  (at your option) any later version.
- *
- *  This program is distributed in the hope that it will be useful,
- *  but WITHOUT ANY WARRANTY; without even the implied warranty of
- *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- *  GNU Affero General Public License for more details.
- *
- *  You should have received a copy of the GNU Affero General Public License
- *  along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-/**
- * General set of UI elements and UI-related functions regarding user login and
- * connection management.
- */
-var GuacamoleRootUI = {
-
-    "sections": {
-        "login_form"         : document.getElementById("login-form"),
-        "recent_connections" : document.getElementById("recent-connections"),
-        "all_connections"    : document.getElementById("all-connections")
-    },
-
-    "messages": {
-        "login_error"           : document.getElementById("login-error"),
-        "no_recent_connections" : document.getElementById("no-recent")
-    },
-
-    "fields": {
-        "username"  : document.getElementById("username"),
-        "password"  : document.getElementById("password"),
-        "clipboard" : document.getElementById("clipboard")
-    },
-    
-    "buttons": {
-        "login"  : document.getElementById("login"),
-        "logout" : document.getElementById("logout"),
-        "manage" : document.getElementById("manage")
-    },
-
-    "settings": {
-        "auto_fit"      : document.getElementById("auto-fit"),
-        "disable_sound" : document.getElementById("disable-sound")
-    },
-
-    "views": {
-        "login"       : document.getElementById("login-ui"),
-        "connections" : document.getElementById("connection-list-ui")
-    },
-
-    "session_state" :  new GuacamoleSessionState(),
-    "parameters"    :  null
-
-};
-
-// Get parameters from query string
-GuacamoleRootUI.parameters = window.location.search.substring(1) || null;
-
-/**
- * A connection UI object which can be easily added to a list of connections
- * for sake of display.
- * 
- * @param {String} id The ID of this object, including prefix.
- * @param {String} name The name that should be displayed.
- */
-GuacamoleRootUI.RecentConnection = function(id, name) {
-
-    /**
-     * The ID of this object, including prefix.
-     * @type String
-     */
-    this.id = id;
-
-    /**
-     * The displayable name of this object.
-     * @type String
-     */
-    this.name = name;
-
-    // Create connection display elements
-    var element      = GuacUI.createElement("div",  "connection");
-    var thumbnail    = GuacUI.createChildElement(element, "div",  "thumbnail");
-    var caption      = GuacUI.createChildElement(element, "div",  "caption");
-    var name_element = GuacUI.createChildElement(caption, "span", "name");
-
-    // Connect on click
-    element.addEventListener("click", function(e) {
-
-        // Prevent click from affecting parent
-        e.stopPropagation();
-        e.preventDefault();
-
-        // Open connection
-        GuacUI.openObject(id, GuacamoleRootUI.parameters);
-
-    }, false);
-
-    // Set name
-    name_element.textContent = name;
-
-    // Add screenshot if available
-    var thumbnail_url = GuacamoleHistory.get(id).thumbnail;
-    if (thumbnail_url) {
-
-        // Create thumbnail element
-        var thumb_img = GuacUI.createChildElement(thumbnail, "img");
-        thumb_img.src = thumbnail_url;
-
-    }
-
-    /**
-     * Returns the DOM element representing this connection.
-     */
-    this.getElement = function() {
-        return element;
-    };
-
-    /**
-     * Sets the thumbnail URL of this existing connection. Note that this will
-     * only work if the connection already had a thumbnail associated with it.
-     */
-    this.setThumbnail = function(url) {
-
-        // If no image element, create it
-        if (!thumb_img) {
-            thumb_img = document.createElement("img");
-            thumb_img.src = url;
-            thumbnail.appendChild(thumb_img);
-        }
-
-        // Otherwise, set source of existing
-        else
-            thumb_img.src = url;
-
-    };
-
-};
-
-/**
- * Attempts to login the given user using the given password, throwing an
- * error if the process fails.
- * 
- * @param {String} username The name of the user to login as.
- * @param {String} password The password to use to authenticate the user.
- */
-GuacamoleRootUI.login = function(username, password) {
-
-    // Get username and password from form
-    var data =
-           "username=" + encodeURIComponent(username)
-        + "&password=" + encodeURIComponent(password)
-
-    // Include query parameters in submission data
-    if (GuacamoleRootUI.parameters)
-        data += "&" + GuacamoleRootUI.parameters;
-
-    // Log in
-    var xhr = new XMLHttpRequest();
-    xhr.open("POST", "login", false);
-    xhr.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
-    xhr.send(data);
-
-    // Handle failures
-    if (xhr.status != 200)
-        throw new Error("Invalid login");
-
-};
-
-/**
- * Set of all thumbnailed connections, indexed by ID. Here, each connection
- * is a GuacamoleRootUI.RecentConnection.
- */
-GuacamoleRootUI.recentConnections = {};
-
-/**
- * Set of all connections, indexed by ID. Each connection is a
- * GuacamoleService.Connection.
- */
-GuacamoleRootUI.connections = {};
-
-/**
- * Adds the given RecentConnection to the recent connections list.
- */
-GuacamoleRootUI.addRecentConnection = function(id, name) {
-
-    // Create recent connection object
-    var connection = new GuacamoleRootUI.RecentConnection(id, name);
-
-    // Add connection object to list of thumbnailed connections
-    GuacamoleRootUI.recentConnections[connection.id] =
-        connection;
-    
-    // Add connection to recent list
-    GuacamoleRootUI.sections.recent_connections.appendChild(
-        connection.getElement());
-
-    // Hide "No recent connections" message
-    GuacamoleRootUI.messages.no_recent_connections.style.display = "none";
-
-};
-
-
-/**
- * Resets the interface such that the login UI is displayed if
- * the user is not authenticated (or authentication fails) and
- * the connection list UI (or the client for the only available
- * connection, if there is only one) is displayed if the user is
- * authenticated.
- */
-GuacamoleRootUI.reset = function() {
-
-    function hasEntry(object) {
-        for (var name in object)
-            return true;
-        return false;
-    }
-
-    // Read root group
-    var root_group;
-    try {
-        root_group = GuacamoleService.Connections.list(GuacamoleRootUI.parameters);
-
-        // Show admin elements if admin permissions available
-        var permissions = GuacamoleService.Permissions.list(null, GuacamoleRootUI.parameters);
-        if (permissions.administer
-            || permissions.create_connection
-            || permissions.create_user
-            || hasEntry(permissions.update_user)
-            || hasEntry(permissions.remove_user)
-            || hasEntry(permissions.administer_user)
-            || hasEntry(permissions.update_connection)
-            || hasEntry(permissions.remove_connection)
-            || hasEntry(permissions.administer_connection))
-                GuacUI.addClass(document.body, "admin");
-            else
-                GuacUI.removeClass(document.body, "admin");
-
-    }
-    catch (e) {
-
-        // Show login UI if unable to get connections
-        GuacamoleRootUI.views.login.style.display = "";
-        GuacamoleRootUI.views.connections.style.display = "none";
-
-        return;
-
-    }
-
-
-    // Create group view
-    var group_view = new GuacUI.GroupView(root_group, GuacUI.GroupView.SHOW_CONNECTIONS);
-    GuacamoleRootUI.sections.all_connections.appendChild(group_view.getElement());
-
-    // Add any connections with thumbnails
-    for (var connection_id in group_view.connections) {
-
-        // Get corresponding connection
-        var connection = group_view.connections[connection_id];
-
-        // If thumbnail exists, add to recent connections
-        if (GuacamoleHistory.get("c/" + connection_id).thumbnail)
-            GuacamoleRootUI.addRecentConnection("c/" + connection_id, connection.name);
-
-    }
-
-    // Add any groups with thumbnails
-    for (var group_id in group_view.groups) {
-
-        // Get corresponding group 
-        var group = group_view.groups[group_id];
-
-        // If thumbnail exists, add to recent connections
-        if (GuacamoleHistory.get("g/" + group_id).thumbnail)
-            GuacamoleRootUI.addRecentConnection("g/" + group_id, group.name);
-
-    }
-
-    // Open connections when clicked
-    group_view.onconnectionclick = function(connection) {
-        GuacUI.openConnection(connection.id, GuacamoleRootUI.parameters);
-    };
-
-    // Open connection groups when clicked
-    group_view.ongroupclick = function(group) {
-        
-        // Connect if balancing
-        if (group.type === GuacamoleService.ConnectionGroup.Type.BALANCING)
-            GuacUI.openConnectionGroup(group.id, GuacamoleRootUI.parameters);
-        
-    };
-
-    // Save all connections for later reference
-    GuacamoleRootUI.connections = group_view.connections;
-
-    // If connections could be retrieved, display list
-    GuacamoleRootUI.views.login.style.display = "none";
-    GuacamoleRootUI.views.connections.style.display = "";
-
-};
-
-GuacamoleHistory.onchange = function(id, old_entry, new_entry) {
-
-    // Get existing connection, if any
-    var connection = GuacamoleRootUI.recentConnections[id];
-
-    // If we are adding or updating a connection
-    if (new_entry) {
-
-        // Ensure connection is added
-        if (!connection) {
-
-            // If connection not actually defined, storage must be being
-            // modified externally. Stop early.
-            if (!GuacamoleRootUI.connections[id]) return;
-
-            // Create new connection
-            GuacamoleRootUI.addRecentConnection(id, connection.name);
-
-        }
-
-        // Set new thumbnail 
-        connection.setThumbnail(new_entry.thumbnail);
-
-    }
-
-    // Otherwise, delete existing connection
-    else {
-
-        GuacamoleRootUI.sections.recent_connections.removeChild(
-            connection.getElement());
-
-        delete GuacamoleRootUI.recentConnections[id];
-
-        // Display "No recent connections" message if none left
-        if (GuacamoleRootUI.recentConnections.length === 0)
-            GuacamoleRootUI.messages.no_recent_connections.style.display = "";
-
-    }
-    
-};
-
-/*
- * This window has no name. We need it to have no name. If someone navigates
- * to the root UI within the same window as a previous connection, we need to
- * remove the name from that window such that new attempts to use that previous
- * connection do not replace the contents of this very window.
- */
-window.name = "";
-
-/*
- * Update session state when auto-fit checkbox is changed
- */
-
-GuacamoleRootUI.settings.auto_fit.onchange =
-GuacamoleRootUI.settings.auto_fit.onclick  = function() {
-
-    GuacamoleRootUI.session_state.setProperty(
-        "auto-fit", GuacamoleRootUI.settings.auto_fit.checked);
-
-};
-
-/*
- * Update session state when disable-sound checkbox is changed
- */
-
-GuacamoleRootUI.settings.disable_sound.onchange =
-GuacamoleRootUI.settings.disable_sound.onclick  = function() {
-
-    GuacamoleRootUI.session_state.setProperty(
-        "disable-sound", GuacamoleRootUI.settings.disable_sound.checked);
-
-};
-
-/*
- * Update clipboard contents when changed
- */
-
-window.onblur =
-GuacamoleRootUI.fields.clipboard.onchange = function() {
-
-    // Set value if changed
-    var new_value = GuacamoleRootUI.fields.clipboard.value;
-    if (GuacamoleRootUI.session_state.getProperty("clipboard") != new_value)
-        GuacamoleRootUI.session_state.setProperty("clipboard", new_value);
-
-};
-
-/*
- * Update element states when session state changes
- */
-
-GuacamoleRootUI.session_state.onchange =
-function(old_state, new_state, name) {
-
-    // Clipboard
-    if (name == "clipboard")
-        GuacamoleRootUI.fields.clipboard.value = new_state[name];
-
-    // Auto-fit display
-    else if (name == "auto-fit")
-        GuacamoleRootUI.fields.auto_fit.checked = new_state[name];
-
-    // Disable Sound
-    else if (name == "disable-sound")
-        GuacamoleRootUI.fields.disable_sound.checked = new_state[name];
-
-};
-
-/*
- * Initialize clipboard with current data
- */
-
-if (GuacamoleRootUI.session_state.getProperty("clipboard"))
-    GuacamoleRootUI.fields.clipboard.value =
-        GuacamoleRootUI.session_state.getProperty("clipboard");
-
-/*
- * Default to true if auto-fit not specified
- */
-
-if (GuacamoleRootUI.session_state.getProperty("auto-fit") === undefined)
-    GuacamoleRootUI.session_state.setProperty("auto-fit", true);
-
-/*
- * Initialize auto-fit setting in UI
- */
-
-GuacamoleRootUI.settings.auto_fit.checked =
-    GuacamoleRootUI.session_state.getProperty("auto-fit");
-
-/*
- * Initialize disable-sound setting in UI
- */
-GuacamoleRootUI.settings.disable_sound.checked =
-    GuacamoleRootUI.session_state.getProperty("disable-sound");
-
-/*
- * Set handler for logout
- */
-
-GuacamoleRootUI.buttons.logout.onclick = function() {
-    window.location.href = "logout";
-};
-
-/*
- * Set handler for admin
- */
-
-GuacamoleRootUI.buttons.manage.onclick = function() {
-    window.location.href = "admin.xhtml";
-};
-
-/*
- * Set handler for login
- */
-
-GuacamoleRootUI.sections.login_form.onsubmit = function() {
-
-    try {
-
-        // Attempt login
-        GuacamoleRootUI.login(
-            GuacamoleRootUI.fields.username.value,
-            GuacamoleRootUI.fields.password.value
-        );
-
-        // Ensure username/password fields are blurred after login attempt
-        GuacamoleRootUI.fields.username.blur();
-        GuacamoleRootUI.fields.password.blur();
-
-        // Reset UI
-        GuacamoleRootUI.reset();
-
-    }
-    catch (e) {
-
-        // Display error, reset and refocus password field
-        GuacamoleRootUI.messages.login_error.textContent = e.message;
-
-        // Reset and recofus password field
-        GuacamoleRootUI.fields.password.value = "";
-        GuacamoleRootUI.fields.password.focus();
-
-    }
-
-    // Always cancel submit
-    return false;
-
-};
-
-/*
- * Turn off autocorrect and autocapitalization on usename 
- */
-
-GuacamoleRootUI.fields.username.setAttribute("autocorrect", "off");
-GuacamoleRootUI.fields.username.setAttribute("autocapitalize", "off");
-
-/*
- * Initialize UI
- */
-
-GuacamoleRootUI.reset();
-
-/*
- * Make sure body has an associated touch event handler such that CSS styles
- * will work in browsers that require this.
- */
-document.body.ontouchstart = function() {};
diff --git a/guacamole/src/main/webapp/scripts/service.js b/guacamole/src/main/webapp/scripts/service.js
deleted file mode 100644
index c427356..0000000
--- a/guacamole/src/main/webapp/scripts/service.js
+++ /dev/null
@@ -1,1398 +0,0 @@
-/*
- *  Guacamole - Clientless Remote Desktop
- *  Copyright (C) 2010  Michael Jumper
- *
- *  This program is free software: you can redistribute it and/or modify
- *  it under the terms of the GNU Affero General Public License as published by
- *  the Free Software Foundation, either version 3 of the License, or
- *  (at your option) any later version.
- *
- *  This program is distributed in the hope that it will be useful,
- *  but WITHOUT ANY WARRANTY; without even the implied warranty of
- *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- *  GNU Affero General Public License for more details.
- *
- *  You should have received a copy of the GNU Affero General Public License
- *  along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-/**
- * Main Guacamole web service namespace.
- * @namespace
- */
-var GuacamoleService = GuacamoleService || {};
-
-/**
- * An arbitary Guacamole connection group, having the given type, ID and name.
- * 
- * @constructor
- * @param {Number} type The type of this connection group - either ORGANIZATIONAL
- *                      or BALANCING.
- * @param {String} id An arbitrary ID, likely assigned by the auth provider.
- * @param {String} name The human-readable name of this group.
- */
-GuacamoleService.ConnectionGroup = function(type, id, name) {
-
-    /**
-     * The type of this connection group.
-     * @type Number
-     */
-    this.type = type;
-
-    /**
-     * The unique ID of this connection group.
-     * @type String
-     */
-    this.id = id;
-
-    /**
-     * The human-readable name associated with this connection group.
-     * @type String
-     */
-    this.name = name;
-
-    /**
-     * The parent connection group of this group. If this group is the root
-     * group, this will be null.
-     * 
-     * @type GuacamoleService.ConnectionGroup
-     */
-    this.parent = null;
-
-    /**
-     * All connection groups contained within this group.
-     * @type GuacamoleService.ConnectionGroup[]
-     */
-    this.groups = [];
-
-    /**
-     * All connections contained within this group.
-     * @type GuacamoleService.Connection[]
-     */
-    this.connections = [];
-
-};
-
-/**
- * Set of all possible types for ConnectionGroups.
- */
-GuacamoleService.ConnectionGroup.Type = {
-
-    /**
-     * Organizational groups exist solely to hold connections or other groups,
-     * and provide no other semantics.
-     * 
-     * @type Number
-     */
-    "ORGANIZATIONAL" : 0,
-
-    /**
-     * Balancing groups act as connections. Users that have READ permission on
-     * balancing groups can use the group as if it were a connection, and that
-     * group will choose an appropriate connection within itself for that user
-     * to use.
-     * 
-     * @type Number
-     */
-    "BALANCING"      : 1
-
-};
-
-/**
- * An arbitrary Guacamole connection, consisting of an ID/protocol pair.
- * 
- * @constructor
- * @param {String} protocol The protocol used by this connection.
- * @param {String} id The ID associated with this connection.
- * @param {String} name The human-readable name associated with this connection.
- */
-GuacamoleService.Connection = function(protocol, id, name) {
-
-    /**
-     * Reference to this connection.
-     */
-    var guac_connection = this;
-
-    /**
-     * The parent connection group of this connection.
-     * @type GuacamoleService.ConnectionGroup
-     */
-    this.parent = null;
-
-    /**
-     * The protocol associated with this connection.
-     */
-    this.protocol = protocol;
-
-    /**
-     * The ID associated with this connection.
-     */
-    this.id = id;
-
-    /**
-     * All parameters associated with this connection, if available.
-     */
-    this.parameters = {};
-
-    /**
-     * The name of this connection. This name is arbitrary and local to the
-     * group containing the connection.
-     * 
-     * @type String
-     */
-    this.name = name;
-
-    /**
-     * An array of GuacamoleService.Connection.Record listing the usage
-     * history of this connection.
-     */
-    this.history = [];
-
-    /**
-     * Returns the number of active users of this connection (which may be
-     * multiple instances under the same user) by walking the history records.
-     * 
-     * @return {Number} The number of active users of this connection.
-     */
-    this.currentUsage = function() {
-
-        // Number of users of this connection
-        var usage = 0;
-
-        // Walk history counting active entries
-        for (var i=0; i<guac_connection.history.length; i++) {
-            if (guac_connection.history[i].active)
-                usage++;
-        }
-
-        return usage;
-
-    };
-
-};
-
-/**
- * Creates a new GuacamoleService.Connection.Record describing a single
- * session for the given username and having the given start/end times.
- * 
- * @constructor
- * @param {String} username The username of the user who used the connection.
- * @param {Number} start The time that the connection began (in UNIX epoch
- *                       milliseconds).
- * @param {Number} end The time that the connection ended (in UNIX epoch
- *                     milliseconds). This parameter is optional.
- * @param {Boolean} active Whether the connection is currently active.
- */
-GuacamoleService.Connection.Record = function(username, start, end, active) {
-    
-    /**
-     * The username of the user associated with this record.
-     * @type String
-     */
-    this.username = username;
-    
-    /**
-     * The time the corresponding connection began.
-     * @type Date
-     */
-    this.start = new Date(start);
-
-    /**
-     * The time the corresponding connection terminated (if any).
-     * @type Date
-     */
-    this.end = null;
-
-    /**
-     * Whether this connection is currently active.
-     */
-    this.active = active;
-
-    /**
-     * The duration of this connection, in seconds. This value is only
-     * defined if the end time is available.
-     * @type Number
-     */
-    this.duration = null;
-
-    // If end time given, intialize end time
-    if (end) {
-        this.end = new Date(end);
-        this.duration = (end - start) / 1000;
-    }
-    
-};
-
-/**
- * A basic set of permissions that can be assigned to a user, describing
- * whether they can create other users/connections and describing which
- * users/connections they have permission to read or modify.
- */
-GuacamoleService.PermissionSet = function() {
-
-    /**
-     * Whether permission to create users is granted.
-     */
-    this.create_user = false;
-
-    /**
-     * Whether permission to create connections is granted.
-     */
-    this.create_connection = false;
-
-    /**
-     * Whether permission to create connection groups is granted.
-     */
-    this.create_connection_group = false;
-
-    /**
-     * Whether permission to administer the system in general is granted.
-     */
-    this.administer = false;
-
-    /**
-     * Object with a property entry for each readable user.
-     */
-    this.read_user = {};
-
-    /**
-     * Object with a property entry for each updatable user.
-     */
-    this.update_user = {};
-
-    /**
-     * Object with a property entry for each removable user.
-     */
-    this.remove_user = {};
-
-    /**
-     * Object with a property entry for each administerable user.
-     */
-    this.administer_user = {};
-
-    /**
-     * Object with a property entry for each readable connection.
-     */
-    this.read_connection = {};
-
-    /**
-     * Object with a property entry for each updatable connection.
-     */
-    this.update_connection = {};
-
-    /**
-     * Object with a property entry for each removable connection.
-     */
-    this.remove_connection = {};
-
-    /**
-     * Object with a property entry for each administerable connection.
-     */
-    this.administer_connection = {};
-
-    /**
-     * Object with a property entry for each readable connection group.
-     */
-    this.read_connection_group = {};
-
-    /**
-     * Object with a property entry for each updatable connection group.
-     */
-    this.update_connection_group = {};
-
-    /**
-     * Object with a property entry for each removable connection group.
-     */
-    this.remove_connection_group = {};
-
-    /**
-     * Object with a property entry for each administerable connection group.
-     */
-    this.administer_connection_group = {};
-
-};
-
-/**
- * Handles the reponse from the given XMLHttpRequest object, throwing an error
- * with a meaningful message if the request failed.
- * 
- * @param {XMLHttpRequest} xhr The XMLHttpRequest to check the response of.
- */
-GuacamoleService.handleResponse = function(xhr) {
-
-    // For HTTP Forbidden, just return permission denied
-    if (xhr.status == 403)
-        throw new Error("Permission denied.");
-
-    // Otherwise, if unsuccessful, throw error with message derived from
-    // response
-    if (xhr.status != 200) {
-
-        // Retrieve error message
-        var message =    xhr.getResponseHeader("Guacamole-Error-Message")
-                      || xhr.statusText;
-
-        // Throw error with derived message
-        throw new Error(message);
-
-    }
-
-};
-
-/**
- * Collection of service functions which deal with connections. Each function
- * makes an explicit HTTP query to the server, and parses the response.
- */
-GuacamoleService.Connections = {
-
-    /**
-     * Comparator which compares two arbitrary objects by their name property.
-     */
-    "comparator" : function(a, b) {
-        return a.name.localeCompare(b.name);
-    },
-
-    /**
-     * Returns the root connection group, containing a hierarchy of all other
-     * groups and connections for which the current user has access.
-     * 
-     * @param {String} parameters Any parameters which should be passed to the
-     *                            server for the sake of authentication
-     *                            (optional).
-     * @return {GuacamoleService.ConnectionGroup} The root group, containing
-     *                                            a hierarchy of all other
-     *                                            groups and connections to
-     *                                            which the current user has
-     *                                            access.
-     */   
-    "list" : function(parameters) {
-
-        /**
-         * Parse the contents of the given connection element within XML,
-         * returning a corresponding GuacamoleService.Connection.
-         * 
-         * @param {GuacamoleService.ConnectionGroup} The connection group
-         *                                           containing this connection.
-         * @param {Element} element The element being parsed.
-         * @return {GuacamoleService.Connection} The connection represented by
-         *                                       the element just parsed.
-         */
-        function parseConnection(parent, element) {
-
-            var i;
-
-            var connection = new GuacamoleService.Connection(
-                element.getAttribute("protocol"),
-                element.getAttribute("id"),
-                element.getAttribute("name")
-            );
-
-            // Set parent
-            connection.parent = parent;
-
-            // Add parameter values for each parmeter received
-            var paramElements = element.getElementsByTagName("param");
-            for (i=0; i<paramElements.length; i++) {
-
-                var paramElement = paramElements[i];
-                var name = paramElement.getAttribute("name");
-
-                connection.parameters[name] = paramElement.textContent;
-
-            }
-
-            // Parse history, if available
-            var historyElements = element.getElementsByTagName("history");
-            if (historyElements.length === 1) {
-
-                // For each record in history
-                var history = historyElements[0];
-                var recordElements = history.getElementsByTagName("record");
-                for (i=0; i<recordElements.length; i++) {
-
-                    // Get record
-                    var recordElement = recordElements[i];
-                    var record = new GuacamoleService.Connection.Record(
-                        recordElement.textContent,
-                        parseInt(recordElement.getAttribute("start")),
-                        parseInt(recordElement.getAttribute("end")),
-                        recordElement.getAttribute("active") === "yes"
-                    );
-
-                    // Append to connection history
-                    connection.history.push(record);
-
-                }
-
-            }
-
-            // Return parsed connection
-            return connection;
-
-        }
-
-        /**
-         * Recursively parse the contents of the given group element within XML,
-         * returning a corresponding GuacamoleService.ConnectionGroup.
-         * 
-         * @param {GuacamoleService.ConnectionGroup} The connection group
-         *                                           containing this group.
-         * @param {Element} element The element being parsed.
-         * @return {GuacamoleService.ConnectionGroup} The connection group
-         *                                            represented by the element
-         *                                            just parsed.
-         */
-        function parseGroup(parent, element) {
-
-            var id   = element.getAttribute("id");
-            var name = element.getAttribute("name");
-            var type_string = element.getAttribute("type");
-
-            // Translate type name
-            var type;
-            if (type_string === "organizational")
-                type = GuacamoleService.ConnectionGroup.Type.ORGANIZATIONAL;
-            else if (type_string === "balancing")
-                type = GuacamoleService.ConnectionGroup.Type.BALANCING;
-
-            // Create corresponding group
-            var group = new GuacamoleService.ConnectionGroup(type, id, name);
-
-            // Set parent
-            group.parent = parent;
-
-            // For each child element
-            var current = element.firstChild;
-            while (current !== null) {
-
-                var i, child;
-                var children = current.childNodes;
-
-                if (current.localName === "connections") {
-
-                    // Parse all child connections
-                    for (i=0; i<children.length; i++) {
-                        var child = children[i];
-                        if (child.localName === "connection")
-                            group.connections.push(parseConnection(group, child));
-                    }
-                    
-                }
-                else if (current.localName === "groups") {
-
-                    // Parse all child groups 
-                    for (i=0; i<children.length; i++) {
-                        var child = children[i];
-                        if (child.localName === "group")
-                            group.groups.push(parseGroup(group, child));
-                    }
- 
-                }
-
-                // Next element
-                current = current.nextSibling;
-
-            }
-
-            // Sort groups and connections
-            group.groups.sort(GuacamoleService.Connections.comparator);
-            group.connections.sort(GuacamoleService.Connections.comparator);
-
-            // Return created group
-            return group;
-            
-        }
-
-        // Construct request URL
-        var list_url = "connections";
-        if (parameters) list_url += "?" + parameters;
-
-        // Get connection list
-        var xhr = new XMLHttpRequest();
-        xhr.open("GET", list_url, false);
-        xhr.send(null);
-
-        // Handle response
-        GuacamoleService.handleResponse(xhr);
-        return parseGroup(null, xhr.responseXML.documentElement);
- 
-    },
-
-    /**
-     * Creates a new connection.
-     * 
-     * @param {GuacamoleService.Connection} connection The connection to create.
-     * @param {String} parameters Any parameters which should be passed to the
-     *                            server for the sake of authentication
-     *                            (optional).
-     */
-    "create" : function(connection, parameters) {
-
-        // Construct request URL
-        var users_url = "connections/create?name=" + encodeURIComponent(connection.name);
-        if (parameters) users_url += "&" + parameters;
-
-        // Init POST data
-        var data = "protocol=" + encodeURIComponent(connection.protocol);
-
-        // Add group if given
-        if (connection.parent)
-            data += "&parentID=" + encodeURIComponent(connection.parent.id);
-
-        // Add parameters
-        for (var name in connection.parameters)
-            data += "&_" + encodeURIComponent(name)
-                 +  "="  + encodeURIComponent(connection.parameters[name]);
-
-        // Add user
-        var xhr = new XMLHttpRequest();
-        xhr.open("POST", users_url, false);
-        xhr.setRequestHeader("Content-type", "application/x-www-form-urlencoded; charset=UTF-8");
-        xhr.send(data);
-
-        // Handle response
-        GuacamoleService.handleResponse(xhr);
-
-    },
-
-    /**
-     * Updates an existing connection. All properties are updated except
-     * the location of the connection, which is ignored.
-     * 
-     * @param {GuacamoleService.Connection} connection The connection to create.
-     * @param {String} parameters Any parameters which should be passed to the
-     *                            server for the sake of authentication
-     *                            (optional).
-     */
-    "update" : function(connection, parameters) {
-
-        // Construct request URL
-        var users_url = "connections/update?id=" + encodeURIComponent(connection.id);
-        if (parameters) users_url += "&" + parameters;
-
-        // Init POST data
-        var data =
-                  "name="      + encodeURIComponent(connection.name)
-                + "&protocol=" + encodeURIComponent(connection.protocol);
-
-        // Add parameters
-        for (var name in connection.parameters)
-            data += "&_" + encodeURIComponent(name)
-                 +  "="  + encodeURIComponent(connection.parameters[name]);
-
-        // Add user
-        var xhr = new XMLHttpRequest();
-        xhr.open("POST", users_url, false);
-        xhr.setRequestHeader("Content-type", "application/x-www-form-urlencoded; charset=UTF-8");
-        xhr.send(data);
-
-        // Handle response
-        GuacamoleService.handleResponse(xhr);
-
-    },
-
-    /**
-     * Updates the location of an existing connection. This does not result
-     * in any change to the connection provided as a parameter.
-     * 
-     * @param {GuacamoleService.Connection} connection The connection to create.
-     * @param {GuacamoleService.ConnectionGroup} dest The destination group.
-     * @param {String} parameters Any parameters which should be passed to the
-     *                            server for the sake of authentication
-     *                            (optional).
-     */
-    "move" : function(connection, dest, parameters) {
-
-        // Construct request URL
-        var connection_url = "connections/move?id=" + encodeURIComponent(connection.id);
-        if (parameters) connection_url += "&" + parameters;
-
-        // Init POST data
-        var data = "parentID=" + encodeURIComponent(dest.id);
-
-        // Move connection
-        var xhr = new XMLHttpRequest();
-        xhr.open("POST", connection_url, false);
-        xhr.setRequestHeader("Content-type", "application/x-www-form-urlencoded; charset=UTF-8");
-        xhr.send(data);
-
-        // Handle response
-        GuacamoleService.handleResponse(xhr);
-
-    },
-
-    /**
-     * Deletes the connection having the given identifier.
-     * 
-     * @param {String} id The identifier of the connection to delete.
-     * @param {String} parameters Any parameters which should be passed to the
-     *                            server for the sake of authentication
-     *                            (optional).
-     */
-    "remove" : function(id, parameters) {
-
-        // Construct request URL
-        var connections_url = "connections/delete?id=" + encodeURIComponent(id);
-        if (parameters) connections_url += "&" + parameters;
-
-        // Add user
-        var xhr = new XMLHttpRequest();
-        xhr.open("GET", connections_url, false);
-        xhr.send(null);
-
-        // Handle response
-        GuacamoleService.handleResponse(xhr);
-
-    }
-
-};
-
-/**
- * Collection of service functions which deal with connections groups. Each
- * function makes an explicit HTTP query to the server, and parses the response.
- */
-GuacamoleService.ConnectionGroups = {
-
-    /**
-     * Creates a new connection group.
-     * 
-     * @param {GuacamoleService.ConnectionGroup} group The group to create.
-     * @param {String} parameters Any parameters which should be passed to the
-     *                            server for the sake of authentication
-     *                            (optional).
-     */
-    "create" : function(group, parameters) {
-
-        // Construct request URL
-        var groups_url = "connectiongroups/create?name=" + encodeURIComponent(group.name);
-        if (parameters) groups_url += "&" + parameters;
-
-        // Init POST data
-        var data;
-        if (group.type === GuacamoleService.ConnectionGroup.Type.ORGANIZATIONAL)
-            data = "type=organizational";
-        else if (group.type === GuacamoleService.ConnectionGroup.Type.BALANCING)
-            data = "type=balancing";
-
-        // Add parent group if given
-        if (group.parent)
-            data += "&parentID=" + encodeURIComponent(group.parent.id);
-
-        // Create group
-        var xhr = new XMLHttpRequest();
-        xhr.open("POST", groups_url, false);
-        xhr.setRequestHeader("Content-type", "application/x-www-form-urlencoded; charset=UTF-8");
-        xhr.send(data);
-
-        // Handle response
-        GuacamoleService.handleResponse(xhr);
-
-    },
-
-    /**
-     * Updates an existing connection group. All properties are updated except
-     * the location of the group, which is ignored.
-     * 
-     * @param {GuacamoleService.ConnectionGroup} group The group to create.
-     * @param {String} parameters Any parameters which should be passed to the
-     *                            server for the sake of authentication
-     *                            (optional).
-     */
-    "update" : function(group, parameters) {
-
-        // Construct request URL
-        var groups_url = "connectiongroups/update?id=" + encodeURIComponent(group.id);
-        if (parameters) groups_url += "&" + parameters;
-
-        // Init POST data
-        var data = "name=" + encodeURIComponent(group.name);
-
-        // Add type
-        if (group.type === GuacamoleService.ConnectionGroup.Type.ORGANIZATIONAL)
-            data += "&type=organizational";
-        else if (group.type === GuacamoleService.ConnectionGroup.Type.BALANCING)
-            data += "&type=balancing";
-
-        // Update group
-        var xhr = new XMLHttpRequest();
-        xhr.open("POST", groups_url, false);
-        xhr.setRequestHeader("Content-type", "application/x-www-form-urlencoded; charset=UTF-8");
-        xhr.send(data);
-
-        // Handle response
-        GuacamoleService.handleResponse(xhr);
-
-    },
-
-    /**
-     * Sets the location of an existing connection group. This does not result
-     * in any change to the group provided as a parameter.
-     * 
-     * @param {GuacamoleService.ConnectionGroup} group The group to create.
-     * @param {GuacamoleService.ConnectionGroup} dest The destination group.
-     * @param {String} parameters Any parameters which should be passed to the
-     *                            server for the sake of authentication
-     *                            (optional).
-     */
-    "move" : function(group, dest, parameters) {
-
-        // Construct request URL
-        var groups_url = "connectiongroups/move?id=" + encodeURIComponent(group.id);
-        if (parameters) groups_url += "&" + parameters;
-
-        // Init POST data
-        var data = "parentID=" + encodeURIComponent(dest.id);
-
-        // Move group
-        var xhr = new XMLHttpRequest();
-        xhr.open("POST", groups_url, false);
-        xhr.setRequestHeader("Content-type", "application/x-www-form-urlencoded; charset=UTF-8");
-        xhr.send(data);
-
-        // Handle response
-        GuacamoleService.handleResponse(xhr);
-
-    },
-
-    /**
-     * Deletes the connection group having the given identifier.
-     * 
-     * @param {String} id The identifier of the group to delete.
-     * @param {String} parameters Any parameters which should be passed to the
-     *                            server for the sake of authentication
-     *                            (optional).
-     */
-    "remove" : function(id, parameters) {
-
-        // Construct request URL
-        var groups_url = "connectiongroups/delete?id=" + encodeURIComponent(id);
-        if (parameters) groups_url += "&" + parameters;
-
-        // Delete group
-        var xhr = new XMLHttpRequest();
-        xhr.open("GET", groups_url, false);
-        xhr.send(null);
-
-        // Handle response
-        GuacamoleService.handleResponse(xhr);
-
-    }
-
-};
-
-/**
- * Collection of service functions which deal with users. Each function
- * makes an explicit HTTP query to the server, and parses the response.
- */
-GuacamoleService.Users = {
-
-    /**
-     * Returns an array of usernames for which the current user has access.
-     * 
-     * @param {String} parameters Any parameters which should be passed to the
-     *                            server for the sake of authentication
-     *                            (optional).
-     * @return {String[]} An array of usernames for which the current user has
-     *                    access.
-     */
-    "list" : function(parameters) {
-
-        // Construct request URL
-        var users_url = "users";
-        if (parameters) users_url += "?" + parameters;
-
-        // Get user list
-        var xhr = new XMLHttpRequest();
-        xhr.open("GET", users_url, false);
-        xhr.send(null);
-
-        // Handle response
-        GuacamoleService.handleResponse(xhr);
-
-        // Otherwise, get list
-        var users = new Array();
-
-        var userElements = xhr.responseXML.getElementsByTagName("user");
-        for (var i=0; i<userElements.length; i++)
-            users.push(userElements[i].getAttribute("name"));
-
-        // Sort by username
-        users.sort();
-
-        return users;
-     
-    },
-
-    /**
-     * Updates the user having the given username.
-     * 
-     * @param {String} username The username of the user to create.
-     * @param {String} password The password to assign to the user (optional).
-     * @param {GuacamoleService.PermissionSet} permissions_added All permissions that were added.
-     * @param {GuacamoleService.PermissionSet} permissions_removed All permissions that were removed.
-     * @param {String} parameters Any parameters which should be passed to the
-     *                            server for the sake of authentication
-     *                            (optional).
-     */
-    "update" : function(username, password, permissions_added,
-        permissions_removed, parameters) {
-
-        // Construct request URL
-        var users_url = "users/update";
-        if (parameters) users_url += "?" + parameters;
-
-        // Init POST data
-        var data = "name=" + encodeURIComponent(username);
-        if (password) data += "&password=" + encodeURIComponent(password);
-
-        var name;
-
-        // System permissions
-        if (permissions_added.create_user)             data += "&%2Bsys=create-user";
-        if (permissions_added.create_connection)       data += "&%2Bsys=create-connection";
-        if (permissions_added.create_connection_group) data += "&%2Bsys=create-connection-group";
-        if (permissions_added.administer)              data += "&%2Bsys=admin";
-
-        // User permissions 
-        for (name in permissions_added.read_user)
-            data += "&%2Buser=read:"  + encodeURIComponent(name);
-        for (name in permissions_added.administer_user)
-            data += "&%2Buser=admin:" + encodeURIComponent(name);
-        for (name in permissions_added.update_user)
-            data += "&%2Buser=update:" + encodeURIComponent(name);
-        for (name in permissions_added.remove_user)
-            data += "&%2Buser=delete:" + encodeURIComponent(name);
-
-        // Connection permissions 
-        for (name in permissions_added.read_connection)
-            data += "&%2Bconnection=read:" + encodeURIComponent(name);
-        for (name in permissions_added.administer_connection)
-            data += "&%2Bconnection=admin:" + encodeURIComponent(name);
-        for (name in permissions_added.update_connection)
-            data += "&%2Bconnection=update:" + encodeURIComponent(name);
-        for (name in permissions_added.remove_connection)
-            data += "&%2Bconnection=delete:" + encodeURIComponent(name);
-
-        // Connection group permissions 
-        for (name in permissions_added.read_connection_group)
-            data += "&%2Bconnection-group=read:" + encodeURIComponent(name);
-        for (name in permissions_added.administer_connection_group)
-            data += "&%2Bconnection-group=admin:" + encodeURIComponent(name);
-        for (name in permissions_added.update_connection_group)
-            data += "&%2Bconnection-group=update:" + encodeURIComponent(name);
-        for (name in permissions_added.remove_connection_group)
-            data += "&%2Bconnection-group=delete:" + encodeURIComponent(name);
-
-        // Creation permissions
-        if (permissions_removed.create_user)             data += "&-sys=create-user";
-        if (permissions_removed.create_connection)       data += "&-sys=create-connection";
-        if (permissions_removed.create_connection_group) data += "&-sys=create-connection-group";
-        if (permissions_removed.administer)              data += "&-sys=admin";
-
-        // User permissions 
-        for (name in permissions_removed.read_user)
-            data += "&-user=read:"  + encodeURIComponent(name);
-        for (name in permissions_removed.administer_user)
-            data += "&-user=admin:" + encodeURIComponent(name);
-        for (name in permissions_removed.update_user)
-            data += "&-user=update:" + encodeURIComponent(name);
-        for (name in permissions_removed.remove_user)
-            data += "&-user=delete:" + encodeURIComponent(name);
-
-        // Connection permissions 
-        for (name in permissions_removed.read_connection)
-            data += "&-connection=read:" + encodeURIComponent(name);
-        for (name in permissions_removed.administer_connection)
-            data += "&-connection=admin:" + encodeURIComponent(name);
-        for (name in permissions_removed.update_connection)
-            data += "&-connection=update:" + encodeURIComponent(name);
-        for (name in permissions_removed.remove_connection)
-            data += "&-connection=delete:" + encodeURIComponent(name);
-
-        // Connection group permissions 
-        for (name in permissions_removed.read_connection_group)
-            data += "&-connection-group=read:" + encodeURIComponent(name);
-        for (name in permissions_removed.administer_connection_group)
-            data += "&-connection-group=admin:" + encodeURIComponent(name);
-        for (name in permissions_removed.update_connection_group)
-            data += "&-connection-group=update:" + encodeURIComponent(name);
-        for (name in permissions_removed.remove_connection_group)
-            data += "&-connection-group=delete:" + encodeURIComponent(name);
-
-        // Update user
-        var xhr = new XMLHttpRequest();
-        xhr.open("POST", users_url, false);
-        xhr.setRequestHeader("Content-type", "application/x-www-form-urlencoded; charset=UTF-8");
-        xhr.send(data);
-
-        // Handle response
-        GuacamoleService.handleResponse(xhr);
-
-    },
-
-    /**
-     * Creates a new user having the given username.
-     * 
-     * @param {String} username The username of the user to create.
-     * @param {String} parameters Any parameters which should be passed to the
-     *                            server for the sake of authentication
-     *                            (optional).
-     */
-    "create" : function(username, parameters) {
-
-        // Construct request URL
-        var users_url = "users/create?name=" + encodeURIComponent(username);
-        if (parameters) users_url += "&" + parameters;
-
-        // Add user
-        var xhr = new XMLHttpRequest();
-        xhr.open("GET", users_url, false);
-        xhr.send(null);
-
-        // Handle response
-        GuacamoleService.handleResponse(xhr);
-
-    },
-
-    /**
-     * Deletes the user having the given username.
-     * 
-     * @param {String} username The username of the user to delete.
-     * @param {String} parameters Any parameters which should be passed to the
-     *                            server for the sake of authentication
-     *                            (optional).
-     */
-    "remove" : function(username, parameters) {
-
-        // Construct request URL
-        var users_url = "users/delete?name=" + encodeURIComponent(username);
-        if (parameters) users_url += "&" + parameters;
-
-        // Add user
-        var xhr = new XMLHttpRequest();
-        xhr.open("GET", users_url, false);
-        xhr.send(null);
-
-        // Handle response
-        GuacamoleService.handleResponse(xhr);
-
-    }
-
-};
-
-/**
- * Collection of service functions which deal with permissions. Each function
- * makes an explicit HTTP query to the server, and parses the response.
- */
-GuacamoleService.Permissions = {
-
-     /**
-      * Returns a PermissionSet describing the permissions given to a
-      * specified user.
-      *
-      * @param {String} username The username of the user to list permissions
-      *                          of. 
-      * @param {String} parameters Any parameters which should be passed to the
-      *                            server for the sake of authentication
-      *                            (optional).
-      * @return {GuacamoleService.PermissionSet} A PermissionSet describing the
-      *                                          permissions given to the
-      *                                          specified user.
-      */   
-    "list" : function(username, parameters) {
-
-        // Construct request URL
-        var list_url = "permissions";
-        if (parameters) list_url += "?" + parameters;
-
-        // Init POST data
-        var data;
-        if (username)
-            data = "user=" + encodeURIComponent(username);
-        else
-            data = null;
-
-        // Get permission list
-        var xhr = new XMLHttpRequest();
-        xhr.open("POST", list_url, false);
-        xhr.setRequestHeader("Content-type", "application/x-www-form-urlencoded; charset=UTF-8");
-        xhr.send(data);
-
-        // Handle response
-        GuacamoleService.handleResponse(xhr);
-
-        // Otherwise, build PermissionSet
-        var i, type, name;
-        var permissions = new GuacamoleService.PermissionSet();
-
-        // Read system permissions
-        var connectionsElements = xhr.responseXML.getElementsByTagName("system");
-        for (i=0; i<connectionsElements.length; i++) {
-
-            // Get type
-            type = connectionsElements[i].getAttribute("type");
-            switch (type) {
-
-                // Create connection permission
-                case "create-connection":
-                    permissions.create_connection = true;
-                    break;
-
-                // Create connection group permission
-                case "create-connection-group":
-                    permissions.create_connection_group = true;
-                    break;
-
-                // Create user permission
-                case "create-user":
-                    permissions.create_user = true;
-                    break;
-
-                // System admin permission
-                case "admin":
-                    permissions.administer = true;
-                    break;
-
-            }
-
-        }
-
-        // Read connection permissions
-        var connectionElements = xhr.responseXML.getElementsByTagName("connection");
-        for (i=0; i<connectionElements.length; i++) {
-
-            // Get name and type
-            type = connectionElements[i].getAttribute("type");
-            name = connectionElements[i].getAttribute("name");
-
-            switch (type) {
-
-                // Read permission
-                case "read":
-                    permissions.read_connection[name] = true;
-                    break;
-
-                // Update permission
-                case "update":
-                    permissions.update_connection[name] = true;
-                    break;
-
-                // Admin permission
-                case "admin":
-                    permissions.administer_connection[name] = true;
-                    break;
-
-                // Delete permission
-                case "delete":
-                    permissions.remove_connection[name] = true;
-                    break;
-
-            }
-
-        }
-
-        // Read connection group permissions
-        var connectionGroupElements = xhr.responseXML.getElementsByTagName("connection-group");
-        for (i=0; i<connectionGroupElements.length; i++) {
-
-            // Get name and type
-            type = connectionGroupElements[i].getAttribute("type");
-            name = connectionGroupElements[i].getAttribute("name");
-
-            switch (type) {
-
-                // Read permission
-                case "read":
-                    permissions.read_connection_group[name] = true;
-                    break;
-
-                // Update permission
-                case "update":
-                    permissions.update_connection_group[name] = true;
-                    break;
-
-                // Admin permission
-                case "admin":
-                    permissions.administer_connection_group[name] = true;
-                    break;
-
-                // Delete permission
-                case "delete":
-                    permissions.remove_connection_group[name] = true;
-                    break;
-
-            }
-
-        }
-
-        // Read user permissions
-        var userElements = xhr.responseXML.getElementsByTagName("user");
-        for (i=0; i<userElements.length; i++) {
-
-            // Get name and type
-            type = userElements[i].getAttribute("type");
-            name = userElements[i].getAttribute("name");
-
-            switch (type) {
-
-                // Read permission
-                case "read":
-                    permissions.read_user[name] = true;
-                    break;
-
-                // Update permission
-                case "update":
-                    permissions.update_user[name] = true;
-                    break;
-
-                // Admin permission
-                case "admin":
-                    permissions.administer_user[name] = true;
-                    break;
-
-                // Delete permission
-                case "delete":
-                    permissions.remove_user[name] = true;
-                    break;
-
-            }
-
-        }
-
-        return permissions;
- 
-    }
-
-};
-
-/**
- * Representation of a protocol supported by Guacamole, having a given name,
- * title, and set of parameters.
- */
-GuacamoleService.Protocol = function(name, title, parameters) {
-
-    /**
-     * The unique name associated with this protocol. This is the name that is
-     * used to identify the protocol to Guacamole.
-     */
-    this.name = name;
-
-    /**
-     * A human-readable title describing this protocol. This is what the user
-     * will use to identify the protocol.
-     */
-    this.title = title;
-
-    /**
-     * Array of all available parameters, in desired order of presentation.
-     * @type GuacamoleService.Protocol.Parameter[]
-     */
-    this.parameters = parameters || [];
-
-};
-
-/**
- * A parameter belonging to a protocol. Each parameter has a name which
- * identifies the parameter to the protocol, a human-readable title, 
- * a value for boolean parameters, and a type which dictates 
- * its presentation to the user.
- */
-GuacamoleService.Protocol.Parameter = function(name, title, type, value, options) {
-
-    /**
-     * The name of this parameter.
-     */
-    this.name = name;
-
-    /**
-     * A human-readable title describing this parameter.
-     */
-    this.title = title;
-
-    /**
-     * The type of this parameter.
-     */
-    this.type = type;
-
-    /**
-     * The value of this parameter.
-     */
-    this.value = value;
-
-    /**
-     * All available options, if applicable, in desired order of presentation.
-     * @type GuacamoleService.Protocol.Parameter.Option[]
-     */
-    this.options = options || [];
-
-};
-
-/**
- * An option for a parameter. A parameter has options if it only has a specified
- * and enumerated legal set of values.
- */
-GuacamoleService.Protocol.Parameter.Option = function(value, title) {
-
-    /**
-     * The value of this option. This is the value that will be assigned to the
-     * parameter if this option is chosen.
-     */
-    this.value = value;
-
-    /**
-     * The title of this option. This is the value that will be presented to the
-     * user for selection.
-     */
-    this.title = title;
-
-};
-
-/**
- * A free-form text field.
- */
-GuacamoleService.Protocol.Parameter.TEXT     = 0;
-
-/**
- * A password field.
- */
-GuacamoleService.Protocol.Parameter.PASSWORD = 1;
-
-/**
- * A numeric field.
- */
-GuacamoleService.Protocol.Parameter.NUMERIC  = 2;
-
-/**
- * A boolean (checkbox) field.
- */
-GuacamoleService.Protocol.Parameter.BOOLEAN  = 3;
-
-/**
- * An enumerated (select) field.
- */
-GuacamoleService.Protocol.Parameter.ENUM     = 4;
-
-/**
- * Collection of service functions which deal with protocols. Each function
- * makes an explicit HTTP query to the server, and parses the response.
- */
-GuacamoleService.Protocols = {
-
-     /**
-      * Returns an array containing all available protocols and all
-      * corresponding parameters, as well as hints regarding expected datatype
-      * and allowed/default values.
-      * 
-      * Note that this function is a stub returning a simple object until the
-      * corresponding server-side component is created.
-      *
-      * @param {String} parameters Any parameters which should be passed to the
-      *                            server for the sake of authentication
-      *                            (optional).
-      * @return {GuacamoleService.Protocol[]} An array containing all available
-      *                                     protocols.
-      */   
-    "list" : function(parameters) {
-
-        // Construct request URL
-        var list_url = "protocols";
-        if (parameters) list_url += "?" + parameters;
-
-        // Get permission list
-        var xhr = new XMLHttpRequest();
-        xhr.open("GET", list_url, false);
-        xhr.send(null);
-
-        // Handle response
-        GuacamoleService.handleResponse(xhr);
-
-        // Array of all protocols
-        var protocols = [];
-
-        // Parse all protocols
-        var protocolElements = xhr.responseXML.getElementsByTagName("protocol");
-        for (var i=0; i<protocolElements.length; i++) {
-
-            // Get protocol element
-            var protocolElement = protocolElements[i];
-
-            // Create corresponding protocol
-            var protocol = new GuacamoleService.Protocol(
-                protocolElement.getAttribute("name"),
-                protocolElement.getAttribute("title")
-            );
-
-            // Parse all parameters
-            var paramElements = protocolElement.getElementsByTagName("param");
-            for (var j=0; j<paramElements.length; j++) {
-
-                // Get parameter element
-                var paramElement = paramElements[j];
-
-                // Create corresponding parameter
-                var parameter = new GuacamoleService.Protocol.Parameter(
-                    paramElement.getAttribute("name"),
-                    paramElement.getAttribute("title")
-                );
-
-                // Parse type
-                switch (paramElement.getAttribute("type")) {
-
-                    // Text parameter
-                    case "text":
-                        parameter.type = GuacamoleService.Protocol.Parameter.TEXT;
-                        break;
-
-                    // Password parameter
-                    case "password":
-                        parameter.type = GuacamoleService.Protocol.Parameter.PASSWORD;
-                        break;
-
-                    // Numeric parameter
-                    case "numeric":
-                        parameter.type = GuacamoleService.Protocol.Parameter.NUMERIC;
-                        break;
-
-                    // Boolean parameter
-                    case "boolean":
-                        parameter.type = GuacamoleService.Protocol.Parameter.BOOLEAN;
-                        parameter.value = paramElement.getAttribute("value");
-                        break;
-
-                    // Enumerated parameter
-                    case "enum":
-                        parameter.type = GuacamoleService.Protocol.Parameter.ENUM;
-                        break;
-
-                }
-
-                // Parse all options
-                var optionElements = paramElement.getElementsByTagName("option");
-                for (var k=0; k<optionElements.length; k++) {
-
-                    // Get option element
-                    var optionElement = optionElements[k];
-
-                    parameter.options.push(
-                        new GuacamoleService.Protocol.Parameter.Option(
-                            optionElement.getAttribute("value"),
-                            optionElement.textContent
-                        ));
-
-                } // end for each option
-
-                // Add parameter 
-                protocol.parameters.push(parameter);
-
-            } // end for each parameter
-
-            // Add protocol
-            protocols.push(protocol);
-
-        } // end for each protocol
-
-        return protocols;
-
-    }
-
-};
\ No newline at end of file
diff --git a/guacamole/src/main/webapp/scripts/session.js b/guacamole/src/main/webapp/scripts/session.js
deleted file mode 100644
index 9b25a89..0000000
--- a/guacamole/src/main/webapp/scripts/session.js
+++ /dev/null
@@ -1,107 +0,0 @@
-
-/*
- *  Guacamole - Clientless Remote Desktop
- *  Copyright (C) 2010  Michael Jumper
- *
- *  This program is free software: you can redistribute it and/or modify
- *  it under the terms of the GNU Affero General Public License as published by
- *  the Free Software Foundation, either version 3 of the License, or
- *  (at your option) any later version.
- *
- *  This program is distributed in the hope that it will be useful,
- *  but WITHOUT ANY WARRANTY; without even the implied warranty of
- *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- *  GNU Affero General Public License for more details.
- *
- *  You should have received a copy of the GNU Affero General Public License
- *  along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-/**
- * Maintains state across multiple Guacamole pages via HTML5 Web Storage.
- * @constructor
- */
-function GuacamoleSessionState() {
-
-    /**
-     * Reference to this GuacamoleSessionState.
-     * @private
-     */
-    var guac_state = this;
-
-    /**
-     * The last read state object.
-     * @private
-     */
-    var state = localStorage.getItem("GUACAMOLE_STATE") || {};
-
-    /**
-     * Reloads the internal state, sending onchange events for all changed,
-     * deleted, or new properties.
-     */
-    this.reload = function() {
-
-        // Pull current state
-        var new_state = JSON.parse(localStorage.getItem("GUACAMOLE_STATE") || "{}");
-        
-        // Assign new state
-        var old_state = state;
-        state = new_state;
-
-        // Check if any values are different
-        for (var name in new_state) {
-
-            // If value changed, call handler
-            var old = old_state[name];
-            if (old != new_state[name]) {
-
-                // Call change handler
-                if (guac_state.onchange)
-                    guac_state.onchange(state, new_state, name);
-
-            }
-
-        }
-
-    };
-
-    /**
-     * Sets the given property to the given value.
-     * 
-     * @param {String} name The name of the property to change.
-     * @param value An arbitrary value.
-     */
-    this.setProperty = function(name, value) {
-        state[name] = value;
-        localStorage.setItem("GUACAMOLE_STATE", JSON.stringify(state));
-    };
-
-    /**
-     * Returns the value stored under the property having the given name.
-     * 
-     * @param {String} name The name of the property to read.
-     * @return The value of the given property.
-     */
-    this.getProperty = function(name) {
-        return state[name];
-    };
-
-    /**
-     * Event which is fired whenever a property value is changed externally.
-     * 
-     * @event
-     * @param old_state An object whose properties' values are the old values
-     *                  of this GuacamoleSessionState.
-     * @param new_state An object whose properties' values are the new values
-     *                  of this GuacamoleSessionState.
-     * @param {String} name The name of the property that is being changed.
-     */
-    this.onchange = null;
-
-    // Reload when modified
-    window.addEventListener("storage", guac_state.reload, false);
-
-    // Initial load
-    guac_state.reload();
-
-}
diff --git a/guacamole/src/main/webapp/styles/animation.css b/guacamole/src/main/webapp/styles/animation.css
deleted file mode 100644
index 80bb237..0000000
--- a/guacamole/src/main/webapp/styles/animation.css
+++ /dev/null
@@ -1,35 +0,0 @@
-
-/*
- *  Guacamole - Clientless Remote Desktop
- *  Copyright (C) 2010  Michael Jumper
- *
- *  This program is free software: you can redistribute it and/or modify
- *  it under the terms of the GNU Affero General Public License as published by
- *  the Free Software Foundation, either version 3 of the License, or
- *  (at your option) any later version.
- *
- *  This program is distributed in the hope that it will be useful,
- *  but WITHOUT ANY WARRANTY; without even the implied warranty of
- *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- *  GNU Affero General Public License for more details.
- *
- *  You should have received a copy of the GNU Affero General Public License
- *  along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-/**
- * fadein: Fade from fully transparent to fully opaque.
- */
- at keyframes fadein {
-    from { opacity: 0; }
-    to   { opacity: 1; }
-}
- at -moz-keyframes fadein {
-    from { opacity: 0; }
-    to   { opacity: 1; }
-}
- at -webkit-keyframes fadein {
-    from { opacity: 0; }
-    to   { opacity: 1; }
-}
-
diff --git a/guacamole/src/main/webapp/styles/client.css b/guacamole/src/main/webapp/styles/client.css
deleted file mode 100644
index 94ce614..0000000
--- a/guacamole/src/main/webapp/styles/client.css
+++ /dev/null
@@ -1,420 +0,0 @@
-
-/*
- *  Guacamole - Clientless Remote Desktop
- *  Copyright (C) 2010  Michael Jumper
- *
- *  This program is free software: you can redistribute it and/or modify
- *  it under the terms of the GNU Affero General Public License as published by
- *  the Free Software Foundation, either version 3 of the License, or
- *  (at your option) any later version.
- *
- *  This program is distributed in the hope that it will be useful,
- *  but WITHOUT ANY WARRANTY; without even the implied warranty of
- *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- *  GNU Affero General Public License for more details.
- *
- *  You should have received a copy of the GNU Affero General Public License
- *  along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-body {
-    background: black;
-    font-family: FreeSans, Helvetica, Arial, sans-serif;
-    padding: 0;
-    margin: 0;
-}
-
-img {
-    border: none;
-}
-
-.software-cursor {
-    cursor: url('../images/mouse/dot.gif'),url('../images/mouse/blank.cur'),default;
-    overflow: hidden;
-}
-
-.guac-error .software-cursor {
-    cursor: default;
-}
-
-* {
-    -webkit-tap-highlight-color: rgba(0,0,0,0);
-}
-
-.event-target {
-    position: fixed;
-    opacity: 0;
-}
-
-/* Dialogs */
-
-div.dialogOuter {
-    display: table;
-    height: 100%;
-    width: 100%;
-    position: fixed;
-    left: 0;
-    top: 0;
-    background: rgba(0, 0, 0, 0.75);
-}
-
-div.dialogMiddle {
-    width: 100%;
-    text-align: center;
-    display: table-cell;
-    vertical-align: middle;
-}
-
-div.dialog {
-    padding: 1em;
-
-    max-width: 75%;
-    text-align: left;
-
-    display: inline-block;
-}
-
-div.dialog h1 {
-    margin: 0;
-    margin-bottom: 0.25em;
-    text-align: center;
-}
-
-div.dialog div.buttons {
-    margin: 0;
-    margin-top: 0.5em;
-    text-align: center;
-}
-
-button {
-
-    border-style: solid;
-    border-width: 1px;
-
-    padding: 0.25em;
-    padding-right: 1em;
-    padding-left: 1em;
-
-}
-
-button:active {
-
-    padding-top: 0.35em;
-    padding-left: 1.1em;
-
-    padding-bottom: 0.15em;
-    padding-right: 0.9em;
-
-}
-
-button#reconnect {
-    display: none;
-}
-
-.guac-error button#reconnect {
-    display: inline;
-
-    background:   #200;
-    border-color: #822;
-    color:        #944; 
-}
-
-.guac-error button#reconnect:hover {
-    background:   #822;
-    border-color: #B33;
-    color:        black; 
-}
-
-
-div.dialog p {
-    margin: 0;
-}
-
-div.displayOuter {
-    height: 100%;
-    width: 100%;
-    position: absolute;
-    left: 0;
-    top: 0;
-    display: table;
-}
-
-div.displayMiddle {
-    width: 100%;
-    display: table-cell;
-    vertical-align: middle;
-    text-align: center;
-}
-
-div#display * {
-    position: relative;
-}
-
-div#display > * {
-    margin-left: auto;
-    margin-right: auto;
-}
-
-div.magnifier-background {
-    position: absolute;
-    left: 0;
-    top: 0;
-    width: 100%;
-    height: 100%;
-    z-index: 1;
-    overflow: hidden;
-}
-
-div.magnifier {
-
-    position: absolute;
-    left: 0;
-    top: 0;
-    
-    box-shadow: 2px 2px 10px rgba(0, 0, 0, 0.75);
-    width: 50%;
-    height: 50%;
-    overflow: hidden;
-
-}
-
-.pan-overlay,
-.type-overlay {
-    position: fixed;
-    left: 0;
-    top: 0;
-    width: 100%;
-    height: 100%;
-    z-index: 1;
-}
-
-.pan-overlay .indicator {
-    position: fixed;
-    background-size: 32px 32px;
-    -moz-background-size: 32px 32px;
-    -webkit-background-size: 32px 32px;
-    -khtml-background-size: 32px 32px;
-    background-position: center;
-    background-repeat: no-repeat;
-    opacity: 0.8;
-}
-
-.pan-overlay .indicator.up {
-
-    top: 0;
-    left: 0;
-    right: 0;
-    height: 32px;
-
-    background-image: url('../images/arrows/arrows-u.png');
-    
-}
-
-.pan-overlay .indicator.down {
-
-    bottom: 0;
-    left: 0;
-    right: 0;
-    height: 32px;
-
-    background-image: url('../images/arrows/arrows-d.png');
-    
-}
-
-.pan-overlay .indicator.left {
-
-    top: 0;
-    bottom: 0;
-    left: 0;
-    width: 32px;
-
-    background-image: url('../images/arrows/arrows-l.png');
-    
-}
-
-.pan-overlay .indicator.right {
-
-    top: 0;
-    bottom: 0;
-    right: 0;
-    width: 32px;
-
-    background-image: url('../images/arrows/arrows-r.png');
-    
-}
-
-/* Viewport Clone */
-
-div#viewportClone {
-    display: table;
-    height: 100%;
-    width: 100%;
-    position: fixed;
-    left: 0;
-    top: 0;
-
-    visibility: hidden;
-}
-
-.status {
-    text-shadow: 0 0 0.25em black, 0 0 0.25em black, 0 0 0.25em black, 0 0 0.25em black;
-    font-size: xx-large;
-    color: white;
-}
-
-.guac-error .status {
-    text-shadow: 0 0 0.25em black, 0 0 0.25em black, 0 0 0.25em black, 0 0 0.25em black;
-    color: #D44;
-}
-
-p.hint {
-    
-    border: 0.25em solid rgba(255, 255, 255, 0.25);
-    background: black;
-    opacity: 0.75;
-
-    color: white;
-
-    max-width: 10em;
-    padding: 1em;
-    margin: 1em;
-
-    position: absolute;
-    left: 0;
-    top: 0;
-
-    box-shadow: 0.25em 0.25em 0.25em rgba(0, 0, 0, 0.75);
-    
-}
-
-#notificationArea {
-    position: fixed;
-    right: 0.5em;
-    bottom: 0.5em;
-    max-width: 25%;
-    min-width: 10em;
-}
-
-.notification {
-
-    font-size: 0.9em;
-    
-    border: 1px solid rgba(255, 255, 255, 0.25);
-    background: black;
-    opacity: 0.9;
-
-    color: white;
-
-    padding: 0.5em;
-    margin: 1em;
-    overflow: hidden;
-
-    box-shadow: 0.25em 0.25em 0.25em rgba(0, 0, 0, 0.75);
- 
-}
-
-.notification div {
-    display: inline-block;
-}
-
-.notification .title-bar {
-    display: block;
-    white-space: nowrap;
-    font-weight: bold;
-
-    border-bottom: 1px solid white;
-    padding-bottom: 0.5em;
-    margin-bottom: 0.5em;
-}
-
-.notification .title-bar * {
-    vertical-align: middle;
-}
-
-.notification .caption {
-    color: silver;
-}
-
-.notification .close {
-
-    background: url('../images/action-icons/guac-close.png');
-    background-size: 10px 10px;
-    -moz-background-size: 10px 10px;
-    -webkit-background-size: 10px 10px;
-    -khtml-background-size: 10px 10px;
-
-    width: 10px;
-    height: 10px;
-
-    float: right;
-    cursor: pointer;
-
-}
-
- at keyframes progress {
-    from {background-position: 0px  0px;}
-    to   {background-position: 64px 0px;}
-}
-
- at -webkit-keyframes progress {
-    from {background-position: 0px  0px;}
-    to   {background-position: 64px 0px;}
-}
-
-.download.notification .caption {
-    width: 100%;
-    white-space: nowrap;
-    overflow: hidden;
-    text-overflow: ellipsis;
-}
-
-.download.notification .progress,
-.download.notification .download {
-
-    margin-top: 1em;
-    margin-left: 0.75em;
-    padding: 0.25em;
-    min-width: 5em;
-    
-    border: 1px solid gray;
-    border-radius: 0.2em;
-
-    text-align: center;
-    float: right;
-
-}
-
-.download.notification .progress {
-
-    background: #444 url('../images/progress.png');
-    background-size: 16px 16px;
-    -moz-background-size: 16px 16px;
-    -webkit-background-size: 16px 16px;
-    -khtml-background-size: 16px 16px;
-
-    animation-name: progress;
-    animation-duration: 2s;
-    animation-timing-function: linear;
-    animation-iteration-count: infinite;
-
-    -webkit-animation-name: progress;
-    -webkit-animation-duration: 2s;
-    -webkit-animation-timing-function: linear;
-    -webkit-animation-iteration-count: infinite;
-
-}
-
-.download.notification .download {
-    background: rgb(16, 87, 153);
-    cursor: pointer;
-}
-
-#preload {
-    visibility: hidden;
-    position: absolute;
-    left: 0;
-    right: 0;
-    width: 0;
-    height: 0;
-    overflow: hidden;
-}
\ No newline at end of file
diff --git a/guacamole/src/main/webapp/styles/keyboard.css b/guacamole/src/main/webapp/styles/keyboard.css
deleted file mode 100644
index d2608cd..0000000
--- a/guacamole/src/main/webapp/styles/keyboard.css
+++ /dev/null
@@ -1,150 +0,0 @@
-
-/*
- *  Guacamole - Clientless Remote Desktop
- *  Copyright (C) 2010  Michael Jumper
- *
- *  This program is free software: you can redistribute it and/or modify
- *  it under the terms of the GNU Affero General Public License as published by
- *  the Free Software Foundation, either version 3 of the License, or
- *  (at your option) any later version.
- *
- *  This program is distributed in the hope that it will be useful,
- *  but WITHOUT ANY WARRANTY; without even the implied warranty of
- *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- *  GNU Affero General Public License for more details.
- *
- *  You should have received a copy of the GNU Affero General Public License
- *  along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-.keyboard-container {
-    text-align: center;
-
-    position: fixed;
-    left: 0;
-    bottom: 0;
-    width: 100%;
-    margin: 0;
-    padding: 0;
-
-    border-top: 1px solid black;
-    background: #222;
-    opacity: 0.85;
-
-    z-index: 1;
-}
-
-.guac-keyboard {
-    display: inline-block;
-    width: 100%;
-    
-    margin: 0;
-    padding: 0;
-    cursor: default;
-
-    text-align: left;
-    vertical-align: middle;
-}
-
-.guac-keyboard .guac-keyboard-key-container {
-    display: inline-block;
-}
-
-.guac-keyboard .guac-keyboard-key {
-    background: #444;
-    border: 1px outset #888;
-    -moz-border-radius: 0.1em;
-    -webkit-border-radius: 0.1em;
-    -khtml-border-radius: 0.1em;
-    border-radius: 0.1em;
-}
-
-.guac-keyboard .guac-keyboard-cap {
-    color: white;
-    font-family: sans-serif;
-    font-size: 50%;
-    font-weight: lighter;
-    text-align: center;
-    white-space: pre;
-}
-
-.guac-keyboard .guac-keyboard-key:hover {
-    cursor: pointer;
-}
-
-.guac-keyboard .guac-keyboard-key.highlight {
-    background: #666;
-    border-color: #666;
-}
-
-.guac-keyboard.guac-keyboard-modifier-shift .guac-keyboard-key.shift,
-.guac-keyboard.guac-keyboard-modifier-numsym .guac-keyboard-key.numsym {
-    background: #882;
-    border-color: #DD4;
-}
-
-.guac-keyboard.guac-keyboard-modifier-control .guac-keyboard-key.control,
-.guac-keyboard.guac-keyboard-modifier-numsym .guac-keyboard-key.numsym {
-    background: #882;
-    border-color: #DD4;
-}
-
-.guac-keyboard.guac-keyboard-modifier-alt .guac-keyboard-key.alt,
-.guac-keyboard.guac-keyboard-modifier-numsym .guac-keyboard-key.numsym {
-    background: #882;
-    border-color: #DD4;
-}
-
-.guac-keyboard.guac-keyboard-modifier-super .guac-keyboard-key.super,
-.guac-keyboard.guac-keyboard-modifier-numsym .guac-keyboard-key.numsym {
-    background: #882;
-    border-color: #DD4;
-}
-
-.guac-keyboard .guac-keyboard-key.guac-keyboard-pressed {
-    background: #822;
-    border-color: #D44;
-    border-style: inset;
-}
-
-.guac-keyboard .guac-keyboard-row {
-    line-height: 0;
-}
-
-.guac-keyboard .guac-keyboard-column {
-    display: inline-block;
-    text-align: center;
-    vertical-align: top;
-}
-
-.guac-keyboard .guac-keyboard-gap {
-    display: inline-block;
-}
-
-/* Hide keycaps requiring modifiers which are NOT currently active. */
-.guac-keyboard:not(.guac-keyboard-modifier-caps)
-.guac-keyboard-cap.guac-keyboard-requires-caps,
-
-.guac-keyboard:not(.guac-keyboard-modifier-numsym)
-.guac-keyboard-cap.guac-keyboard-requires-numsym,
-
-.guac-keyboard:not(.guac-keyboard-modifier-shift)
-.guac-keyboard-cap.guac-keyboard-requires-shift,
-
-/* Hide keycaps NOT requiring modifiers which ARE currently active, where that
-   modifier is used to determine which cap is displayed for the current key. */
-.guac-keyboard.guac-keyboard-modifier-shift
-.guac-keyboard-key.guac-keyboard-uses-shift
-.guac-keyboard-cap:not(.guac-keyboard-requires-shift),
-
-.guac-keyboard.guac-keyboard-modifier-numsym
-.guac-keyboard-key.guac-keyboard-uses-numsym
-.guac-keyboard-cap:not(.guac-keyboard-requires-numsym),
-
-.guac-keyboard.guac-keyboard-modifier-caps
-.guac-keyboard-key.guac-keyboard-uses-caps
-.guac-keyboard-cap:not(.guac-keyboard-requires-caps) {
-
-    display: none;
-    
-}
diff --git a/guacamole/src/main/webapp/styles/login.css b/guacamole/src/main/webapp/styles/login.css
deleted file mode 100644
index 2927032..0000000
--- a/guacamole/src/main/webapp/styles/login.css
+++ /dev/null
@@ -1,350 +0,0 @@
-
-/*
- *  Guacamole - Clientless Remote Desktop
- *  Copyright (C) 2010  Michael Jumper
- *
- *  This program is free software: you can redistribute it and/or modify
- *  it under the terms of the GNU Affero General Public License as published by
- *  the Free Software Foundation, either version 3 of the License, or
- *  (at your option) any later version.
- *
- *  This program is distributed in the hope that it will be useful,
- *  but WITHOUT ANY WARRANTY; without even the implied warranty of
- *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- *  GNU Affero General Public License for more details.
- *
- *  You should have received a copy of the GNU Affero General Public License
- *  along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-* {
-    -webkit-tap-highlight-color: rgba(0,0,0,0);
-}
-
-input[type=checkbox], input[type=text], textarea {
-    -webkit-tap-highlight-color: rgba(128,192,128,0.5);
-}
-
-input[type=submit], button {
-    -webkit-appearance: none;
-}
-
-body {
-    background: #EEE;
-    font-family: FreeSans, Helvetica, Arial, sans-serif;
-    padding: 0;
-    margin: 0;
-}
-
-#manage {
-    display: none;
-}
-
-.admin #manage {
-    display: inline-block;
-}
-
-div#login-ui {
-    height: 100%;
-    width: 100%;
-    position: fixed;
-    left: 0;
-    top: 0;
-    display: table;
-}
-
-p#login-error {
-    text-align: center;
-    background: #FDD;
-    color: red;
-    margin: 0.2em;
-}
-
-div#login-logo {
-    position: relative;
-    bottom: 0;
-    display: inline-block;
-    vertical-align: middle;
-}
-
-div#login-dialog-middle {
-    width: 100%;
-    display: table-cell;
-    vertical-align: middle;
-    text-align: center;
-}
-
-div#login-dialog {
-
-    max-width: 75%;
-    text-align: left;
-
-    display: inline-block;
-}
-
-div#login-dialog h1 {
-    margin-top: 0;
-    margin-bottom: 0em;
-    text-align: center;
-}
-
-div#login-dialog #buttons {
-    padding-top: 0.5em;
-    text-align: right;
-}
-
-div#login-dialog #buttons input,
-div#logout-panel button {
-
-    background: #8A6;
-    border: 1px solid rgba(0, 0, 0, 0.4);
-    -moz-border-radius: 0.6em;
-    -webkit-border-radius: 0.6em;
-    -khtml-border-radius: 0.6em;
-    border-radius: 0.6em;
-
-    color: white;
-    text-shadow: -1px -1px rgba(0, 0, 0, 0.3);
-    font-weight: bold;
-    font-size: 1.125em;
-
-    box-shadow: inset -1px -1px 0.25em rgba(0, 0, 0, 0.25),
-                inset 1px 1px 0.25em rgba(255, 255, 255, 0.25),
-                -1px -1px 0.25em rgba(0, 0, 0, 0.25),
-                1px 1px 0.25em rgba(255, 255, 255, 0.25);
-    
-    padding: 0.35em;
-    padding-right: 1em;
-    padding-left: 1em;
-    min-width: 5em;
-
-}
-
-div#login-dialog #buttons input:hover,
-div#logout-panel button:hover {
-    background: #9C7;
-}
-
-div#login-dialog #buttons input:active,
-div#logout-panel button:active {
-
-    padding-left: 1.1em;
-    padding-right: 0.9em;
-    padding-top: 0.45em;
-    padding-bottom: 0.25em;
-    
-    box-shadow: 
-                inset 1px 1px 0.25em rgba(0, 0, 0, 0.25),
-                -1px -1px 0.25em rgba(0, 0, 0, 0.25),
-                1px 1px 0.25em rgba(255, 255, 255, 0.25);
-}
-
-div#login-dialog #login-fields {
-    
-    vertical-align: middle;
-
-    padding: 1em;
-    background: #DDD;
-    border: 1px solid #999;
-    -moz-border-radius: 0.25em;
-    -webkit-border-radius: 0.25em;
-    -khtml-border-radius: 0.25em;
-    border-radius: 0.25em;
-
-}
-
-div#login-dialog th {
-    text-shadow: 1px 1px white;
-}
-
-div#login-dialog #login-fields input {
-    border: 1px solid #777;
-    -moz-border-radius: 0.2em;
-    -webkit-border-radius: 0.2em;
-    -khtml-border-radius: 0.2em;
-    border-radius: 0.2em;
-    width: 100%;
-}
-
-div#login-dialog #login-fields img.logo {
-    position: fixed;
-    margin: 10px;
-    left: 0;
-    bottom: 0;
-    opacity: 0.1;
-    z-index: -1;
-}
-
-div#version-dialog {
-    position: fixed;
-    right: 0;
-    bottom: 0;
-    text-align: right;
-
-    font-style: italic;
-    font-size: 0.75em;
-    color: black;
-    opacity: 0.5;
-
-    padding: 0.5em;
-}
-
-img {
-    border: none;
-}
-
-img#license {
-    float: right;
-    margin: 2px;
-}
-
-div#connection-list-ui h1 {
-    
-    margin: 0;
-    padding: 0.5em;
-
-    font-size: 2em;
-    vertical-align: middle;
-    text-align: center;
-
-}
-
-div#connection-list-ui h2 {
-
-    padding: 0.5em;
-    margin: 0;
-    font-size: 1.5em;
-
-    font-weight: lighter;
-    text-shadow: 1px 1px white;
-
-    border-top: 1px solid #AAA;
-    border-bottom: 1px solid #AAA;
-    background: #DDD;
-    
-}
-
-div#connection-list-ui img {
-    vertical-align: middle;
-}
-
-div#logout-panel {
-    padding: 0.45em;
-    text-align: right;
-    float: right;
-}
-
-.history-unavailable div#recent-connections {
-    display: none;
-}
-
-div#recent-connections,
-div#clipboardDiv,
-div#settings,
-div#all-connections {
-    margin: 1em;
-    padding: 0;
-}
-
-#all-connections .list-buttons {
-    text-align: center;
-    padding: 0;
-}
-
-div#recent-connections {
-    text-align: center;
-}
-
-#no-recent {
-
-    color: black;
-    text-shadow: 1px 1px white;
-    opacity: 0.5;
-    
-    font-size: 2em;
-    font-weight: bolder;
-}
-
-div#recent-connections div.connection {
-    -moz-border-radius: 0.5em;
-    -webkit-border-radius: 0.5em;
-    -khtml-border-radius: 0.5em;
-    border-radius: 0.5em;
-    display: inline-block;
-    padding: 1em;
-    margin: 1em;
-    text-align: center;
-    max-width: 75%;
-    overflow: hidden;
-}
-
-.group,
-.connection {
-    cursor: pointer;
-}
-
-.connection:hover {
-    background: #CDA;
-}
-
-.group,
-.connection .name {
-    color: black;
-    font-weight: normal;
-    padding: 0.1em;
-}
-
-.connection .thumbnail {
-    margin: 0.5em;
-}
-
-.connection .thumbnail img {
-    border: 1px solid black;
-    box-shadow: 1px 1px 5px black;
-    max-width: 75%;
-}
-
-div#all-connections .connection {
-    display: block;
-    text-align: left;
-}
-
-div#recent-connections .connection .thumbnail {
-    display: block;
-}
-
-div#all-connections .connection {
-    padding: 0.1em;
-}
-
-div#recent-connections .protocol {
-    display: none;
-}
-
-.caption * {
-    vertical-align: middle;
-}
-
-.caption .name {
-    margin-left: 0.25em;
-}
-
-#clipboardDiv textarea {
-    width: 100%;
-    border: 1px solid #AAA;
-    -moz-border-radius: 0.25em;
-    -webkit-border-radius: 0.25em;
-    -khtml-border-radius: 0.25em;
-    border-radius: 0.25em;
-}
-
-#settings dt {
-    border-bottom: 1px dotted #AAA;
-    padding-bottom: 0.25em;
-}
-
-#settings dd {
-    margin: 1.5em;
-    margin-left: 2.5em;
-    font-size: 0.75em;
-}
\ No newline at end of file
diff --git a/guacamole/src/main/webapp/styles/ui.css b/guacamole/src/main/webapp/styles/ui.css
deleted file mode 100644
index fe4f72f..0000000
--- a/guacamole/src/main/webapp/styles/ui.css
+++ /dev/null
@@ -1,611 +0,0 @@
-
-/*
- *  Guacamole - Clientless Remote Desktop
- *  Copyright (C) 2010  Michael Jumper
- *
- *  This program is free software: you can redistribute it and/or modify
- *  it under the terms of the GNU Affero General Public License as published by
- *  the Free Software Foundation, either version 3 of the License, or
- *  (at your option) any later version.
- *
- *  This program is distributed in the hope that it will be useful,
- *  but WITHOUT ANY WARRANTY; without even the implied warranty of
- *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- *  GNU Affero General Public License for more details.
- *
- *  You should have received a copy of the GNU Affero General Public License
- *  along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
- at import url('animation.css');
- 
-* {
-    -webkit-tap-highlight-color: rgba(0,0,0,0);
-}
-
-input[type=checkbox], input[type=number], input[type=text], textarea {
-    -webkit-tap-highlight-color: rgba(128,192,128,0.5);
-}
-
-input[type=submit], button {
-    -webkit-appearance: none;
-}
-
-div.location, input[type=text], input[type=number], input[type=password] {
-    border: 1px solid #777;
-    -moz-border-radius: 0.2em;
-    -webkit-border-radius: 0.2em;
-    -khtml-border-radius: 0.2em;
-    border-radius: 0.2em;
-    width: 100%;
-    padding: 0.25em;
-    font-size: 10pt;
-    background: white;
-    cursor: text;
-}
-
-button {
-
-    background: #8A6;
-    border: 1px solid rgba(0, 0, 0, 0.4);
-    -moz-border-radius: 0.6em;
-    -webkit-border-radius: 0.6em;
-    -khtml-border-radius: 0.6em;
-    border-radius: 0.6em;
-
-    color: white;
-    text-shadow: -1px -1px rgba(0, 0, 0, 0.3);
-    font-weight: bold;
-    font-size: 1.125em;
-
-    box-shadow: inset -1px -1px 0.25em rgba(0, 0, 0, 0.25),
-                inset 1px 1px 0.25em rgba(255, 255, 255, 0.25),
-                -1px -1px 0.25em rgba(0, 0, 0, 0.25),
-                1px 1px 0.25em rgba(255, 255, 255, 0.25);
-    
-    padding: 0.35em;
-    padding-right: 1em;
-    padding-left: 1em;
-    min-width: 5em;
-
-}
-
-button:hover {
-    background: #9C7;
-}
-
-button:active {
-
-    padding-left: 1.1em;
-    padding-right: 0.9em;
-    padding-top: 0.45em;
-    padding-bottom: 0.25em;
-    
-    box-shadow: 
-                inset 1px 1px 0.25em rgba(0, 0, 0, 0.25),
-                -1px -1px 0.25em rgba(0, 0, 0, 0.25),
-                1px 1px 0.25em rgba(255, 255, 255, 0.25);
-}
-
-button.danger {
-    background: #A43;
-}
-
-button.danger:hover {
-    background: #C54;
-}
-
-body {
-    background: #EEE;
-    font-family: FreeSans, Helvetica, Arial, sans-serif;
-    padding: 0;
-    margin: 0;
-}
-
-img {
-    border: none;
-    vertical-align: middle;
-}
-
-div#version-dialog {
-    position: fixed;
-    right: 0;
-    bottom: 0;
-    text-align: right;
-
-    font-style: italic;
-    font-size: 0.75em;
-    color: black;
-    opacity: 0.5;
-
-    padding: 0.5em;
-}
-
-h1 {
-    
-    margin: 0;
-    padding: 0.5em;
-
-    font-size: 2em;
-    vertical-align: middle;
-    text-align: center;
-
-}
-
-h2 {
-
-    border-top: 1px solid #AAA;
-    border-bottom: 1px solid #AAA;
-    background: rgba(0, 0, 0, 0.07);
-
-    padding: 0.5em;
-    margin: 0;
-    font-size: 1.5em;
-
-    font-weight: lighter;
-    text-shadow: 1px 1px white;
-
-}
-
-div.section {
-    margin: 0;
-    padding: 1em;
-}
-
-/*
- * Dialogs
- */
-
-.dialog-container {
-    position: fixed;
-    top: 0;
-    left: 0;
-    bottom: 0;
-    right: 0;
-    background: rgba(0, 0, 0, 0.5);
-    padding: 1em;
-}
-
-.dialog {
-
-    max-width: 100%;
-    width: 8in;
-    margin-left: auto;
-    margin-right: auto;
-    max-height: 100%;
-    overflow: auto;
-
-    border: 1px solid rgba(0, 0, 0, 0.5);
-    background: #E7E7E7;
-
-    -moz-border-radius: 0.2em;
-    -webkit-border-radius: 0.2em;
-    -khtml-border-radius: 0.2em;
-    border-radius: 0.2em;
-
-    box-shadow: 0.1em 0.1em 0.2em rgba(0, 0, 0, 0.6);
-    
-}
-
-.dialog > * {
-    margin: 1em;
-}
-
-.dialog .header {
-    margin: 0;
-}
-
-.dialog td {
-    position: relative;
-}
-
-.dialog .overlay {
-    position: fixed;
-    top: 0;
-    left: 0;
-    bottom: 0;
-    right: 0;
-    z-index: 1;
-}
-
-.dialog .dropdown {
-
-    position: absolute;
-    z-index: 2;
-    margin-top: -1px;
-
-    width: 3in;
-    max-height: 2in;
-    overflow: auto;
-
-    border: 1px solid rgba(0, 0, 0, 0.5);
-    background: white;
-
-    font-size: 10pt;
-
-}
-
-.dialog .footer {
-    text-align: center;
-}
-
-/*
- * List elements
- */
-
-.list-item {
-
-    display: block;
-    text-align: left;
-    cursor: pointer;
-
-    position: relative;
-
-}
-
-.icon {
-    width: 24px;
-    height: 24px;
-    background-size: 16px 16px;
-    -moz-background-size: 16px 16px;
-    -webkit-background-size: 16px 16px;
-    -khtml-background-size: 16px 16px;
-    background-repeat: no-repeat;
-    background-position: center center;
-    opacity: 0.5;
-    display: inline-block;
-    vertical-align: middle;
-}
-
-.list-item * {
-    vertical-align: middle;
-}
-
-.list-item .caption {
-    padding: 0.1em;
-}
-
-.list-item .name {
-    color: black;
-    font-weight: normal;
-    padding: 0.1em;
-    margin-left: 0.25em;
-}
-
-.list-item .usage {
-    float: right;
-    font-style: italic;
-    color: gray;
-}
-
-.list-item.in-use {
-    opacity: 0.5;
-}
-
-.choice .list-item .usage {
-    display: none;
-}
-
-.choice .list-item.in-use {
-    opacity: 1;
-}
-
-/*
- * List element styling
- */
-
-.list-item.selected {
-    background: #DEB;
-}
-
-.list-item.selected > .icon {
-    opacity: 1.0;
-}
-
-.list-item:not(.selected) .caption:hover {
-    background: #CDA;
-}
-
-.choice .list-item {
-    display: inline-block;
-}
-
-.choice input[type='checkbox'] {
-    vertical-align: top;
-    height: 24px;
-    padding: 0;
-    margin: 0;
-}
-
-.disabled .list-item:not(.selected) {
-    opacity: 0.25;
-}
-
-.disabled .list-item:not(.selected):hover {
-    background: inherit;
-}
-
-/*
- * List element fields (editing)
- */
-
-/*
-.form {
-
-    position: absolute;
-    display: inline-block;
-    vertical-align: middle;
-    z-index: 1;
-
-    border: 1px solid rgba(0, 0, 0, 0.5);
-    background: #E7E7E7;
-    padding: 0;
-    margin: 0.25em;
-
-    -moz-border-radius: 0.2em;
-    -webkit-border-radius: 0.2em;
-    -khtml-border-radius: 0.2em;
-    border-radius: 0.2em;
-
-    box-shadow: 0.1em 0.1em 0.2em rgba(0, 0, 0, 0.6);
-
-}
-*/
-
-.form .fields th,
-.form .permissions th {
-    font-weight: normal;
-    vertical-align: middle;
-    text-align: left;
-}
-
-.form h2 {
-    border-top: none;
-}
-
-.form h3 {
-    font-size: 1em;
-    margin-bottom: 0.25em;
-}
-
-.form {
-    cursor: auto;
-    animation-name: fadein;
-    -webkit-animation-name: fadein;
-    animation-duration: 0.125s;
-    -webkit-animation-duration: 0.125s;
-}
-
-.object-buttons {
-
-    text-align: right;
-
-    border-top: 1px solid rgba(0, 0, 0, 0.1);
-    padding-top: 0.5em;
-    margin: 0.5em;
-
-}
-
-/*
- * List element icons
- */
-
-.icon.user {
-    background-image: url('../images/user-icons/guac-user.png');
-}
-
-.icon.user.add {
-    background-image: url('../images/action-icons/guac-user-add.png');
-}
-
-.icon.connection {
-    background-image: url('../images/protocol-icons/guac-plug.png');
-}
-
-.icon.connection.add {
-    background-image: url('../images/action-icons/guac-monitor-add.png');
-}
-
-.protocol {
-    display: inline-block;
-}
-
-.protocol .icon {
-    width: 24px;
-    height: 24px;
-    background-image: url('../images/protocol-icons/guac-plug.png');
-    background-size: 16px 16px;
-    -moz-background-size: 16px 16px;
-    -webkit-background-size: 16px 16px;
-    -khtml-background-size: 16px 16px;
-    background-repeat: no-repeat;
-    background-position: center center;
-    opacity: 0.5;
-}
-
-.protocol .icon.ssh {
-    background-image: url('../images/protocol-icons/guac-text.png');
-}
-
-.protocol .icon.vnc,
-.protocol .icon.rdp {
-    background-image: url('../images/protocol-icons/guac-monitor.png');
-}
-
-.connection .thumbnail {
-    display: none;
-}
-
-/*
- * Groups
- */
-
-.group > .children {
-    margin-left: 13px;
-    padding-left: 6px;
-    display: none;
-}
- 
-.group.expanded > .children {
-    display: block;
-    border-left: 1px dotted rgba(0, 0, 0, 0.25);
-}
-
-.group > .caption .icon.type {
-    display: none;
-}
-
-.group.balancer > .caption .icon.type {
-    display: inline-block;
-    background-image: url('../images/protocol-icons/guac-monitor.png');
-}
-
-.group > .caption .icon.group {
-    opacity: 0.75;
-    background-image: url('../images/group-icons/guac-closed.png');
-}
-
-.group.expanded > .caption .icon.group {
-    background-image: url('../images/group-icons/guac-open.png');
-}
-
-.group.empty > .caption .icon.group {
-    opacity: 0.25;
-    background-image: url('../images/group-icons/guac-open.png');
-}
-
-.group.empty.balancer > .caption .icon.group {
-    display: none;
-}
-
-/*
- * Settings formatting
- */
-
-.form dt,
-.settings dt {
-    border-bottom: 1px dotted #AAA;
-    padding-bottom: 0.25em;
-}
-
-.form dd,
-.settings dd {
-    margin: 1.5em;
-    margin-left: 2.5em;
-    font-size: 0.75em;
-}
-
-#connections input.name,
-#users input.name {
-    max-width: 80%;
-    width: 20em;
-}
-
-#connection-list,
-#user-list {
-    border: 1px solid rgba(0, 0, 0, 0.25);
-    min-height: 20em;
-    -moz-border-radius: 0.2em;
-    -webkit-border-radius: 0.2em;
-    -khtml-border-radius: 0.2em;
-    border-radius: 0.2em;
-}
-
-#connections #add-connection,
-#connections #add-connection-group,
-#users #add-user {
-    font-size: 0.8em;
-}
-
-#connection-add-form,
-#user-add-form {
-    margin: 0.5em;
-}
-
-body:not(.manage-connections) .require-manage-connections,
-body:not(.manage-users) .require-manage-users {
-    display: none;
-}
-
-body:not(.add-connections) #add-connection,
-body:not(.add-connection-groups) #add-connection-group,
-body:not(.add-users) #user-add-form {
-    display: none;
-    display: none;
-}
-
-div#logout-panel {
-    padding: 0.45em;
-    text-align: right;
-    float: right;
-}
-
-.history th,
-.history td {
-    padding-left: 1em;
-    padding-right: 1em;
-}
-
-.first-page,
-.prev-page,
-.set-page,
-.next-page,
-.last-page {
-    cursor: pointer;
-    vertical-align: middle;
-}
-
-.first-page.disabled,
-.prev-page.disabled,
-.set-page.disabled,
-.next-page.disabled,
-.last-page.disabled {
-    cursor: auto;
-    opacity: 0.25;
-}
-
-.set-page,
-.more-pages {
-    display: inline-block;
-    padding: 0.25em;
-    text-align: center;
-    min-width: 1.25em;
-}
-
-.set-page {
-    text-decoration: underline;
-}
-
-.set-page.current {
-    cursor: auto;
-    text-decoration: none;
-    font-weight: bold;
-    background: rgba(0, 0, 0, 0.1);
-    border: 1px solid rgba(0, 0, 0, 0.1);
-    -moz-border-radius: 0.2em;
-    -webkit-border-radius: 0.2em;
-    -khtml-border-radius: 0.2em;
-    border-radius: 0.2em;
-}
-
-.icon.first-page {
-    background-image: url('../images/action-icons/guac-first-page.png');
-}
-
-.icon.prev-page {
-    background-image: url('../images/action-icons/guac-prev-page.png');
-}
-
-.icon.next-page {
-    background-image: url('../images/action-icons/guac-next-page.png');
-}
-
-.icon.last-page {
-    background-image: url('../images/action-icons/guac-last-page.png');
-}
-
-.buttons,
-.list-pager-buttons {
-    text-align: center;
-    margin: 1em;
-}
\ No newline at end of file
diff --git a/guacamole/src/main/webapp/translations/de.json b/guacamole/src/main/webapp/translations/de.json
new file mode 100644
index 0000000..996f6ad
--- /dev/null
+++ b/guacamole/src/main/webapp/translations/de.json
@@ -0,0 +1,624 @@
+{
+    
+    "NAME" : "Deutsch",
+    
+    "APP" : {
+
+        "ACTION_ACKNOWLEDGE"        : "OK",
+        "ACTION_CANCEL"             : "Abbruch",
+        "ACTION_CLONE"              : "Kopieren",
+        "ACTION_CONTINUE"           : "Weiter",
+        "ACTION_DELETE"             : "Löschen",
+        "ACTION_DELETE_SESSIONS"    : "Beende Sitzung",
+        "ACTION_LOGIN"              : "Anmelden",
+        "ACTION_LOGOUT"             : "Abmelden",
+        "ACTION_MANAGE_CONNECTIONS" : "Verbindungen",
+        "ACTION_MANAGE_PREFERENCES" : "Einstellungen",
+        "ACTION_MANAGE_SETTINGS"    : "Einstellungen",
+        "ACTION_MANAGE_SESSIONS"    : "Aktive Sitzungen",
+        "ACTION_MANAGE_USERS"       : "Benutzer",
+        "ACTION_NAVIGATE_BACK"      : "Zurück",
+        "ACTION_NAVIGATE_HOME"      : "Startseite",
+        "ACTION_SAVE"               : "Speichern",
+        "ACTION_SEARCH"             : "Suche",
+        "ACTION_UPDATE_PASSWORD"    : "Aktualisiere Passwort",
+        "ACTION_VIEW_HISTORY"       : "Verlauf",
+
+        "DIALOG_HEADER_ERROR" : "Fehler",
+
+        "ERROR_PASSWORD_BLANK"    : "Bitte ein Passwort vergeben.",
+        "ERROR_PASSWORD_MISMATCH" : "Die Passwörter stimmen nicht überein.",
+        
+        "FIELD_HEADER_PASSWORD"       : "Passwort:",
+        "FIELD_HEADER_PASSWORD_AGAIN" : "Wiederhole Passwort:",
+
+        "FIELD_PLACEHOLDER_FILTER" : "Filter",
+
+        "FORMAT_DATE_TIME_PRECISE" : "dd-MM-yyyy HH:mm:ss",
+
+        "INFO_ACTIVE_USER_COUNT" : "In Benutzung durch {USERS} Benutzer.",
+
+        "NAME" : "Guacamole ${project.version}",
+
+        "TEXT_HISTORY_DURATION" : "{VALUE} {UNIT, select, second{{VALUE, plural, one{Sekunde} other{Sekunden}}} minute{{VALUE, plural, one{Minute} other{Minuten}}} hour{{VALUE, plural, one{Stunde} other{Stunden}}} day{{VALUE, plural, one{Tag} other{Tage}}} other{}}"
+
+    },
+
+    "CLIENT" : {
+
+        "ACTION_ACKNOWLEDGE"               : "@:APP.ACTION_ACKNOWLEDGE",
+        "ACTION_CLEAR_COMPLETED_TRANSFERS" : "Entferne abgeschlossene Übertragungen",
+        "ACTION_DISCONNECT"                : "Trennen",
+        "ACTION_LOGOUT"                    : "@:APP.ACTION_LOGOUT",
+        "ACTION_NAVIGATE_BACK"             : "@:APP.ACTION_NAVIGATE_BACK",
+        "ACTION_NAVIGATE_HOME"             : "@:APP.ACTION_NAVIGATE_HOME",
+        "ACTION_RECONNECT"                 : "Neu Verbinden",
+        "ACTION_SAVE_FILE"                 : "@:APP.ACTION_SAVE",
+        "ACTION_UPLOAD_FILES"              : "Dateien hochladen",
+
+        "DIALOG_HEADER_CONNECTING"       : "Verbindung",
+        "DIALOG_HEADER_CONNECTION_ERROR" : "Verbindungsfehler",
+        "DIALOG_HEADER_DISCONNECTED"     : "Verbindung getrennt",
+
+        "ERROR_CLIENT_201"     : "Aufgrund hoher Serverauslastung wurde diese Verbindung zurückgesetzt. Versuche es in wenigen Minuten erneut.",
+        "ERROR_CLIENT_202"     : "Der Verbindungsaufbau wurde durch den Guacamole Server abgebrochen da der Entfernte Computer nicht reagiert. Versuche es noch einmal oder kontaktiere den Systemadministrator.",        
+        "ERROR_CLIENT_203"     : "Der entfernte Computer hat einen Fehler hervorgerufen und die Verbindung geschlossen. Versuche es noch einmal oder kontaktiere den Systemadministrator.",
+        "ERROR_CLIENT_205"     : "Diese Verbindung kollidiert mit einer bestehenen Verbindung. Versuche es später erneut.",
+        "ERROR_CLIENT_301"     : "Anmeldung Fehlgeschlagen. Bitte schließen Sie und versuchen Sie es erneut.",
+        "ERROR_CLIENT_303"     : "Sie haben keine Berechtigung auf diese Verbindung zuzugreifen. Wenn Sie diese Berechtigung benötigen, bitten Sie den Systemadministrator diese Berechtigung hinzuzufügen oder prüfen Sie Ihre Systemeinstellugen.",
+        "ERROR_CLIENT_308"     : "Die Verbindung wurde durch den Guacamole Server geschlossen da keine aktive Interkommukaion mit dem Browser besteht. Dies wird gewöhnlich durch Netzwerkprobleme verursacht, wie eine schlechte drahtlose Verbindung oder eine sehr langsame Netzwerkverbindung. Bitte überprüfen Sie Ihre Netzwerkverbindung und versuchen Sie es erneut.",
+        "ERROR_CLIENT_31D"     : "Der Zugang zu dieser Verbindung wurde durch den Guacamole Server verweigert, da die maximale Anzahl der gleichzeitigen Zugiffe für diese Verbindung für einen einzelnen Benutzer erreicht wurde.",
+        "ERROR_CLIENT_DEFAULT" : "Die Verbindung wurde aufgrund eines interen Fehlers im Guacamole Server beendet. Sollte dieses Problem weiterhin bestehen informieren Sie den Systemadministrator oder überprüfen Sie die Protokolle.",
+
+        "ERROR_TUNNEL_201"     : "Der Verbindungsversuch wurde aufgrund zu vieler bestehenden Verbindungen durch den Guacamole Server zurückgewiesen. Versuche es in wenigen Minuten erneut.",
+        "ERROR_TUNNEL_202"     : "Die Verbindung zum Server wurde aufgrund hoher Latenz geschlossen. Dies wird gewöhnlich durch Netzwerkprobleme verursacht, wie eine schlechte drahtlose Verbindung oder eine sehr langsame Netzwerkverbindung. Bitte überprüfen Sie Ihre Netzwerkverbindung und versuchen Sie es erneut.",
+        "ERROR_TUNNEL_203"     : "Die Verbindung wurde aufgrund eines interen Fehlers beendet. Versuche es noch einmal oder kontaktiere den Systemadministrator.",
+        "ERROR_TUNNEL_204"     : "Die angeforderte Verbindung exisiert nicht. Bitte überprüfe den Verbindungsnamen und versuche es erneut.",
+        "ERROR_TUNNEL_205"     : "Diese Verbindung ist in Verwendung, ein konkurrierender Zugriff ist nicht gestattet. Versuche es später erneut.",
+        "ERROR_TUNNEL_301"     : "Sie haben keine Zugriffsberechtigung für diese Verbindung. Bitte melden Sie sich an und versuchen es erneut.",
+        "ERROR_TUNNEL_303"     : "Sie haben keine Zugriffsberechtigung für diese Verbindung. Wenn Sie diese Berechtigung benötigen, bitten Sie den Systemadministrator diese Berechtigung hinzuzufügen oder prüfen Sie Ihre Systemeinstellugen.",
+        "ERROR_TUNNEL_308"     : "Die Verbindung wurde durch den Guacamole Server geschlossen da keine aktive Interkommunikation mit dem Browser besteht. Dies wird gewöhnlich durch Netzwerkprobleme verursacht, wie eine schlechte drahtlose Verbindung oder eine sehr langsame Netzwerkverbindung. Bitte überprüfen Sie Ihre Netzwerkverbindung und versuchen Sie es erneut.",
+        "ERROR_TUNNEL_31D"     : "Der Zugang zu dieser Verbindung wurde durch den Guacamole Server verweigert, da die maximale Anzahl der gleichzeitigen Zugiffe für einen einzelnen Benutzer erreicht wurde. Bitte schliesse eine oder mehrere Verbindungen und versuche es erneut.",
+        "ERROR_TUNNEL_DEFAULT" : "Die Verbindung wurde aufgrund eines interen Fehlers im Guacamole Server beendet. Sollte dieses Problem weiterhin bestehen informieren Sie den Systemadministrator oder überprüfen Sie die Protokolle.",
+
+        "ERROR_UPLOAD_100"     : "Dateiübertragungen werden entweder nicht unterstützt oder sind nicht aktiviert. Bitte kontaktiere den Systemadministrator oder überprüfe die Protokolle.",
+        "ERROR_UPLOAD_201"     : "Die maximale Anzahl gleichzeitiger Dateiübertragungen erreicht. Bitte warte bis laufende Dateiübertagungen abgeschlossen sind und versuche es erneut.",
+        "ERROR_UPLOAD_202"     : "Die Dateiübertragung konnte nicht gestartet werden da der Entfernet Computer nicht reagiert. Bitte versuche es erneut oder kontaktiere den Systemadministrator.",
+        "ERROR_UPLOAD_203"     : "Der entfernte Computer hat bei der Übertragungen einen Fehler verursacht. Bitte versuche es erneut oder kontaktiere den Systemadministrator.",
+        "ERROR_UPLOAD_204"     : "Das Übertragungsziel existiert nicht. Bitte überprüfe ob der Zielort exisitert und versuche es erneut.",
+        "ERROR_UPLOAD_205"     : "Das Übertragungsziel ist zur Zeit gesperrt. Bitte warte bis alle laufenden Prozesse beendet wurden und versuche es erneut.",
+        "ERROR_UPLOAD_301"     : "Es besteht ohne Anmeldung keine Berechtigung zur Dateiübertragung. Bitte anmelden und erneut versuchen.",
+        "ERROR_UPLOAD_303"     : "Es besteht keine Berechtingung zur Dateiübertragung. Wenn Sie diese Berechtigung benötigen überprüfen Sie die Systemeinstellungen oder überprüfen Sie diese gemeinsam mit dem Systemadministrator.",
+        "ERROR_UPLOAD_308"     : "Die Dateiübertragung weist keinen Fortschritt auf. Dies wird gewöhnlich durch Netzwerkprobleme verursacht, wie eine schlechte drahtlose Verbindung oder eine sehr langsame Netzwerkverbindung. Bitte überprüfen Sie Ihre Netzwerkverbindung und versuchen Sie es erneut.",
+        "ERROR_UPLOAD_31D"     : "Die maximale Anzahl gleichzeiter Dateiübertragungen erreicht. Bitte warte bis laufende Dateiübertagungen abgeschlossen sind und versuche es erneut.",
+        "ERROR_UPLOAD_DEFAULT" : "Die Verbindung wurde aufgrund eines interen Fehlers im Guacamole Server beendet. Sollte dieses Problem weiterhin bestehen informieren Sie den Systemadministrator oder überprüfen Sie die Protokolle.",
+
+        "HELP_CLIPBOARD"           : "Kopierter oder ausgeschnittener Text aus Guacamole wird hier angezeigt. Änderungen am Text werden direkt auf die entfernte Zwischenablage angewandt.",
+        "HELP_INPUT_METHOD_NONE"   : "Keine Eingabemethode in Verwendung. Tastatureingaben werden von der Hardwaretastatur akzeptiert.",
+        "HELP_INPUT_METHOD_OSK"    : "Bildschirmeingaben und die eingebettete Guacamole Bildschrimtastatur werden akzeptiert. Die Bildschirmtastatur gestattet Tastenkombinationen die ansonsten unmöglich sind (z.B.: Strg-Alt-Del).",
+        "HELP_INPUT_METHOD_TEXT"   : "Gestattet Eingaben von Text und emuliert Tastaturkombinationen basierend auf den eingegebenen Text. Dies wird benötigt für Geräte ohne Hardwaretastatur.",
+        "HELP_MOUSE_MODE"          : "Beeinflusst, wie sich die entfernte Maus bei Touchpadberührungen verhält.",
+        "HELP_MOUSE_MODE_ABSOLUTE" : "Tippen Sie auf die Zielposition, der Klick erfolgt am Ort der Berührung des Touchscreen's.",
+        "HELP_MOUSE_MODE_RELATIVE" : "Den Mauszeiger zur Zielposition bewegen und klicken. Der Klick erfolgt an der Position des Mauszeigers.",
+
+        "INFO_NO_FILE_TRANSFERS" : "Keine Dateiübertragungen.",
+
+        "NAME_INPUT_METHOD_NONE"   : "Keine",
+        "NAME_INPUT_METHOD_OSK"    : "Bildschirmtastatur",
+        "NAME_INPUT_METHOD_TEXT"   : "Texteingabe",
+        "NAME_KEY_CTRL"            : "Strg",
+        "NAME_KEY_ALT"             : "Alt",
+        "NAME_KEY_ESC"             : "Esc",
+        "NAME_KEY_TAB"             : "Tab",
+        "NAME_MOUSE_MODE_ABSOLUTE" : "Touchscreen",
+        "NAME_MOUSE_MODE_RELATIVE" : "Touchpad",
+
+        "SECTION_HEADER_CLIPBOARD"      : "Zwischenablage",
+        "SECTION_HEADER_DEVICES"        : "Geräte",
+        "SECTION_HEADER_DISPLAY"        : "Anzeige",
+        "SECTION_HEADER_FILE_TRANSFERS" : "Dateiübertragung",
+        "SECTION_HEADER_INPUT_METHOD"   : "Eingabemethode",
+        "SECTION_HEADER_MOUSE_MODE"     : "Mausemulationsmodus",
+
+        "TEXT_ZOOM_AUTO_FIT"              : "Autoanpassung Fenstergröße",
+        "TEXT_CLIENT_STATUS_IDLE"         : "Inaktiv.",
+        "TEXT_CLIENT_STATUS_CONNECTING"   : "Verbindungsaufbau zu Guacamole...",
+        "TEXT_CLIENT_STATUS_DISCONNECTED" : "Verbindung wurde getrennt.",
+        "TEXT_CLIENT_STATUS_WAITING"      : "Verbindungsaufbau zu Guacamole. Bitte warten...",
+        "TEXT_RECONNECT_COUNTDOWN"        : "Neuverbindung in {REMAINING} {REMAINING, plural, one{Sekunde} other{Sekunden}}...",
+        "TEXT_FILE_TRANSFER_PROGRESS"     : "{PROGRESS} {UNIT, select, b{B} kb{KB} mb{MB} gb{GB} other{}}",
+
+        "URL_OSK_LAYOUT" : "layouts/de-de-qwertz.json"
+
+    },
+
+    "DATA_SOURCE_DEFAULT" : {
+        "NAME" : "Standard (XML)"
+    },
+
+    "FORM" : {
+
+        "FIELD_PLACEHOLDER_DATE" : "YYYY-MM-DD",
+        "FIELD_PLACEHOLDER_TIME" : "HH:MM:SS",
+
+        "HELP_SHOW_PASSWORD" : "Passwort anzeigen",
+        "HELP_HIDE_PASSWORD" : "Passwort ausblenden"
+
+    },
+
+    "HOME" : {
+
+        "FIELD_PLACEHOLDER_FILTER" : "@:APP.FIELD_PLACEHOLDER_FILTER",
+
+        "INFO_ACTIVE_USER_COUNT" : "@:APP.INFO_ACTIVE_USER_COUNT",
+
+        "INFO_NO_RECENT_CONNECTIONS" : "Keine aktiven Verbindungen.",
+        
+        "PASSWORD_CHANGED" : "Passwort geändert.",
+
+        "SECTION_HEADER_ALL_CONNECTIONS"    : "Alle Verbindungen",
+        "SECTION_HEADER_RECENT_CONNECTIONS" : "Letzte Verbindungen"
+
+    },
+
+    "LOGIN": {
+
+        "ACTION_ACKNOWLEDGE" : "@:APP.ACTION_ACKNOWLEDGE",
+        "ACTION_CONTINUE"    : "@:APP.ACTION_CONTINUE",
+        "ACTION_LOGIN"       : "@:APP.ACTION_LOGIN",
+
+        "DIALOG_HEADER_ERROR" : "@:APP.DIALOG_HEADER_ERROR",
+
+        "ERROR_INVALID_LOGIN" : "Anmeldungsfehler",
+
+        "FIELD_HEADER_USERNAME" : "Benutzername",
+        "FIELD_HEADER_PASSWORD" : "Passwort"
+
+    },
+
+    "MANAGE_CONNECTION" : {
+
+        "ACTION_ACKNOWLEDGE"          : "@:APP.ACTION_ACKNOWLEDGE",
+        "ACTION_CANCEL"               : "@:APP.ACTION_CANCEL",
+        "ACTION_CLONE"                : "@:APP.ACTION_CLONE",
+        "ACTION_DELETE"               : "@:APP.ACTION_DELETE",
+        "ACTION_SAVE"                 : "@:APP.ACTION_SAVE",
+
+        "DIALOG_HEADER_CONFIRM_DELETE" : "Lösche Verbindung",
+        "DIALOG_HEADER_ERROR"          : "@:APP.DIALOG_HEADER_ERROR",
+
+        "FIELD_HEADER_LOCATION" : "Standort:",
+        "FIELD_HEADER_NAME"     : "Name:",
+        "FIELD_HEADER_PROTOCOL" : "Protokol:",
+
+        "FORMAT_HISTORY_START" : "@:APP.FORMAT_DATE_TIME_PRECISE",
+
+        "INFO_CONNECTION_DURATION_UNKNOWN" : "--",
+        "INFO_CONNECTION_ACTIVE_NOW"       : "Aktivieren",
+        "INFO_CONNECTION_NOT_USED"         : "Diese Verbindung wurde bisher nicht verwendet.",
+
+        "SECTION_HEADER_EDIT_CONNECTION" : "Bearbeite Verbindung",
+        "SECTION_HEADER_HISTORY"         : "Verlauf",
+        "SECTION_HEADER_PARAMETERS"      : "Parameter",
+
+        "TABLE_HEADER_HISTORY_USERNAME" : "Benutzername",
+        "TABLE_HEADER_HISTORY_START"    : "Begin",
+        "TABLE_HEADER_HISTORY_DURATION" : "Dauer",
+
+        "TEXT_CONFIRM_DELETE"   : "Dieser Löschvorgang ist unumkehrbar. Soll diese Verbindung wirklich gelöscht werden?",
+        "TEXT_HISTORY_DURATION" : "@:APP.TEXT_HISTORY_DURATION"
+
+    },
+
+    "MANAGE_CONNECTION_GROUP" : {
+
+        "ACTION_ACKNOWLEDGE"   : "@:APP.ACTION_ACKNOWLEDGE",
+        "ACTION_CANCEL"        : "@:APP.ACTION_CANCEL",
+        "ACTION_DELETE"        : "@:APP.ACTION_DELETE",
+        "ACTION_SAVE"          : "@:APP.ACTION_SAVE",
+
+        "DIALOG_HEADER_CONFIRM_DELETE" : "Lösche Verbindungsgruppe",
+        "DIALOG_HEADER_ERROR"          : "@:APP.DIALOG_HEADER_ERROR",
+
+        "FIELD_HEADER_LOCATION" : "Standort:",
+        "FIELD_HEADER_NAME"     : "Name:",
+        "FIELD_HEADER_TYPE"     : "Typ:",
+
+        "NAME_TYPE_BALANCING"       : "Balancing",
+        "NAME_TYPE_ORGANIZATIONAL"  : "Organisation",
+
+        "SECTION_HEADER_EDIT_CONNECTION_GROUP" : "Ändere Verbindungsgruppe",
+
+        "TEXT_CONFIRM_DELETE" : "Dieser Löschvorgang ist unumkehrbar. Soll diese Verbindungsgruppe wirklich gelöscht werden?"
+
+    },
+
+    "MANAGE_USER" : {
+
+        "ACTION_ACKNOWLEDGE"   : "@:APP.ACTION_ACKNOWLEDGE",
+        "ACTION_CANCEL"        : "@:APP.ACTION_CANCEL",
+        "ACTION_DELETE"        : "@:APP.ACTION_DELETE",
+        "ACTION_SAVE"          : "@:APP.ACTION_SAVE",
+
+        "DIALOG_HEADER_CONFIRM_DELETE" : "Lösche Benutzer",
+        "DIALOG_HEADER_ERROR"          : "@:APP.DIALOG_HEADER_ERROR",
+
+        "ERROR_PASSWORD_MISMATCH" : "@:APP.ERROR_PASSWORD_MISMATCH",
+
+        "FIELD_HEADER_ADMINISTER_SYSTEM"             : "Administration:",
+        "FIELD_HEADER_CHANGE_OWN_PASSWORD"           : "Ändere eigenes Passwort:",
+        "FIELD_HEADER_CREATE_NEW_USERS"              : "Erstelle neue Benutzer:",
+        "FIELD_HEADER_CREATE_NEW_CONNECTIONS"        : "Erstelle neue Verbindung:",
+        "FIELD_HEADER_CREATE_NEW_CONNECTION_GROUPS"  : "Erstelle neue Verbindungsgruppe:",
+        "FIELD_HEADER_PASSWORD"                      : "@:APP.FIELD_HEADER_PASSWORD",
+        "FIELD_HEADER_PASSWORD_AGAIN"                : "@:APP.FIELD_HEADER_PASSWORD_AGAIN",
+        "FIELD_HEADER_USERNAME"                      : "Benutzername:",
+        
+        "FIELD_PLACEHOLDER_FILTER" : "@:APP.FIELD_PLACEHOLDER_FILTER",
+
+        "INFO_READ_ONLY" : "Dieses Benutzerkonto kann nicht bearbeitet werden.",
+        
+        "SECTION_HEADER_CONNECTIONS" : "Verbindungen",
+        "SECTION_HEADER_EDIT_USER"   : "Ändere Benutzer",
+        "SECTION_HEADER_PERMISSIONS" : "Berechtigungen",
+
+        "TEXT_CONFIRM_DELETE" : "Dieser Löschvorgang ist unumkehrbar. Soll dieser Benutzer wirklich gelöscht werden?"
+
+    },
+    
+    "PROTOCOL_RDP" : {
+
+        "FIELD_HEADER_CLIENT_NAME"     : "Client-Name:",
+        "FIELD_HEADER_COLOR_DEPTH"     : "Farbtiefe:",
+        "FIELD_HEADER_CONSOLE"         : "Mit Konsole verbinden (Windows 2003 / 2003 R2):",
+        "FIELD_HEADER_CONSOLE_AUDIO"   : "Audiounterstützung Konsole:",
+        "FIELD_HEADER_CREATE_DRIVE_PATH" : "Erstellen Sie automatisch Laufwerk:",
+        "FIELD_HEADER_DISABLE_AUDIO"   : "Deaktivere Audio:",
+        "FIELD_HEADER_DISABLE_AUTH"    : "Deaktivere Authentifizierung:",
+        "FIELD_HEADER_DOMAIN"          : "Domäne:",
+        "FIELD_HEADER_DPI"             : "Auflösung (DPI):",
+        "FIELD_HEADER_DRIVE_PATH"      : "Laufwerkspfad:",
+        "FIELD_HEADER_ENABLE_DESKTOP_COMPOSITION" : "Aktiviere Desktop Gestaltung (Aero):",
+        "FIELD_HEADER_ENABLE_DRIVE"               : "Aktiviere Laufwerk:",
+        "FIELD_HEADER_ENABLE_FONT_SMOOTHING"      : "Aktiviere Schriftartglättung (ClearType):",
+        "FIELD_HEADER_ENABLE_FULL_WINDOW_DRAG"    : "Aktiviere Fensterziehen:",
+        "FIELD_HEADER_ENABLE_MENU_ANIMATIONS"     : "Aktiviere Menüanimationen:",
+        "FIELD_HEADER_ENABLE_PRINTING"            : "Aktiviere Drucken:",
+        "FIELD_HEADER_ENABLE_SFTP"                : "Aktiviere SFTP:",
+        "FIELD_HEADER_ENABLE_THEMING"             : "Aktiviere Theming:",
+        "FIELD_HEADER_ENABLE_WALLPAPER"           : "Aktiviere Desktophintergrund:",
+        "FIELD_HEADER_HEIGHT"          : "Höhe:",
+        "FIELD_HEADER_HOSTNAME"        : "Hostname:",
+        "FIELD_HEADER_IGNORE_CERT"     : "Server Zertifikat ignorieren:",
+        "FIELD_HEADER_INITIAL_PROGRAM" : "Startprogramm:",
+        "FIELD_HEADER_PASSWORD"        : "Passwort:",
+        "FIELD_HEADER_PORT"            : "Port:",
+        "FIELD_HEADER_REMOTE_APP_ARGS" : "Parameter:",
+        "FIELD_HEADER_REMOTE_APP_DIR"  : "Arbeitsverzeichnis:",
+        "FIELD_HEADER_REMOTE_APP"      : "Programm:",
+        "FIELD_HEADER_SECURITY"        : "Sicherheitsverfahren:",
+        "FIELD_HEADER_SERVER_LAYOUT"   : "Tastatur Layout:",
+        "FIELD_HEADER_SFTP_DIRECTORY"  : "Standard-Upload-Verzeichnis:",
+        "FIELD_HEADER_SFTP_HOSTNAME"    : "Hostname:",
+        "FIELD_HEADER_SFTP_PASSPHRASE"  : "Passphrase:",
+        "FIELD_HEADER_SFTP_PASSWORD"    : "Passwort:",
+        "FIELD_HEADER_SFTP_PORT"        : "Port:",
+        "FIELD_HEADER_SFTP_PRIVATE_KEY" : "Privater Schlüssel:",
+        "FIELD_HEADER_SFTP_USERNAME"    : "Benutzername:",
+        "FIELD_HEADER_STATIC_CHANNELS" : "Statischer Kanalname:",
+        "FIELD_HEADER_USERNAME"        : "Benutzername:",
+        "FIELD_HEADER_WIDTH"           : "Breite:",
+
+        "FIELD_OPTION_COLOR_DEPTH_16"    : "Hohe Farbtiefe (16-bit)",
+        "FIELD_OPTION_COLOR_DEPTH_24"    : "Echtfarben (24-bit)",
+        "FIELD_OPTION_COLOR_DEPTH_32"    : "Echtfarben (32-bit)",
+        "FIELD_OPTION_COLOR_DEPTH_8"     : "256 Farben",
+        "FIELD_OPTION_COLOR_DEPTH_EMPTY" : "",
+
+        "FIELD_OPTION_SECURITY_ANY"   : "Jede",
+        "FIELD_OPTION_SECURITY_EMPTY" : "",
+        "FIELD_OPTION_SECURITY_NLA"   : "NLA (Netzwerkebene Authentifizierung)",
+        "FIELD_OPTION_SECURITY_RDP"   : "RDP Verschlüsselung",
+        "FIELD_OPTION_SECURITY_TLS"   : "TLS Verschlüsselung",
+
+        "FIELD_OPTION_SERVER_LAYOUT_DE_DE_QWERTZ" : "Deutsch (Qwertz)",
+        "FIELD_OPTION_SERVER_LAYOUT_EMPTY"        : "",
+        "FIELD_OPTION_SERVER_LAYOUT_EN_US_QWERTY" : "US Englisch (Qwerty)",
+        "FIELD_OPTION_SERVER_LAYOUT_FAILSAFE"     : "Unicode",
+        "FIELD_OPTION_SERVER_LAYOUT_FR_FR_AZERTY" : "Französisch (Azerty)",
+        "FIELD_OPTION_SERVER_LAYOUT_IT_IT_QWERTY" : "Italienisch (Qwerty)",
+        "FIELD_OPTION_SERVER_LAYOUT_SV_SE_QWERTY" : "Schwedisch (Qwerty)",
+
+        "NAME" : "RDP",
+
+        "SECTION_HEADER_AUTHENTICATION"     : "Authentifizierung",
+        "SECTION_HEADER_BASIC_PARAMETERS"   : "Basiseinstellungen",
+        "SECTION_HEADER_DEVICE_REDIRECTION" : "Geräteumleitung",
+        "SECTION_HEADER_DISPLAY"            : "Bildschirm",
+        "SECTION_HEADER_NETWORK"            : "Netzwerk",
+        "SECTION_HEADER_PERFORMANCE"        : "Geschwindigkeit",
+        "SECTION_HEADER_REMOTEAPP"          : "Entferntes Programm",
+        "SECTION_HEADER_SFTP"               : "SFTP"
+
+    },
+
+    "PROTOCOL_SSH" : {
+
+        "FIELD_HEADER_COLOR_SCHEME" : "Farbschema:",
+        "FIELD_HEADER_COMMAND"     : "Befehl ausführen:",
+        "FIELD_HEADER_FONT_NAME"   : "Schriftart Name:",
+        "FIELD_HEADER_FONT_SIZE"   : "Schriftart Größe:",
+        "FIELD_HEADER_ENABLE_SFTP" : "Aktiviere SFTP:",
+        "FIELD_HEADER_HOSTNAME"    : "Hostname:",
+        "FIELD_HEADER_USERNAME"    : "Benutzername:",
+        "FIELD_HEADER_PASSWORD"    : "Passwort:",
+        "FIELD_HEADER_PASSPHRASE"  : "Passphrase:",
+        "FIELD_HEADER_PORT"        : "Port:",
+        "FIELD_HEADER_PRIVATE_KEY" : "Privater Schlüssel:",
+
+        "FIELD_OPTION_COLOR_SCHEME_BLACK_WHITE" : "Schwarz auf Weiß",
+        "FIELD_OPTION_COLOR_SCHEME_EMPTY"       : "",
+        "FIELD_OPTION_COLOR_SCHEME_GRAY_BLACK"  : "Grau auf Schwarz",
+        "FIELD_OPTION_COLOR_SCHEME_GREEN_BLACK" : "Grün auf Schwarz",
+        "FIELD_OPTION_COLOR_SCHEME_WHITE_BLACK" : "Weiß auf Schwarz",
+
+        "FIELD_OPTION_FONT_SIZE_8"     : "8",
+        "FIELD_OPTION_FONT_SIZE_9"     : "9",
+        "FIELD_OPTION_FONT_SIZE_10"    : "10",
+        "FIELD_OPTION_FONT_SIZE_11"    : "11",
+        "FIELD_OPTION_FONT_SIZE_12"    : "12",
+        "FIELD_OPTION_FONT_SIZE_14"    : "14",
+        "FIELD_OPTION_FONT_SIZE_18"    : "18",
+        "FIELD_OPTION_FONT_SIZE_24"    : "24",
+        "FIELD_OPTION_FONT_SIZE_30"    : "30",
+        "FIELD_OPTION_FONT_SIZE_36"    : "36",
+        "FIELD_OPTION_FONT_SIZE_48"    : "48",
+        "FIELD_OPTION_FONT_SIZE_60"    : "60",
+        "FIELD_OPTION_FONT_SIZE_72"    : "72",
+        "FIELD_OPTION_FONT_SIZE_96"    : "96",
+        "FIELD_OPTION_FONT_SIZE_EMPTY" : "",
+
+        "NAME" : "SSH",
+
+        "SECTION_HEADER_AUTHENTICATION" : "Authentifizierung",
+        "SECTION_HEADER_DISPLAY"        : "Bildschirm",
+        "SECTION_HEADER_NETWORK"        : "Netzwerk",
+        "SECTION_HEADER_SESSION"        : "Sitzung / Umgebung",
+        "SECTION_HEADER_SFTP"           : "SFTP"
+
+    },
+
+    "PROTOCOL_TELNET" : {
+
+        "FIELD_HEADER_COLOR_SCHEME"   : "Farbschema:",
+        "FIELD_HEADER_FONT_NAME"      : "Schriftart Name:",
+        "FIELD_HEADER_FONT_SIZE"      : "Schriftart Größe:",
+        "FIELD_HEADER_HOSTNAME"       : "Hostname:",
+        "FIELD_HEADER_USERNAME"       : "Benutzername:",
+        "FIELD_HEADER_PASSWORD"       : "Passwort:",
+        "FIELD_HEADER_PASSWORD_REGEX" : "Reguläre Passwortersetzungen:",
+        "FIELD_HEADER_PORT"           : "Port:",
+
+        "FIELD_OPTION_COLOR_SCHEME_BLACK_WHITE" : "Schwarz auf Weiß",
+        "FIELD_OPTION_COLOR_SCHEME_EMPTY"       : "",
+        "FIELD_OPTION_COLOR_SCHEME_GRAY_BLACK"  : "Grau auf Schwarz",
+        "FIELD_OPTION_COLOR_SCHEME_GREEN_BLACK" : "Grün auf Schwarz",
+        "FIELD_OPTION_COLOR_SCHEME_WHITE_BLACK" : "Weiß auf Schwarz",
+
+        "FIELD_OPTION_FONT_SIZE_8"     : "8",
+        "FIELD_OPTION_FONT_SIZE_9"     : "9",
+        "FIELD_OPTION_FONT_SIZE_10"    : "10",
+        "FIELD_OPTION_FONT_SIZE_11"    : "11",
+        "FIELD_OPTION_FONT_SIZE_12"    : "12",
+        "FIELD_OPTION_FONT_SIZE_14"    : "14",
+        "FIELD_OPTION_FONT_SIZE_18"    : "18",
+        "FIELD_OPTION_FONT_SIZE_24"    : "24",
+        "FIELD_OPTION_FONT_SIZE_30"    : "30",
+        "FIELD_OPTION_FONT_SIZE_36"    : "36",
+        "FIELD_OPTION_FONT_SIZE_48"    : "48",
+        "FIELD_OPTION_FONT_SIZE_60"    : "60",
+        "FIELD_OPTION_FONT_SIZE_72"    : "72",
+        "FIELD_OPTION_FONT_SIZE_96"    : "96",
+        "FIELD_OPTION_FONT_SIZE_EMPTY" : "",
+
+        "NAME" : "Telnet",
+
+        "SECTION_HEADER_AUTHENTICATION" : "Authentifizierung",
+        "SECTION_HEADER_DISPLAY"        : "Bildschirm",
+        "SECTION_HEADER_NETWORK"        : "Netzwerk"
+
+    },
+
+    "PROTOCOL_VNC" : {
+
+        "FIELD_HEADER_AUDIO_SERVERNAME" : "Audioservername:",
+        "FIELD_HEADER_CLIPBOARD_ENCODING" : "Codierung:",
+        "FIELD_HEADER_COLOR_DEPTH"      : "Farbtiefe:",
+        "FIELD_HEADER_CURSOR"           : "Cursor:",
+        "FIELD_HEADER_DEST_HOST"        : "Ziel Host:",
+        "FIELD_HEADER_DEST_PORT"        : "Ziel Port:",
+        "FIELD_HEADER_ENABLE_AUDIO"     : "Aktiviere Audio:",
+        "FIELD_HEADER_ENABLE_SFTP"      : "Aktiviere SFTP:",
+        "FIELD_HEADER_HOSTNAME"         : "Hostname:",
+        "FIELD_HEADER_PASSWORD"         : "Passwort:",
+        "FIELD_HEADER_PORT"             : "Port:",
+        "FIELD_HEADER_READ_ONLY"        : "Nur-Lesen:",
+        "FIELD_HEADER_SFTP_DIRECTORY"   : "Standard-Upload-Verzeichnis:",
+        "FIELD_HEADER_SFTP_HOSTNAME"    : "Hostname:",
+        "FIELD_HEADER_SFTP_PASSPHRASE"  : "Passphrase:",
+        "FIELD_HEADER_SFTP_PASSWORD"    : "Passwort:",
+        "FIELD_HEADER_SFTP_PORT"        : "Port:",
+        "FIELD_HEADER_SFTP_PRIVATE_KEY" : "Privater Schlüssel:",
+        "FIELD_HEADER_SFTP_USERNAME"    : "Benutzername:",
+        "FIELD_HEADER_SWAP_RED_BLUE"    : "Vertausche rot/blau Komponenten:",
+
+        "FIELD_OPTION_COLOR_DEPTH_8"     : "256 Farben",
+        "FIELD_OPTION_COLOR_DEPTH_16"    : "Hohe Farbtiefe (16-bit)",
+        "FIELD_OPTION_COLOR_DEPTH_24"    : "Echfarben (24-bit)",
+        "FIELD_OPTION_COLOR_DEPTH_32"    : "Echfarben (32-bit)",
+        "FIELD_OPTION_COLOR_DEPTH_EMPTY" : "",
+
+        "FIELD_OPTION_CURSOR_EMPTY"  : "",
+        "FIELD_OPTION_CURSOR_LOCAL"  : "Lokal",
+        "FIELD_OPTION_CURSOR_REMOTE" : "Entfernt",
+
+        "FIELD_OPTION_CLIPBOARD_ENCODING_CP1252"    : "CP1252",
+        "FIELD_OPTION_CLIPBOARD_ENCODING_EMPTY"     : "",
+        "FIELD_OPTION_CLIPBOARD_ENCODING_ISO8859_1" : "ISO 8859-1",
+        "FIELD_OPTION_CLIPBOARD_ENCODING_UTF_8"     : "UTF-8",
+        "FIELD_OPTION_CLIPBOARD_ENCODING_UTF_16"    : "UTF-16",
+
+        "NAME" : "VNC",
+
+        "SECTION_HEADER_AUDIO"          : "Audio",
+        "SECTION_HEADER_AUTHENTICATION" : "Authentifizierung",
+        "SECTION_HEADER_CLIPBOARD"      : "Zwischenablage",
+        "SECTION_HEADER_DISPLAY"        : "Bildschirm",
+        "SECTION_HEADER_NETWORK"        : "Netzwerk",
+        "SECTION_HEADER_REPEATER"       : "VNC Repeater",
+        "SECTION_HEADER_SFTP"           : "SFTP"
+
+    },
+
+    "SETTINGS" : {
+
+        "SECTION_HEADER_SETTINGS" : "Einstellungen"
+
+    },
+
+    "SETTINGS_CONNECTIONS" : {
+
+        "ACTION_ACKNOWLEDGE"          : "@:APP.ACTION_ACKNOWLEDGE",
+        "ACTION_NEW_CONNECTION"       : "Neue Verbindung",
+        "ACTION_NEW_CONNECTION_GROUP" : "Neue Verbindungsgruppe",
+
+        "DIALOG_HEADER_ERROR" : "@:APP.DIALOG_HEADER_ERROR",
+
+        "FIELD_PLACEHOLDER_FILTER" : "@:APP.FIELD_PLACEHOLDER_FILTER",
+
+        "HELP_CONNECTIONS"   : "Klicke oder Tippe auf eine Verbindung um diese zu verwalten. Abhänig von Ihrer Zugriffsebene können Verbindungen hinzugefügt, gelöscht oder Parameter (Protokol, Hostname, Port, etc.) geändert werden.",
+        
+        "INFO_ACTIVE_USER_COUNT" : "@:APP.INFO_ACTIVE_USER_COUNT",
+
+        "SECTION_HEADER_CONNECTIONS"     : "Verbindungen"
+
+    },
+
+    "SETTINGS_CONNECTION_HISTORY" : {
+
+        "ACTION_SEARCH" : "@:APP.ACTION_SEARCH",
+
+        "FIELD_PLACEHOLDER_FILTER" : "@:APP.FIELD_PLACEHOLDER_FILTER",
+
+        "FORMAT_DATE" : "@:APP.FORMAT_DATE_TIME_PRECISE",
+
+        "HELP_CONNECTION_HISTORY" : "Die letzten Verbindungen werden hier historisch aufgelistet und können durch Klicken auf die Spaltenüberschriften sortiert werden. Zum Aufsuchen von bestimmten Datensätzen, geben Sie eine Filterzeichenfolge ein und klicken Sie auf \"Suchen\". Nur Datensätze, die die vorgesehenen Filterzeichenfolge entsprechen, werden aufgelistet.",
+
+        "INFO_CONNECTION_DURATION_UNKNOWN" : "--",
+        "INFO_NO_HISTORY"                  : "Keine passenden Datensätze",
+
+        "TABLE_HEADER_SESSION_CONNECTION_NAME" : "Verbindungsname",
+        "TABLE_HEADER_SESSION_DURATION"        : "Dauer",
+        "TABLE_HEADER_SESSION_STARTDATE"       : "Startzeit",
+        "TABLE_HEADER_SESSION_USERNAME"        : "Benutzername",
+
+        "TEXT_HISTORY_DURATION" : "@:APP.TEXT_HISTORY_DURATION"
+
+    },
+
+    "SETTINGS_PREFERENCES" : {
+
+        "ACTION_ACKNOWLEDGE"        : "@:APP.ACTION_ACKNOWLEDGE",
+        "ACTION_CANCEL"             : "@:APP.ACTION_CANCEL",
+        "ACTION_UPDATE_PASSWORD"    : "@:APP.ACTION_UPDATE_PASSWORD",
+
+        "DIALOG_HEADER_ERROR"    : "@:APP.DIALOG_HEADER_ERROR",
+
+        "ERROR_PASSWORD_BLANK"    : "@:APP.ERROR_PASSWORD_BLANK",
+        "ERROR_PASSWORD_MISMATCH" : "@:APP.ERROR_PASSWORD_MISMATCH",
+
+        "FIELD_HEADER_LANGUAGE"           : "Anzeigesprache:",
+        "FIELD_HEADER_PASSWORD"           : "Passwort:",
+        "FIELD_HEADER_PASSWORD_OLD"       : "Aktuelles Passwort:",
+        "FIELD_HEADER_PASSWORD_NEW"       : "Neues Passwort:",
+        "FIELD_HEADER_PASSWORD_NEW_AGAIN" : "Passwortbestättigung:",
+        "FIELD_HEADER_USERNAME"           : "Benutzername:",
+        
+        "HELP_DEFAULT_INPUT_METHOD" : "Die Standardeingabemethode bestimmt wie Tastaturereignisse an Guacamole weitergeleitet werden. Eine Änderung dieser Einstellung kann erforderlich sein, wenn ein mobiles Gerät verwendet wird oder bei der Eingabe durch einen IME. Dieses Verhalten kann im Menü innerhalb der Guacamole Verbindung geändert werden.",
+        "HELP_DEFAULT_MOUSE_MODE"   : "Der Standard Mausemulationsmodus bestimmt wie sich die entfernte Maus bei Touchpad Berührungen verhält. Dieses Verhalten kann im Menü innerhalb der Guacamole Verbindung geändert werden.",
+        "HELP_INPUT_METHOD_NONE"    : "@:CLIENT.HELP_INPUT_METHOD_NONE",
+        "HELP_INPUT_METHOD_OSK"     : "@:CLIENT.HELP_INPUT_METHOD_OSK",
+        "HELP_INPUT_METHOD_TEXT"    : "@:CLIENT.HELP_INPUT_METHOD_TEXT",
+        "HELP_LANGUAGE"             : "Um die Spracheinstellungen von den Guacamole zu ändern,  wählen Sie eine der verfügbaren Sprachen.",
+        "HELP_MOUSE_MODE_ABSOLUTE"  : "@:CLIENT.HELP_MOUSE_MODE_ABSOLUTE",
+        "HELP_MOUSE_MODE_RELATIVE"  : "@:CLIENT.HELP_MOUSE_MODE_RELATIVE",
+        "HELP_UPDATE_PASSWORD"      : "Wenn Sie das Passwort ändern wollen, geben Sie das aktuelle und das gewünschte Passwort ein und klicken Sie auf \"Ändere Passwort\". Die Änderung wird sofort wirksam.",
+
+        "INFO_PASSWORD_CHANGED" : "Passwort geändert.",
+
+        "NAME_INPUT_METHOD_NONE" : "@:CLIENT.NAME_INPUT_METHOD_NONE",
+        "NAME_INPUT_METHOD_OSK"  : "@:CLIENT.NAME_INPUT_METHOD_OSK",
+        "NAME_INPUT_METHOD_TEXT" : "@:CLIENT.NAME_INPUT_METHOD_TEXT",
+
+        "SECTION_HEADER_DEFAULT_INPUT_METHOD" : "Standard Eingabemethode",
+        "SECTION_HEADER_DEFAULT_MOUSE_MODE"   : "Standard Mausemulationsmodus",
+        "SECTION_HEADER_UPDATE_PASSWORD"      : "Ändere Passwort"
+
+    },
+
+    "SETTINGS_USERS" : {
+
+        "ACTION_ACKNOWLEDGE"   : "@:APP.ACTION_ACKNOWLEDGE",
+        "ACTION_NEW_USER"      : "Neuer Benutzer",
+
+        "DIALOG_HEADER_ERROR" : "@:APP.DIALOG_HEADER_ERROR",
+
+        "FIELD_PLACEHOLDER_FILTER" : "@:APP.FIELD_PLACEHOLDER_FILTER",
+
+        "HELP_USERS" : "Klicke oder Tippe auf einen Benutzer um diesen zu verwalten. Abhänig von Ihrer Zugriffsebene können Benutzer hinzugefügt, gelöscht bzw. dessen Passwort geändert werden.",
+
+        "SECTION_HEADER_USERS"       : "Benutzer"
+
+    },
+    
+    "SETTINGS_SESSIONS" : {
+        
+        "ACTION_ACKNOWLEDGE" : "@:APP.ACTION_ACKNOWLEDGE",
+        "ACTION_CANCEL"      : "@:APP.ACTION_CANCEL",
+        "ACTION_DELETE"      : "Beende Sitzung",
+        
+        "DIALOG_HEADER_CONFIRM_DELETE" : "Beende Sitzung",
+        "DIALOG_HEADER_ERROR"          : "@:APP.DIALOG_HEADER_ERROR",
+        
+        "FIELD_PLACEHOLDER_FILTER" : "@:APP.FIELD_PLACEHOLDER_FILTER",
+        
+        "FORMAT_STARTDATE" : "@:APP.FORMAT_DATE_TIME_PRECISE",
+
+        "HELP_SESSIONS" : "Alle aktiven Guacamole Sitzungen werden hier aufgelistet. Wenn Sie eine oder mehrere Sitzungen beenden wollen, wählen Sie diese Sitzung durch Aktivierung der nebenstehende Box und klicken auf \"Beende Sitzung\". Beendung einer Sitzung trennt den Benutzer von dessen Verbindung unverzüglich.",
+        
+        "INFO_NO_SESSIONS" : "Keine aktiven Sitzungen",
+
+        "SECTION_HEADER_SESSIONS" : "Aktive Sitzungen",
+        
+        "TABLE_HEADER_SESSION_USERNAME"        : "Benutzername",
+        "TABLE_HEADER_SESSION_STARTDATE"       : "Aktive seit",
+        "TABLE_HEADER_SESSION_REMOTEHOST"      : "Entfernter Host",
+        "TABLE_HEADER_SESSION_CONNECTION_NAME" : "Verbindungsname",
+        
+        "TEXT_CONFIRM_DELETE" : "Sind Sie sicher, dass alle ausgwählten Sitzungen beendet werden sollen? Die Benutzer dieser Sitzungen werden unverzüglich getrennt."
+
+    },
+
+    "USER_MENU" : {
+
+        "ACTION_LOGOUT"             : "@:APP.ACTION_LOGOUT",
+        "ACTION_MANAGE_CONNECTIONS" : "@:APP.ACTION_MANAGE_CONNECTIONS",
+        "ACTION_MANAGE_PREFERENCES" : "@:APP.ACTION_MANAGE_PREFERENCES",
+        "ACTION_MANAGE_SESSIONS"    : "@:APP.ACTION_MANAGE_SESSIONS",
+        "ACTION_MANAGE_SETTINGS"    : "@:APP.ACTION_MANAGE_SETTINGS",
+        "ACTION_MANAGE_USERS"       : "@:APP.ACTION_MANAGE_USERS",
+        "ACTION_NAVIGATE_HOME"      : "@:APP.ACTION_NAVIGATE_HOME"
+
+    }
+
+}
diff --git a/guacamole/src/main/webapp/translations/en.json b/guacamole/src/main/webapp/translations/en.json
new file mode 100644
index 0000000..951b38b
--- /dev/null
+++ b/guacamole/src/main/webapp/translations/en.json
@@ -0,0 +1,628 @@
+{
+    
+    "NAME" : "English",
+    
+    "APP" : {
+
+        "ACTION_ACKNOWLEDGE"        : "OK",
+        "ACTION_CANCEL"             : "Cancel",
+        "ACTION_CLONE"              : "Clone",
+        "ACTION_CONTINUE"           : "Continue",
+        "ACTION_DELETE"             : "Delete",
+        "ACTION_DELETE_SESSIONS"    : "Kill Sessions",
+        "ACTION_LOGIN"              : "Login",
+        "ACTION_LOGOUT"             : "Logout",
+        "ACTION_MANAGE_CONNECTIONS" : "Connections",
+        "ACTION_MANAGE_PREFERENCES" : "Preferences",
+        "ACTION_MANAGE_SETTINGS"    : "Settings",
+        "ACTION_MANAGE_SESSIONS"    : "Active Sessions",
+        "ACTION_MANAGE_USERS"       : "Users",
+        "ACTION_NAVIGATE_BACK"      : "Back",
+        "ACTION_NAVIGATE_HOME"      : "Home",
+        "ACTION_SAVE"               : "Save",
+        "ACTION_SEARCH"             : "Search",
+        "ACTION_UPDATE_PASSWORD"    : "Update Password",
+        "ACTION_VIEW_HISTORY"       : "History",
+
+        "DIALOG_HEADER_ERROR" : "Error",
+
+        "ERROR_PASSWORD_BLANK"    : "Your password cannot be blank.",
+        "ERROR_PASSWORD_MISMATCH" : "The provided passwords do not match.",
+        
+        "FIELD_HEADER_PASSWORD"       : "Password:",
+        "FIELD_HEADER_PASSWORD_AGAIN" : "Re-enter Password:",
+
+        "FIELD_PLACEHOLDER_FILTER" : "Filter",
+
+        "FORMAT_DATE_TIME_PRECISE" : "yyyy-MM-dd HH:mm:ss",
+
+        "INFO_ACTIVE_USER_COUNT" : "Currently in use by {USERS} {USERS, plural, one{user} other{users}}.",
+
+        "NAME" : "Guacamole ${project.version}",
+
+        "TEXT_HISTORY_DURATION" : "{VALUE} {UNIT, select, second{{VALUE, plural, one{second} other{seconds}}} minute{{VALUE, plural, one{minute} other{minutes}}} hour{{VALUE, plural, one{hour} other{hours}}} day{{VALUE, plural, one{day} other{days}}} other{}}"
+
+    },
+
+    "CLIENT" : {
+
+        "ACTION_ACKNOWLEDGE"               : "@:APP.ACTION_ACKNOWLEDGE",
+        "ACTION_CLEAR_COMPLETED_TRANSFERS" : "Clear",
+        "ACTION_DISCONNECT"                : "Disconnect",
+        "ACTION_LOGOUT"                    : "@:APP.ACTION_LOGOUT",
+        "ACTION_NAVIGATE_BACK"             : "@:APP.ACTION_NAVIGATE_BACK",
+        "ACTION_NAVIGATE_HOME"             : "@:APP.ACTION_NAVIGATE_HOME",
+        "ACTION_RECONNECT"                 : "Reconnect",
+        "ACTION_SAVE_FILE"                 : "@:APP.ACTION_SAVE",
+        "ACTION_UPLOAD_FILES"              : "Upload Files",
+
+        "DIALOG_HEADER_CONNECTING"       : "Connecting",
+        "DIALOG_HEADER_CONNECTION_ERROR" : "Connection Error",
+        "DIALOG_HEADER_DISCONNECTED"     : "Disconnected",
+
+        "ERROR_CLIENT_201"     : "This connection has been closed because the server is busy. Please wait a few minutes and try again.",
+        "ERROR_CLIENT_202"     : "The Guacamole server has closed the connection because the remote desktop is taking too long to respond. Please try again or contact your system administrator.",
+        "ERROR_CLIENT_203"     : "The remote desktop server encountered an error and has closed the connection. Please try again or contact your system administrator.",
+        "ERROR_CLIENT_205"     : "This connection has been closed because it conflicts with another connection. Please try again later.",
+        "ERROR_CLIENT_301"     : "Log in failed. Please reconnect and try again.",
+        "ERROR_CLIENT_303"     : "You do not have permission to access this connection. If you require access, please ask your system administrator to add you the list of allowed users, or check your system settings.",
+        "ERROR_CLIENT_308"     : "The Guacamole server has closed the connection because there has been no response from your browser for long enough that it appeared to be disconnected. This is commonly caused by network problems, such as spotty wireless signal, or simply very slow network speeds. Please check your network and try again.",
+        "ERROR_CLIENT_31D"     : "The Guacamole server is denying access to this connection because you have exhausted the limit for simultaneous connection use by an individual user. Please close one or more connections and try again.",
+        "ERROR_CLIENT_DEFAULT" : "An internal error has occurred within the Guacamole server, and the connection has been terminated. If the problem persists, please notify your system administrator, or check your system logs.",
+
+        "ERROR_TUNNEL_201"     : "The Guacamole server has rejected this connection attempt because there are too many active connections. Please wait a few minutes and try again.",
+        "ERROR_TUNNEL_202"     : "The connection has been closed because the server is taking too long to respond. This is usually caused by network problems, such as a spotty wireless signal, or slow network speeds. Please check your network connection and try again or contact your system administrator.",
+        "ERROR_TUNNEL_203"     : "The server encountered an error and has closed the connection. Please try again or contact your system administrator.",
+        "ERROR_TUNNEL_204"     : "The requested connection does not exist. Please check the connection name and try again.",
+        "ERROR_TUNNEL_205"     : "This connection is currently in use, and concurrent access to this connection is not allowed. Please try again later.",
+        "ERROR_TUNNEL_301"     : "You do not have permission to access this connection because you are not logged in. Please log in and try again.",
+        "ERROR_TUNNEL_303"     : "You do not have permission to access this connection. If you require access, please ask your system administrator to add you the list of allowed users, or check your system settings.",
+        "ERROR_TUNNEL_308"     : "The Guacamole server has closed the connection because there has been no response from your browser for long enough that it appeared to be disconnected. This is commonly caused by network problems, such as spotty wireless signal, or simply very slow network speeds. Please check your network and try again.",
+        "ERROR_TUNNEL_31D"     : "The Guacamole server is denying access to this connection because you have exhausted the limit for simultaneous connection use by an individual user. Please close one or more connections and try again.",
+        "ERROR_TUNNEL_DEFAULT" : "An internal error has occurred within the Guacamole server, and the connection has been terminated. If the problem persists, please notify your system administrator, or check your system logs.",
+
+        "ERROR_UPLOAD_100"     : "File transfer is either not supported or not enabled. Please contact your system administrator, or check your system logs.",
+        "ERROR_UPLOAD_201"     : "Too many files are currently being transferred. Please wait for existing transfers to complete, and then try again.",
+        "ERROR_UPLOAD_202"     : "The file cannot be transferred because the remote desktop server is taking too long to respond. Please try again or or contact your system administrator.",
+        "ERROR_UPLOAD_203"     : "The remote desktop server encountered an error during transfer. Please try again or contact your system administrator.",
+        "ERROR_UPLOAD_204"     : "The destination for the file transfer does not exist. Please check that the destination exists and try again.",
+        "ERROR_UPLOAD_205"     : "The destination for the file transfer is currently locked. Please wait for any in-progress tasks to complete and try again.",
+        "ERROR_UPLOAD_301"     : "You do not have permission to upload this file because you are not logged in. Please log in and try again.",
+        "ERROR_UPLOAD_303"     : "You do not have permission to upload this file. If you require access, please check your system settings, or check with your system administrator.",
+        "ERROR_UPLOAD_308"     : "The file transfer has stalled. This is commonly caused by network problems, such as spotty wireless signal, or simply very slow network speeds. Please check your network and try again.",
+        "ERROR_UPLOAD_31D"     : "Too many files are currently being transferred. Please wait for existing transfers to complete, and then try again.",
+        "ERROR_UPLOAD_DEFAULT" : "An internal error has occurred within the Guacamole server, and the connection has been terminated. If the problem persists, please notify your system administrator, or check your system logs.",
+
+        "HELP_CLIPBOARD"           : "Text copied/cut within Guacamole will appear here. Changes to the text below will affect the remote clipboard.",
+        "HELP_INPUT_METHOD_NONE"   : "No input method is used. Keyboard input is accepted from a connected, physical keyboard.",
+        "HELP_INPUT_METHOD_OSK"    : "Display and accept input from the built-in Guacamole on-screen keyboard. The on-screen keyboard allows typing of key combinations that may otherwise be impossible (such as Ctrl-Alt-Del).",
+        "HELP_INPUT_METHOD_TEXT"   : "Allow typing of text, and emulate keyboard events based on the typed text. This is necessary for devices such as mobile phones that lack a physical keyboard.",
+        "HELP_MOUSE_MODE"          : "Determines how the remote mouse behaves with respect to touches.",
+        "HELP_MOUSE_MODE_ABSOLUTE" : "Tap to click. The click occurs at the location of the touch.",
+        "HELP_MOUSE_MODE_RELATIVE" : "Drag to move the mouse pointer and tap to click. The click occurs at the location of the pointer.",
+
+        "INFO_NO_FILE_TRANSFERS" : "No file transfers.",
+
+        "NAME_INPUT_METHOD_NONE"   : "None",
+        "NAME_INPUT_METHOD_OSK"    : "On-screen keyboard",
+        "NAME_INPUT_METHOD_TEXT"   : "Text input",
+        "NAME_KEY_CTRL"            : "Ctrl",
+        "NAME_KEY_ALT"             : "Alt",
+        "NAME_KEY_ESC"             : "Esc",
+        "NAME_KEY_TAB"             : "Tab",
+        "NAME_MOUSE_MODE_ABSOLUTE" : "Touchscreen",
+        "NAME_MOUSE_MODE_RELATIVE" : "Touchpad",
+
+        "SECTION_HEADER_CLIPBOARD"      : "Clipboard",
+        "SECTION_HEADER_DEVICES"        : "Devices",
+        "SECTION_HEADER_DISPLAY"        : "Display",
+        "SECTION_HEADER_FILE_TRANSFERS" : "File Transfers",
+        "SECTION_HEADER_INPUT_METHOD"   : "Input method",
+        "SECTION_HEADER_MOUSE_MODE"     : "Mouse emulation mode",
+
+        "TEXT_ZOOM_AUTO_FIT"              : "Automatically fit to browser window",
+        "TEXT_CLIENT_STATUS_IDLE"         : "Idle.",
+        "TEXT_CLIENT_STATUS_CONNECTING"   : "Connecting to Guacamole...",
+        "TEXT_CLIENT_STATUS_DISCONNECTED" : "You have been disconnected.",
+        "TEXT_CLIENT_STATUS_WAITING"      : "Connected to Guacamole. Waiting for response...",
+        "TEXT_RECONNECT_COUNTDOWN"        : "Reconnecting in {REMAINING} {REMAINING, plural, one{second} other{seconds}}...",
+        "TEXT_FILE_TRANSFER_PROGRESS"     : "{PROGRESS} {UNIT, select, b{B} kb{KB} mb{MB} gb{GB} other{}}",
+
+        "URL_OSK_LAYOUT" : "layouts/en-us-qwerty.json"
+
+    },
+
+    "DATA_SOURCE_DEFAULT" : {
+        "NAME" : "Default (XML)"
+    },
+
+    "FORM" : {
+
+        "FIELD_PLACEHOLDER_DATE" : "YYYY-MM-DD",
+        "FIELD_PLACEHOLDER_TIME" : "HH:MM:SS",
+
+        "HELP_SHOW_PASSWORD" : "Click to show password",
+        "HELP_HIDE_PASSWORD" : "Click to hide password"
+
+    },
+
+    "HOME" : {
+
+        "FIELD_PLACEHOLDER_FILTER" : "@:APP.FIELD_PLACEHOLDER_FILTER",
+
+        "INFO_ACTIVE_USER_COUNT" : "@:APP.INFO_ACTIVE_USER_COUNT",
+
+        "INFO_NO_RECENT_CONNECTIONS" : "No recent connections.",
+        
+        "PASSWORD_CHANGED" : "Password changed.",
+
+        "SECTION_HEADER_ALL_CONNECTIONS"    : "All Connections",
+        "SECTION_HEADER_RECENT_CONNECTIONS" : "Recent Connections"
+
+    },
+
+    "LOGIN": {
+
+        "ACTION_ACKNOWLEDGE" : "@:APP.ACTION_ACKNOWLEDGE",
+        "ACTION_CONTINUE"    : "@:APP.ACTION_CONTINUE",
+        "ACTION_LOGIN"       : "@:APP.ACTION_LOGIN",
+
+        "DIALOG_HEADER_ERROR" : "@:APP.DIALOG_HEADER_ERROR",
+
+        "ERROR_INVALID_LOGIN" : "Invalid Login",
+
+        "FIELD_HEADER_USERNAME" : "Username",
+        "FIELD_HEADER_PASSWORD" : "Password"
+
+    },
+
+    "MANAGE_CONNECTION" : {
+
+        "ACTION_ACKNOWLEDGE"          : "@:APP.ACTION_ACKNOWLEDGE",
+        "ACTION_CANCEL"               : "@:APP.ACTION_CANCEL",
+        "ACTION_CLONE"                : "@:APP.ACTION_CLONE",
+        "ACTION_DELETE"               : "@:APP.ACTION_DELETE",
+        "ACTION_SAVE"                 : "@:APP.ACTION_SAVE",
+
+        "DIALOG_HEADER_CONFIRM_DELETE" : "Delete Connection",
+        "DIALOG_HEADER_ERROR"          : "@:APP.DIALOG_HEADER_ERROR",
+
+        "FIELD_HEADER_LOCATION" : "Location:",
+        "FIELD_HEADER_NAME"     : "Name:",
+        "FIELD_HEADER_PROTOCOL" : "Protocol:",
+
+        "FORMAT_HISTORY_START" : "@:APP.FORMAT_DATE_TIME_PRECISE",
+
+        "INFO_CONNECTION_DURATION_UNKNOWN" : "--",
+        "INFO_CONNECTION_ACTIVE_NOW"       : "Active Now",
+        "INFO_CONNECTION_NOT_USED"         : "This connection has not yet been used.",
+
+        "SECTION_HEADER_EDIT_CONNECTION" : "Edit Connection",
+        "SECTION_HEADER_HISTORY"         : "Usage History",
+        "SECTION_HEADER_PARAMETERS"      : "Parameters",
+
+        "TABLE_HEADER_HISTORY_USERNAME" : "Username",
+        "TABLE_HEADER_HISTORY_START"    : "Start Time",
+        "TABLE_HEADER_HISTORY_DURATION" : "Duration",
+
+        "TEXT_CONFIRM_DELETE"   : "Connections cannot be restored after they have been deleted. Are you sure you want to delete this connection?",
+        "TEXT_HISTORY_DURATION" : "@:APP.TEXT_HISTORY_DURATION"
+
+    },
+
+    "MANAGE_CONNECTION_GROUP" : {
+
+        "ACTION_ACKNOWLEDGE"   : "@:APP.ACTION_ACKNOWLEDGE",
+        "ACTION_CANCEL"        : "@:APP.ACTION_CANCEL",
+        "ACTION_DELETE"        : "@:APP.ACTION_DELETE",
+        "ACTION_SAVE"          : "@:APP.ACTION_SAVE",
+
+        "DIALOG_HEADER_CONFIRM_DELETE" : "Delete Connection Group",
+        "DIALOG_HEADER_ERROR"          : "@:APP.DIALOG_HEADER_ERROR",
+
+        "FIELD_HEADER_LOCATION" : "Location:",
+        "FIELD_HEADER_NAME"     : "Name:",
+        "FIELD_HEADER_TYPE"     : "Type:",
+
+        "NAME_TYPE_BALANCING"       : "Balancing",
+        "NAME_TYPE_ORGANIZATIONAL"  : "Organizational",
+
+        "SECTION_HEADER_EDIT_CONNECTION_GROUP" : "Edit Connection Group",
+
+        "TEXT_CONFIRM_DELETE" : "Connection groups cannot be restored after they have been deleted. Are you sure you want to delete this connection group?"
+
+    },
+
+    "MANAGE_USER" : {
+
+        "ACTION_ACKNOWLEDGE"   : "@:APP.ACTION_ACKNOWLEDGE",
+        "ACTION_CANCEL"        : "@:APP.ACTION_CANCEL",
+        "ACTION_DELETE"        : "@:APP.ACTION_DELETE",
+        "ACTION_SAVE"          : "@:APP.ACTION_SAVE",
+
+        "DIALOG_HEADER_CONFIRM_DELETE" : "Delete User",
+        "DIALOG_HEADER_ERROR"          : "@:APP.DIALOG_HEADER_ERROR",
+
+        "ERROR_PASSWORD_MISMATCH" : "@:APP.ERROR_PASSWORD_MISMATCH",
+
+        "FIELD_HEADER_ADMINISTER_SYSTEM"             : "Administer system:",
+        "FIELD_HEADER_CHANGE_OWN_PASSWORD"           : "Change own password:",
+        "FIELD_HEADER_CREATE_NEW_USERS"              : "Create new users:",
+        "FIELD_HEADER_CREATE_NEW_CONNECTIONS"        : "Create new connections:",
+        "FIELD_HEADER_CREATE_NEW_CONNECTION_GROUPS"  : "Create new connection groups:",
+        "FIELD_HEADER_PASSWORD"                      : "@:APP.FIELD_HEADER_PASSWORD",
+        "FIELD_HEADER_PASSWORD_AGAIN"                : "@:APP.FIELD_HEADER_PASSWORD_AGAIN",
+        "FIELD_HEADER_USERNAME"                      : "Username:",
+
+        "FIELD_PLACEHOLDER_FILTER" : "@:APP.FIELD_PLACEHOLDER_FILTER",
+
+        "INFO_READ_ONLY" : "Sorry, but this user account cannot be edited.",
+
+        "SECTION_HEADER_CONNECTIONS" : "Connections",
+        "SECTION_HEADER_EDIT_USER"   : "Edit User",
+        "SECTION_HEADER_PERMISSIONS" : "Permissions",
+
+        "TEXT_CONFIRM_DELETE" : "Users cannot be restored after they have been deleted. Are you sure you want to delete this user?"
+
+    },
+    
+    "PROTOCOL_RDP" : {
+
+        "FIELD_HEADER_CLIENT_NAME"     : "Client name:",
+        "FIELD_HEADER_COLOR_DEPTH"     : "Color depth:",
+        "FIELD_HEADER_CONSOLE"         : "Administrator console:",
+        "FIELD_HEADER_CONSOLE_AUDIO"   : "Support audio in console:",
+        "FIELD_HEADER_CREATE_DRIVE_PATH" : "Automatically create drive:",
+        "FIELD_HEADER_DISABLE_AUDIO"   : "Disable audio:",
+        "FIELD_HEADER_DISABLE_AUTH"    : "Disable authentication:",
+        "FIELD_HEADER_DOMAIN"          : "Domain:",
+        "FIELD_HEADER_DPI"             : "Resolution (DPI):",
+        "FIELD_HEADER_DRIVE_PATH"      : "Drive path:",
+        "FIELD_HEADER_ENABLE_DESKTOP_COMPOSITION" : "Enable desktop composition (Aero):",
+        "FIELD_HEADER_ENABLE_DRIVE"               : "Enable drive:",
+        "FIELD_HEADER_ENABLE_FONT_SMOOTHING"      : "Enable font smoothing (ClearType):",
+        "FIELD_HEADER_ENABLE_FULL_WINDOW_DRAG"    : "Enable full-window drag:",
+        "FIELD_HEADER_ENABLE_MENU_ANIMATIONS"     : "Enable menu animations:",
+        "FIELD_HEADER_ENABLE_PRINTING"            : "Enable printing:",
+        "FIELD_HEADER_ENABLE_SFTP"     : "Enable SFTP:",
+        "FIELD_HEADER_ENABLE_THEMING"             : "Enable theming:",
+        "FIELD_HEADER_ENABLE_WALLPAPER"           : "Enable wallpaper:",
+        "FIELD_HEADER_HEIGHT"          : "Height:",
+        "FIELD_HEADER_HOSTNAME"        : "Hostname:",
+        "FIELD_HEADER_IGNORE_CERT"     : "Ignore server certificate:",
+        "FIELD_HEADER_INITIAL_PROGRAM" : "Initial program:",
+        "FIELD_HEADER_PASSWORD"        : "Password:",
+        "FIELD_HEADER_PORT"            : "Port:",
+        "FIELD_HEADER_PRECONNECTION_BLOB" : "Preconnection BLOB (VM ID):",
+        "FIELD_HEADER_PRECONNECTION_ID"   : "RDP source ID:",
+        "FIELD_HEADER_REMOTE_APP_ARGS" : "Parameters:",
+        "FIELD_HEADER_REMOTE_APP_DIR"  : "Working directory:",
+        "FIELD_HEADER_REMOTE_APP"      : "Program:",
+        "FIELD_HEADER_SECURITY"        : "Security mode:",
+        "FIELD_HEADER_SERVER_LAYOUT"   : "Keyboard layout:",
+        "FIELD_HEADER_SFTP_DIRECTORY"   : "Default upload directory:",
+        "FIELD_HEADER_SFTP_HOSTNAME"    : "Hostname:",
+        "FIELD_HEADER_SFTP_PASSPHRASE"  : "Passphrase:",
+        "FIELD_HEADER_SFTP_PASSWORD"    : "Password:",
+        "FIELD_HEADER_SFTP_PORT"        : "Port:",
+        "FIELD_HEADER_SFTP_PRIVATE_KEY" : "Private key:",
+        "FIELD_HEADER_SFTP_USERNAME"    : "Username:",
+        "FIELD_HEADER_STATIC_CHANNELS" : "Static channel names:",
+        "FIELD_HEADER_USERNAME"        : "Username:",
+        "FIELD_HEADER_WIDTH"           : "Width:",
+
+        "FIELD_OPTION_COLOR_DEPTH_16"    : "Low color (16-bit)",
+        "FIELD_OPTION_COLOR_DEPTH_24"    : "True color (24-bit)",
+        "FIELD_OPTION_COLOR_DEPTH_32"    : "True color (32-bit)",
+        "FIELD_OPTION_COLOR_DEPTH_8"     : "256 color",
+        "FIELD_OPTION_COLOR_DEPTH_EMPTY" : "",
+
+        "FIELD_OPTION_SECURITY_ANY"   : "Any",
+        "FIELD_OPTION_SECURITY_EMPTY" : "",
+        "FIELD_OPTION_SECURITY_NLA"   : "NLA (Network Level Authentication)",
+        "FIELD_OPTION_SECURITY_RDP"   : "RDP encryption",
+        "FIELD_OPTION_SECURITY_TLS"   : "TLS encryption",
+
+        "FIELD_OPTION_SERVER_LAYOUT_DE_DE_QWERTZ" : "German (Qwertz)",
+        "FIELD_OPTION_SERVER_LAYOUT_EMPTY"        : "",
+        "FIELD_OPTION_SERVER_LAYOUT_EN_US_QWERTY" : "US English (Qwerty)",
+        "FIELD_OPTION_SERVER_LAYOUT_FAILSAFE"     : "Unicode",
+        "FIELD_OPTION_SERVER_LAYOUT_FR_FR_AZERTY" : "French (Azerty)",
+        "FIELD_OPTION_SERVER_LAYOUT_IT_IT_QWERTY" : "Italian (Qwerty)",
+        "FIELD_OPTION_SERVER_LAYOUT_SV_SE_QWERTY" : "Swedish (Qwerty)",
+
+        "NAME" : "RDP",
+
+        "SECTION_HEADER_AUTHENTICATION"     : "Authentication",
+        "SECTION_HEADER_BASIC_PARAMETERS"   : "Basic Settings",
+        "SECTION_HEADER_DEVICE_REDIRECTION" : "Device Redirection",
+        "SECTION_HEADER_DISPLAY"            : "Display",
+        "SECTION_HEADER_NETWORK"            : "Network",
+        "SECTION_HEADER_PERFORMANCE"        : "Performance",
+        "SECTION_HEADER_PRECONNECTION_PDU"  : "Preconnection PDU / Hyper-V",
+        "SECTION_HEADER_REMOTEAPP"          : "RemoteApp",
+        "SECTION_HEADER_SFTP"               : "SFTP"
+
+    },
+
+    "PROTOCOL_SSH" : {
+
+        "FIELD_HEADER_COLOR_SCHEME" : "Color scheme:",
+        "FIELD_HEADER_COMMAND"     : "Execute command:",
+        "FIELD_HEADER_FONT_NAME"   : "Font name:",
+        "FIELD_HEADER_FONT_SIZE"   : "Font size:",
+        "FIELD_HEADER_ENABLE_SFTP" : "Enable SFTP:",
+        "FIELD_HEADER_HOSTNAME"    : "Hostname:",
+        "FIELD_HEADER_USERNAME"    : "Username:",
+        "FIELD_HEADER_PASSWORD"    : "Password:",
+        "FIELD_HEADER_PASSPHRASE"  : "Passphrase:",
+        "FIELD_HEADER_PORT"        : "Port:",
+        "FIELD_HEADER_PRIVATE_KEY" : "Private key:",
+
+        "FIELD_OPTION_COLOR_SCHEME_BLACK_WHITE" : "Black on white",
+        "FIELD_OPTION_COLOR_SCHEME_EMPTY"       : "",
+        "FIELD_OPTION_COLOR_SCHEME_GRAY_BLACK"  : "Gray on black",
+        "FIELD_OPTION_COLOR_SCHEME_GREEN_BLACK" : "Green on black",
+        "FIELD_OPTION_COLOR_SCHEME_WHITE_BLACK" : "White on black",
+
+        "FIELD_OPTION_FONT_SIZE_8"     : "8",
+        "FIELD_OPTION_FONT_SIZE_9"     : "9",
+        "FIELD_OPTION_FONT_SIZE_10"    : "10",
+        "FIELD_OPTION_FONT_SIZE_11"    : "11",
+        "FIELD_OPTION_FONT_SIZE_12"    : "12",
+        "FIELD_OPTION_FONT_SIZE_14"    : "14",
+        "FIELD_OPTION_FONT_SIZE_18"    : "18",
+        "FIELD_OPTION_FONT_SIZE_24"    : "24",
+        "FIELD_OPTION_FONT_SIZE_30"    : "30",
+        "FIELD_OPTION_FONT_SIZE_36"    : "36",
+        "FIELD_OPTION_FONT_SIZE_48"    : "48",
+        "FIELD_OPTION_FONT_SIZE_60"    : "60",
+        "FIELD_OPTION_FONT_SIZE_72"    : "72",
+        "FIELD_OPTION_FONT_SIZE_96"    : "96",
+        "FIELD_OPTION_FONT_SIZE_EMPTY" : "",
+
+        "NAME" : "SSH",
+
+        "SECTION_HEADER_AUTHENTICATION" : "Authentication",
+        "SECTION_HEADER_DISPLAY"        : "Display",
+        "SECTION_HEADER_NETWORK"        : "Network",
+        "SECTION_HEADER_SESSION"        : "Session / Environment",
+        "SECTION_HEADER_SFTP"           : "SFTP"
+
+    },
+
+    "PROTOCOL_TELNET" : {
+
+        "FIELD_HEADER_COLOR_SCHEME"   : "Color scheme:",
+        "FIELD_HEADER_FONT_NAME"      : "Font name:",
+        "FIELD_HEADER_FONT_SIZE"      : "Font size:",
+        "FIELD_HEADER_HOSTNAME"       : "Hostname:",
+        "FIELD_HEADER_USERNAME"       : "Username:",
+        "FIELD_HEADER_PASSWORD"       : "Password:",
+        "FIELD_HEADER_PASSWORD_REGEX" : "Password regular expression:",
+        "FIELD_HEADER_PORT"           : "Port:",
+
+        "FIELD_OPTION_COLOR_SCHEME_BLACK_WHITE" : "Black on white",
+        "FIELD_OPTION_COLOR_SCHEME_EMPTY"       : "",
+        "FIELD_OPTION_COLOR_SCHEME_GRAY_BLACK"  : "Gray on black",
+        "FIELD_OPTION_COLOR_SCHEME_GREEN_BLACK" : "Green on black",
+        "FIELD_OPTION_COLOR_SCHEME_WHITE_BLACK" : "White on black",
+
+        "FIELD_OPTION_FONT_SIZE_8"     : "8",
+        "FIELD_OPTION_FONT_SIZE_9"     : "9",
+        "FIELD_OPTION_FONT_SIZE_10"    : "10",
+        "FIELD_OPTION_FONT_SIZE_11"    : "11",
+        "FIELD_OPTION_FONT_SIZE_12"    : "12",
+        "FIELD_OPTION_FONT_SIZE_14"    : "14",
+        "FIELD_OPTION_FONT_SIZE_18"    : "18",
+        "FIELD_OPTION_FONT_SIZE_24"    : "24",
+        "FIELD_OPTION_FONT_SIZE_30"    : "30",
+        "FIELD_OPTION_FONT_SIZE_36"    : "36",
+        "FIELD_OPTION_FONT_SIZE_48"    : "48",
+        "FIELD_OPTION_FONT_SIZE_60"    : "60",
+        "FIELD_OPTION_FONT_SIZE_72"    : "72",
+        "FIELD_OPTION_FONT_SIZE_96"    : "96",
+        "FIELD_OPTION_FONT_SIZE_EMPTY" : "",
+
+        "NAME" : "Telnet",
+
+        "SECTION_HEADER_AUTHENTICATION" : "Authentication",
+        "SECTION_HEADER_DISPLAY"        : "Display",
+        "SECTION_HEADER_NETWORK"        : "Network"
+
+    },
+
+    "PROTOCOL_VNC" : {
+
+        "FIELD_HEADER_AUDIO_SERVERNAME" : "Audio server name:",
+        "FIELD_HEADER_CLIPBOARD_ENCODING" : "Encoding:",
+        "FIELD_HEADER_COLOR_DEPTH"      : "Color depth:",
+        "FIELD_HEADER_CURSOR"           : "Cursor:",
+        "FIELD_HEADER_DEST_HOST"        : "Destination host:",
+        "FIELD_HEADER_DEST_PORT"        : "Destination port:",
+        "FIELD_HEADER_ENABLE_AUDIO"     : "Enable audio:",
+        "FIELD_HEADER_ENABLE_SFTP"      : "Enable SFTP:",
+        "FIELD_HEADER_HOSTNAME"         : "Hostname:",
+        "FIELD_HEADER_PASSWORD"         : "Password:",
+        "FIELD_HEADER_PORT"             : "Port:",
+        "FIELD_HEADER_READ_ONLY"        : "Read-only:",
+        "FIELD_HEADER_SFTP_DIRECTORY"   : "Default upload directory:",
+        "FIELD_HEADER_SFTP_HOSTNAME"    : "Hostname:",
+        "FIELD_HEADER_SFTP_PASSPHRASE"  : "Passphrase:",
+        "FIELD_HEADER_SFTP_PASSWORD"    : "Password:",
+        "FIELD_HEADER_SFTP_PORT"        : "Port:",
+        "FIELD_HEADER_SFTP_PRIVATE_KEY" : "Private key:",
+        "FIELD_HEADER_SFTP_USERNAME"    : "Username:",
+        "FIELD_HEADER_SWAP_RED_BLUE"    : "Swap red/blue components:",
+
+        "FIELD_OPTION_COLOR_DEPTH_8"     : "256 color",
+        "FIELD_OPTION_COLOR_DEPTH_16"    : "Low color (16-bit)",
+        "FIELD_OPTION_COLOR_DEPTH_24"    : "True color (24-bit)",
+        "FIELD_OPTION_COLOR_DEPTH_32"    : "True color (32-bit)",
+        "FIELD_OPTION_COLOR_DEPTH_EMPTY" : "",
+
+        "FIELD_OPTION_CURSOR_EMPTY"  : "",
+        "FIELD_OPTION_CURSOR_LOCAL"  : "Local",
+        "FIELD_OPTION_CURSOR_REMOTE" : "Remote",
+
+        "FIELD_OPTION_CLIPBOARD_ENCODING_CP1252"    : "CP1252",
+        "FIELD_OPTION_CLIPBOARD_ENCODING_EMPTY"     : "",
+        "FIELD_OPTION_CLIPBOARD_ENCODING_ISO8859_1" : "ISO 8859-1",
+        "FIELD_OPTION_CLIPBOARD_ENCODING_UTF_8"     : "UTF-8",
+        "FIELD_OPTION_CLIPBOARD_ENCODING_UTF_16"    : "UTF-16",
+
+        "NAME" : "VNC",
+
+        "SECTION_HEADER_AUDIO"          : "Audio",
+        "SECTION_HEADER_AUTHENTICATION" : "Authentication",
+        "SECTION_HEADER_CLIPBOARD"      : "Clipboard",
+        "SECTION_HEADER_DISPLAY"        : "Display",
+        "SECTION_HEADER_NETWORK"        : "Network",
+        "SECTION_HEADER_REPEATER"       : "VNC Repeater",
+        "SECTION_HEADER_SFTP"           : "SFTP"
+
+    },
+
+    "SETTINGS" : {
+
+        "SECTION_HEADER_SETTINGS" : "Settings"
+
+    },
+
+    "SETTINGS_CONNECTION_HISTORY" : {
+
+        "ACTION_SEARCH" : "@:APP.ACTION_SEARCH",
+
+        "FIELD_PLACEHOLDER_FILTER" : "@:APP.FIELD_PLACEHOLDER_FILTER",
+
+        "FORMAT_DATE" : "@:APP.FORMAT_DATE_TIME_PRECISE",
+
+        "HELP_CONNECTION_HISTORY" : "History records for past connections are listed here and can be sorted by clicking the column headers. To search for specific records, enter a filter string and click \"Search\". Only records which match the provided filter string will be listed.",
+
+        "INFO_CONNECTION_DURATION_UNKNOWN" : "--",
+        "INFO_NO_HISTORY"                  : "No matching records",
+
+        "TABLE_HEADER_SESSION_CONNECTION_NAME" : "Connection name",
+        "TABLE_HEADER_SESSION_DURATION"        : "Duration",
+        "TABLE_HEADER_SESSION_STARTDATE"       : "Start time",
+        "TABLE_HEADER_SESSION_USERNAME"        : "Username",
+
+        "TEXT_HISTORY_DURATION" : "@:APP.TEXT_HISTORY_DURATION"
+
+    },
+
+    "SETTINGS_CONNECTIONS" : {
+
+        "ACTION_ACKNOWLEDGE"          : "@:APP.ACTION_ACKNOWLEDGE",
+        "ACTION_NEW_CONNECTION"       : "New Connection",
+        "ACTION_NEW_CONNECTION_GROUP" : "New Group",
+
+        "DIALOG_HEADER_ERROR" : "@:APP.DIALOG_HEADER_ERROR",
+
+        "FIELD_PLACEHOLDER_FILTER" : "@:APP.FIELD_PLACEHOLDER_FILTER",
+
+        "HELP_CONNECTIONS"   : "Click or tap on a connection below to manage that connection. Depending on your access level, connections can be added and deleted, and their properties (protocol, hostname, port, etc.) can be changed.",
+        
+        "INFO_ACTIVE_USER_COUNT" : "@:APP.INFO_ACTIVE_USER_COUNT",
+
+        "SECTION_HEADER_CONNECTIONS"     : "Connections"
+
+    },
+
+    "SETTINGS_PREFERENCES" : {
+
+        "ACTION_ACKNOWLEDGE"        : "@:APP.ACTION_ACKNOWLEDGE",
+        "ACTION_CANCEL"             : "@:APP.ACTION_CANCEL",
+        "ACTION_UPDATE_PASSWORD"    : "@:APP.ACTION_UPDATE_PASSWORD",
+
+        "DIALOG_HEADER_ERROR"    : "@:APP.DIALOG_HEADER_ERROR",
+
+        "ERROR_PASSWORD_BLANK"    : "@:APP.ERROR_PASSWORD_BLANK",
+        "ERROR_PASSWORD_MISMATCH" : "@:APP.ERROR_PASSWORD_MISMATCH",
+
+        "FIELD_HEADER_LANGUAGE"           : "Display language:",
+        "FIELD_HEADER_PASSWORD"           : "Password:",
+        "FIELD_HEADER_PASSWORD_OLD"       : "Current Password:",
+        "FIELD_HEADER_PASSWORD_NEW"       : "New Password:",
+        "FIELD_HEADER_PASSWORD_NEW_AGAIN" : "Confirm New Password:",
+        "FIELD_HEADER_USERNAME"           : "Username:",
+        
+        "HELP_DEFAULT_INPUT_METHOD" : "The default input method determines how keyboard events are received by Guacamole. Changing this setting may be necessary when using a mobile device, or when typing through an IME. This setting can be overridden on a per-connection basis within the Guacamole menu.",
+        "HELP_DEFAULT_MOUSE_MODE"   : "The default mouse emulation mode determines how the remote mouse will behave in new connections with respect to touches. This setting can be overridden on a per-connection basis within the Guacamole menu.",
+        "HELP_INPUT_METHOD_NONE"    : "@:CLIENT.HELP_INPUT_METHOD_NONE",
+        "HELP_INPUT_METHOD_OSK"     : "@:CLIENT.HELP_INPUT_METHOD_OSK",
+        "HELP_INPUT_METHOD_TEXT"    : "@:CLIENT.HELP_INPUT_METHOD_TEXT",
+        "HELP_LANGUAGE"             : "Select a different language below to change the language of all text within Guacamole. Available choices will depend on which languages are installed.",
+        "HELP_MOUSE_MODE_ABSOLUTE"  : "@:CLIENT.HELP_MOUSE_MODE_ABSOLUTE",
+        "HELP_MOUSE_MODE_RELATIVE"  : "@:CLIENT.HELP_MOUSE_MODE_RELATIVE",
+        "HELP_UPDATE_PASSWORD"      : "If you wish to change your password, enter your current password and the desired new password below, and click \"Update Password\". The change will take effect immediately.",
+
+        "INFO_PASSWORD_CHANGED" : "Password changed.",
+
+        "NAME_INPUT_METHOD_NONE" : "@:CLIENT.NAME_INPUT_METHOD_NONE",
+        "NAME_INPUT_METHOD_OSK"  : "@:CLIENT.NAME_INPUT_METHOD_OSK",
+        "NAME_INPUT_METHOD_TEXT" : "@:CLIENT.NAME_INPUT_METHOD_TEXT",
+
+        "SECTION_HEADER_DEFAULT_INPUT_METHOD" : "Default Input Method",
+        "SECTION_HEADER_DEFAULT_MOUSE_MODE"   : "Default Mouse Emulation Mode",
+        "SECTION_HEADER_UPDATE_PASSWORD"      : "Change Password"
+
+    },
+
+    "SETTINGS_USERS" : {
+
+        "ACTION_ACKNOWLEDGE"   : "@:APP.ACTION_ACKNOWLEDGE",
+        "ACTION_NEW_USER"      : "New User",
+
+        "DIALOG_HEADER_ERROR" : "@:APP.DIALOG_HEADER_ERROR",
+
+        "FIELD_PLACEHOLDER_FILTER" : "@:APP.FIELD_PLACEHOLDER_FILTER",
+
+        "HELP_USERS" : "Click or tap on a user below to manage that user. Depending on your access level, users can be added and deleted, and their passwords can be changed.",
+
+        "SECTION_HEADER_USERS"       : "Users"
+
+    },
+    
+    "SETTINGS_SESSIONS" : {
+        
+        "ACTION_ACKNOWLEDGE" : "@:APP.ACTION_ACKNOWLEDGE",
+        "ACTION_CANCEL"      : "@:APP.ACTION_CANCEL",
+        "ACTION_DELETE"      : "Kill Sessions",
+        
+        "DIALOG_HEADER_CONFIRM_DELETE" : "Kill Sessions",
+        "DIALOG_HEADER_ERROR"          : "@:APP.DIALOG_HEADER_ERROR",
+        
+        "FIELD_PLACEHOLDER_FILTER" : "@:APP.FIELD_PLACEHOLDER_FILTER",
+        
+        "FORMAT_STARTDATE" : "@:APP.FORMAT_DATE_TIME_PRECISE",
+
+        "HELP_SESSIONS" : "All currently-active Guacamole sessions are listed here. If you wish to kill one or more sessions, check the box next to those sessions and click \"Kill Sessions\". Killing a session will immediately disconnect the user from the associated connection.",
+        
+        "INFO_NO_SESSIONS" : "No active sessions",
+
+        "SECTION_HEADER_SESSIONS" : "Active Sessions",
+        
+        "TABLE_HEADER_SESSION_CONNECTION_NAME" : "Connection name",
+        "TABLE_HEADER_SESSION_REMOTEHOST"      : "Remote host",
+        "TABLE_HEADER_SESSION_STARTDATE"       : "Active since",
+        "TABLE_HEADER_SESSION_USERNAME"        : "Username",
+        
+        "TEXT_CONFIRM_DELETE" : "Are you sure you want to kill all selected sessions? The users using these sessions will be immediately disconnected."
+
+    },
+
+    "USER_MENU" : {
+
+        "ACTION_LOGOUT"             : "@:APP.ACTION_LOGOUT",
+        "ACTION_MANAGE_CONNECTIONS" : "@:APP.ACTION_MANAGE_CONNECTIONS",
+        "ACTION_MANAGE_PREFERENCES" : "@:APP.ACTION_MANAGE_PREFERENCES",
+        "ACTION_MANAGE_SESSIONS"    : "@:APP.ACTION_MANAGE_SESSIONS",
+        "ACTION_MANAGE_SETTINGS"    : "@:APP.ACTION_MANAGE_SETTINGS",
+        "ACTION_MANAGE_USERS"       : "@:APP.ACTION_MANAGE_USERS",
+        "ACTION_NAVIGATE_HOME"      : "@:APP.ACTION_NAVIGATE_HOME",
+        "ACTION_VIEW_HISTORY"       : "@:APP.ACTION_VIEW_HISTORY"
+
+    }
+
+}
diff --git a/guacamole/src/main/webapp/translations/fr.json b/guacamole/src/main/webapp/translations/fr.json
new file mode 100644
index 0000000..c8fa6be
--- /dev/null
+++ b/guacamole/src/main/webapp/translations/fr.json
@@ -0,0 +1,582 @@
+{
+    
+    "NAME" : "Français",
+    
+    "APP" : {
+
+        "ACTION_ACKNOWLEDGE"        : "Confirmer",
+        "ACTION_CANCEL"             : "Annuler",
+        "ACTION_CLONE"              : "Cloner",
+        "ACTION_CONTINUE"           : "Continuer",
+        "ACTION_DELETE"             : "Supprimer",
+        "ACTION_DELETE_SESSIONS"    : "Fermer les Sessions",
+        "ACTION_LOGIN"              : "Se connecter",
+        "ACTION_LOGOUT"             : "Se déconnecter",
+        "ACTION_MANAGE_CONNECTIONS" : "Connexions",
+        "ACTION_MANAGE_PREFERENCES" : "Préférences",
+        "ACTION_MANAGE_SETTINGS"    : "Paramètres",
+        "ACTION_MANAGE_SESSIONS"    : "Sessions Actives",
+        "ACTION_MANAGE_USERS"       : "Utilisateurs",
+        "ACTION_NAVIGATE_BACK"      : "Retour",
+        "ACTION_NAVIGATE_HOME"      : "Accueil",
+        "ACTION_SAVE"               : "Enregistrer",
+        "ACTION_UPDATE_PASSWORD"    : "Mettre à jour mot de passe",
+
+        "DIALOG_HEADER_ERROR" : "Erreur",
+
+        "ERROR_PASSWORD_BLANK"    : "Votre mot de passe ne peut pas être vide.",
+        "ERROR_PASSWORD_MISMATCH" : "Le mot de passe ne correspond pas.",
+        
+        "FIELD_HEADER_PASSWORD"       : "Mot de passe:",
+        "FIELD_HEADER_PASSWORD_AGAIN" : "Répéter mot de passe:",
+
+        "FIELD_PLACEHOLDER_FILTER" : "Filtre",
+
+        "FORMAT_DATE_TIME_PRECISE" : "dd-MM-yyyy HH:mm:ss",
+
+        "INFO_ACTIVE_USER_COUNT" : "Actuellement utilisé par {USERS} {USERS, plural, one{utilisateur} other{utilisateurs}}.",
+
+        "NAME" : "Guacamole ${project.version}",
+
+        "TEXT_HISTORY_DURATION" : "{VALUE} {UNIT, select, second{{VALUE, plural, one{seconde} other{secondes}}} minute{{VALUE, plural, one{minute} other{minutes}}} hour{{VALUE, plural, one{heure} other{heures}}} day{{VALUE, plural, one{jour} other{jours}}} other{}}"
+
+    },
+
+    "CLIENT" : {
+
+        "ACTION_ACKNOWLEDGE"               : "@:APP.ACTION_ACKNOWLEDGE",
+        "ACTION_CLEAR_COMPLETED_TRANSFERS" : "Vider",
+        "ACTION_DISCONNECT"                : "Déconnecter",
+        "ACTION_LOGOUT"                    : "@:APP.ACTION_LOGOUT",
+        "ACTION_NAVIGATE_BACK"             : "@:APP.ACTION_NAVIGATE_BACK",
+        "ACTION_NAVIGATE_HOME"             : "@:APP.ACTION_NAVIGATE_HOME",
+        "ACTION_RECONNECT"                 : "Reconnecter",
+        "ACTION_SAVE_FILE"                 : "@:APP.ACTION_SAVE",
+        "ACTION_UPLOAD_FILES"              : "Envoyer Fichiers",
+
+        "DIALOG_HEADER_CONNECTING"       : "Connexion",
+        "DIALOG_HEADER_CONNECTION_ERROR" : "Erreur de connexion",
+        "DIALOG_HEADER_DISCONNECTED"     : "Déconnecté",
+
+        "ERROR_CLIENT_201"     : "Cette connexion a été fermée car le serveur est occupé. Merci d'attendre quelques minutes et de réessayer.",
+        "ERROR_CLIENT_202"     : "Le serveur Guacamole a fermé cette connexion car l'ordinateur distant a mis trop de temps à répondre. Merci de réessayer ou de contacter l'administrateur.",
+        "ERROR_CLIENT_203"     : "Le serveur distant a rencontré une erreur et a fermé la connexion. Merci de réessayer ou de contacter l'administrateur.",
+        "ERROR_CLIENT_205"     : "Cette connexion a été fermée car elle est en conflit avec une autre. Merci de réessayer plus tard.", 
+        "ERROR_CLIENT_301"     : "Connexion echouée. Merci d'essayer encore.", 
+        "ERROR_CLIENT_303"     : "Vous ne disposez pas des permissions pour accéder à cette connexion. Si vous avez besoin de ces droits demandez à l'administrateur qu'il vous ajoute à la lise des utilisateurs autorisés, ou de vérifier les paramètres système.", 
+        "ERROR_CLIENT_308"     : "Le serveur Guacamole a fermé la connexion car il n'y avait pas de réponse de votre navigateur Internet et qu'il l'a considéré comme déconnecté. Cela se produit à cause de problèmes réseaux (mauvais signal Wi-Fi ou réseau très lent). Merci de vérifier votre réseau et de réessayer.", 
+        "ERROR_CLIENT_31D"     : "Le serveur Guacamole interdit les connexions car vous avez dépassé la limite de connexion simultanée par utilisateur. Merci de fermer une ou plusieurs connexions et de reéssayer.", 
+        "ERROR_CLIENT_DEFAULT" : "Une erreur interne est apparue dans le serveur Guacamole et la connexion a été fermée. Si le problème persiste, merci de notifier l'administrateur ou de regarder les journaux système.", 
+
+        "ERROR_TUNNEL_201"     : "Le serveur Guacamole a rejeté cette tentative de connexion car il y a trop de connexions ouvertes. Merci d'attendre quelques minutes et de réessayer.", 
+        "ERROR_TUNNEL_202"     : "La connexion a été fermée car le serveur met trop de temps à répondre. En général, il s'agit de problème réseau comme un réseau Wi-Fi trop lent ou un réseau très lent. Merci de vérifier votre réseau ou de contacter l'administrateur.", 
+        "ERROR_TUNNEL_203"     : "Le serveur a rencontré une erreur et a fermé la connexion. Merci de réessayer ou de contacter l'administrateur.", 
+        "ERROR_TUNNEL_204"     : "Le connexion demandée n'existe pas. Merci de vérifier le nom et de réessayer.",
+        "ERROR_TUNNEL_205"     : "Cette connexion est actuellement utilisée et les connexions multiples ne sont pas autorisées. Merci de réeassyer plus tard.", 
+        "ERROR_TUNNEL_301"     : "Vous n'avez pas le droit d'accéder à cette connexion car vous n'êtes pas connecté. Merci de vous connecter et de réessayer.", 
+        "ERROR_TUNNEL_303"     : "Vous n'avez pas le droit d'accéder à cette connexion. Si vous souhaitez y avoir accès, merci de demander à l'administrateur de vous ajouter dans la liste des utilisateurs autorisés ou de vérifier les paramètre système.",
+        "ERROR_TUNNEL_308"     : "Le serveur Guacamole a fermé la connexion car il n'y avait pas de réponse de votre navigateur Internet et qu'il l'a considéré comme déconnecté. Cela se produit à cause de problèmes réseaux (mauvais signal Wi-Fi ou réseau très lent). Merci de vérifier votre réseau et de réessayer.",
+        "ERROR_TUNNEL_31D"     : "Le serveur Guacamole interdit cette connexion car vous avez dépassé la limite de connexions simultanées par utilisateur. Merci de fermer une ou plusieurs connexions et de reéssayer.",
+        "ERROR_TUNNEL_DEFAULT" : "Une erreur interne est apparue dans le serveur Guacamole et la connexion a été fermée. Si le problème persiste, merci de notifier l'administrateur ou de regarder les journaux système.",
+
+        "ERROR_UPLOAD_100"     : "Le transfert de fichier n'est pas activé ou supporté. Merci de contacter l'administrateur ou de vérifier les journaux système.",
+        "ERROR_UPLOAD_201"     : "Trop de fichiers sont transférés. Merci d'attendre que les transferts en cours se terminent et réessayer.",
+        "ERROR_UPLOAD_202"     : "Le fichier ne peut être transféré car le serveur distant met trop de temps à répondre. Merci de réessayer ou de contacter votre administrateur.",
+        "ERROR_UPLOAD_203"     : "Le serveur distant a rencontré une erreur durant le transfert. Merci de reéssayer et de contacter l'administrateur.",
+        "ERROR_UPLOAD_204"     : "La destination du transfert de fichier n'existe pas. Merci de vérifier que la destination existe et de réessayer.",
+        "ERROR_UPLOAD_205"     : "La destination du transfert de fichier est actuellement verouillée. Merci de patienter la fin des tâches en cours et de réessayer.",
+        "ERROR_UPLOAD_301"     : "Vous n'avez pas la permission d'envoyer ce fichier car vous n'êtes pas connecté. Merci de vous connecter et de réessayer.",
+        "ERROR_UPLOAD_303"     : "Vous n'avez pas la permission d'envoyer le fichier. Si vous avez besoin de cet accès, merci de vérifier vos paramètres system ou de valider avec votre administrateur.", 
+        "ERROR_UPLOAD_308"     : "Le transfert de fichier s'est bloqué. En général, il s'agit d'un problème réseau comme un signal Wi-Fi faible ou un réseau très lent. Merci de vérifier votre réseau et de réessayer.",
+        "ERROR_UPLOAD_31D"     : "Trop de fichiers sont actuellement transférés. Merci d'attendre que les transferts en cours soient terminés et de réessayer plus tard.", 
+        "ERROR_UPLOAD_DEFAULT" : "Une erreur interne est apparue dans le serveur Guacamole et la connexion a été fermée. Si le problème persiste, merci de notifier l'administrateur ou de regarder les journaux système.",
+
+        "HELP_CLIPBOARD"           : "Texte copié/coupé dans Guacamole apparaîtra ici. Changer le texte ci dessous affectera le presse-papiers distant.",
+        "HELP_INPUT_METHOD_NONE"   : "Aucune méthode de saisie utilisée. Clavier accepté depuis un clavier physique connecté.",
+        "HELP_INPUT_METHOD_OSK"    : "Affiche et utilise la saisie du clavier virtuel intégré dans Guacamole. Le clavier virtuel permet d'utiliser des combinaisons de touches autrement impossibles (comme Ctrl-Alt-Supp).",
+        "HELP_INPUT_METHOD_TEXT"   : "Affiche et utilise la saisie du clavier virtuel intégré dans Guacamole. Ceci est nécessaire pou les périphériques mobiles ne disposant pas de clavier physique.",
+        "HELP_MOUSE_MODE"          : "Détermine comment la souris distante se comporte selon les événements.", 
+        "HELP_MOUSE_MODE_ABSOLUTE" : "Appuyer pour cliquer. Le clique s'effectue à l'endroit de l'appui.",
+        "HELP_MOUSE_MODE_RELATIVE" : "Glisser pour déplacer le pointeur de la souris et appuyer opur cliquer. Le clique s'effectue à l'endroit du pointeur.", 
+
+        "INFO_NO_FILE_TRANSFERS" : "Pas de transfert de fichier.",
+
+        "NAME_INPUT_METHOD_NONE"   : "Aucune",
+        "NAME_INPUT_METHOD_OSK"    : "Clavier virtuel",
+        "NAME_INPUT_METHOD_TEXT"   : "Clavier",
+        "NAME_KEY_CTRL"            : "Ctrl",
+        "NAME_KEY_ALT"             : "Alt",
+        "NAME_KEY_ESC"             : "Echap",
+        "NAME_KEY_TAB"             : "Tab",
+        "NAME_MOUSE_MODE_ABSOLUTE" : "Écran tactile",
+        "NAME_MOUSE_MODE_RELATIVE" : "Pavé tactile",
+
+        "SECTION_HEADER_CLIPBOARD"      : "Presse-papiers",
+        "SECTION_HEADER_DEVICES"        : "Appareils",
+        "SECTION_HEADER_DISPLAY"        : "Affichage",
+        "SECTION_HEADER_FILE_TRANSFERS" : "Transfers de fichiers",
+        "SECTION_HEADER_INPUT_METHOD"   : "Méthode de saisie",
+        "SECTION_HEADER_MOUSE_MODE"     : "Mode émulation souris",
+
+        "TEXT_ZOOM_AUTO_FIT"              : "Adapté à la fenêtre du navigateur",
+        "TEXT_CLIENT_STATUS_IDLE"         : "Inactif.",
+        "TEXT_CLIENT_STATUS_CONNECTING"   : "Connexion à Guacamole...",
+        "TEXT_CLIENT_STATUS_DISCONNECTED" : "Vous avez été deconnecté.",
+        "TEXT_CLIENT_STATUS_WAITING"      : "Connecté à Guacamole. En attente de réponse...",
+        "TEXT_RECONNECT_COUNTDOWN"        : "Reconnexion dans {REMAINING} {REMAINING, plural, one{seconde} other{secondes}}...",
+        "TEXT_FILE_TRANSFER_PROGRESS"     : "{PROGRESS} {UNIT, select, b{B} kb{KB} mb{MB} gb{GB} other{}}",
+
+        "URL_OSK_LAYOUT" : "layouts/fr-fr-azerty.json" 
+
+    },
+
+    "FORM" : {
+
+        "HELP_SHOW_PASSWORD" : "Cliquer pour afficher le mot de passe",
+        "HELP_HIDE_PASSWORD" : "Cliquer pour masquer le mot de passe"
+
+    },
+
+    "HOME" : {
+
+        "FIELD_PLACEHOLDER_FILTER" : "@:APP.FIELD_PLACEHOLDER_FILTER",
+
+        "INFO_ACTIVE_USER_COUNT" : "@:APP.INFO_ACTIVE_USER_COUNT",
+
+        "INFO_NO_RECENT_CONNECTIONS" : "Pas de connexion récente.",
+        
+        "PASSWORD_CHANGED" : "Mot de passe changé.",
+
+        "SECTION_HEADER_ALL_CONNECTIONS"    : "Toutes les Connexions",
+        "SECTION_HEADER_RECENT_CONNECTIONS" : "Connexions récentes"
+
+    },
+
+    "LOGIN": {
+
+        "ACTION_ACKNOWLEDGE" : "@:APP.ACTION_ACKNOWLEDGE",
+        "ACTION_CONTINUE"    : "@:APP.ACTION_CONTINUE",
+        "ACTION_LOGIN"       : "@:APP.ACTION_LOGIN",
+
+        "DIALOG_HEADER_ERROR" : "@:APP.DIALOG_HEADER_ERROR",
+
+        "ERROR_INVALID_LOGIN" : "Identifiant Incorrect",
+
+        "FIELD_HEADER_USERNAME" : "Identifiant",
+        "FIELD_HEADER_PASSWORD" : "Mot de passe"
+
+    },
+
+    "MANAGE_CONNECTION" : {
+
+        "ACTION_ACKNOWLEDGE"          : "@:APP.ACTION_ACKNOWLEDGE",
+        "ACTION_CANCEL"               : "@:APP.ACTION_CANCEL",
+        "ACTION_CLONE"                : "@:APP.ACTION_CLONE",
+        "ACTION_DELETE"               : "@:APP.ACTION_DELETE",
+        "ACTION_SAVE"                 : "@:APP.ACTION_SAVE",
+
+        "DIALOG_HEADER_CONFIRM_DELETE" : "Supprimer Connexion",
+        "DIALOG_HEADER_ERROR"          : "Erreur",
+
+        "FIELD_HEADER_LOCATION" : "Lieu:",
+        "FIELD_HEADER_NAME"     : "Nom:",
+        "FIELD_HEADER_PROTOCOL" : "Protocole:",
+
+        "FORMAT_HISTORY_START" : "@:APP.FORMAT_DATE_TIME_PRECISE",
+
+        "INFO_CONNECTION_DURATION_UNKNOWN" : "--",
+        "INFO_CONNECTION_ACTIVE_NOW"       : "Active",
+        "INFO_CONNECTION_NOT_USED"         : "Cette connexion n'a jamais été utilisée.",
+
+        "SECTION_HEADER_EDIT_CONNECTION" : "Modifier Connexion",
+        "SECTION_HEADER_HISTORY"         : "Historique d'utilisation",
+        "SECTION_HEADER_PARAMETERS"      : "Paramètres",
+
+        "TABLE_HEADER_HISTORY_USERNAME" : "Identifiant",
+        "TABLE_HEADER_HISTORY_START"    : "Ouverture",
+        "TABLE_HEADER_HISTORY_DURATION" : "Durée",
+
+        "TEXT_CONFIRM_DELETE"   : "Les connexions ne pourront être restaurées une fois supprimées. Êtes-vous certains de vouloir supprimer cette connexion ?",
+        "TEXT_HISTORY_DURATION" : "@:APP.TEXT_HISTORY_DURATION"
+
+    },
+
+    "MANAGE_CONNECTION_GROUP" : {
+
+        "ACTION_ACKNOWLEDGE"   : "@:APP.ACTION_ACKNOWLEDGE",
+        "ACTION_CANCEL"        : "@:APP.ACTION_CANCEL",
+        "ACTION_DELETE"        : "@:APP.ACTION_DELETE",
+        "ACTION_SAVE"          : "@:APP.ACTION_SAVE",
+
+        "DIALOG_HEADER_CONFIRM_DELETE" : "Supprimer Groupe de Connexion",
+        "DIALOG_HEADER_ERROR"          : "Erreur",
+
+        "FIELD_HEADER_LOCATION" : "Lieu:",
+        "FIELD_HEADER_NAME"     : "Nom:",
+        "FIELD_HEADER_TYPE"     : "Type:",
+
+        "NAME_TYPE_BALANCING"       : "Répartition",
+        "NAME_TYPE_ORGANIZATIONAL"  : "Organizationel",
+
+        "SECTION_HEADER_EDIT_CONNECTION_GROUP" : "Modifier Groupe de Connexion",
+
+        "TEXT_CONFIRM_DELETE" : "Les groupes de connexions ne pourront être restaurés une fois supprimés. Êtes-vous certains de vouloir supprimer ce groupe de connexion ?"
+
+    },
+
+    "MANAGE_USER" : {
+
+        "ACTION_ACKNOWLEDGE"   : "@:APP.ACTION_ACKNOWLEDGE",
+        "ACTION_CANCEL"        : "@:APP.ACTION_CANCEL",
+        "ACTION_DELETE"        : "@:APP.ACTION_DELETE",
+        "ACTION_SAVE"          : "@:APP.ACTION_SAVE",
+
+        "DIALOG_HEADER_CONFIRM_DELETE"  : "Supprimer Utilisateur",
+        "DIALOG_HEADER_ERROR"           : "Erreur",
+
+        "ERROR_PASSWORD_MISMATCH" : "@:APP.ERROR_PASSWORD_MISMATCH",
+
+        "FIELD_HEADER_ADMINISTER_SYSTEM"             : "Administer system:",
+        "FIELD_HEADER_CHANGE_OWN_PASSWORD"           : "Modifier son propre mot de passe:",
+        "FIELD_HEADER_CREATE_NEW_USERS"              : "Créer nouveaux utilisateurs:",
+        "FIELD_HEADER_CREATE_NEW_CONNECTIONS"        : "Créer nouvelles connexions:",
+        "FIELD_HEADER_CREATE_NEW_CONNECTION_GROUPS"  : "Créer nouveaux groupes de connexion:",
+        "FIELD_HEADER_PASSWORD"                      : "@:APP.FIELD_HEADER_PASSWORD",
+        "FIELD_HEADER_PASSWORD_AGAIN"                : "@:APP.FIELD_HEADER_PASSWORD_AGAIN",
+        "FIELD_HEADER_USERNAME"                      : "Identifiant:",
+        
+        "FIELD_PLACEHOLDER_FILTER" : "@:APP.FIELD_PLACEHOLDER_FILTER",
+
+        "SECTION_HEADER_CONNECTIONS" : "Connexions",
+        "SECTION_HEADER_EDIT_USER"   : "Modifier Utilisateur",
+        "SECTION_HEADER_PERMISSIONS" : "Permissions",
+
+        "TEXT_CONFIRM_DELETE" : "Les utilisateurs ne pourront être restaurés une fois supprimés. Êtes-vous certains de vouloir supprimer cet utilisateur?"
+
+    },
+    
+    "PROTOCOL_RDP" : {
+
+        "FIELD_HEADER_CLIENT_NAME"     : "Nom du Client:",
+        "FIELD_HEADER_COLOR_DEPTH"     : "Qualité couleur:",
+        "FIELD_HEADER_CONSOLE"         : "Console Administrateur:",
+        "FIELD_HEADER_CONSOLE_AUDIO"   : "Support son en console:",
+        "FIELD_HEADER_DISABLE_AUDIO"   : "Désactiver son:",
+        "FIELD_HEADER_DISABLE_AUTH"    : "Désactiver authentification:",
+        "FIELD_HEADER_DOMAIN"          : "Nom du domaine:",
+        "FIELD_HEADER_DPI"             : "Résolution (ppp):",
+        "FIELD_HEADER_DRIVE_PATH"      : "Chemin du lecteur:",
+        "FIELD_HEADER_ENABLE_DESKTOP_COMPOSITION" : "Activer la composition du bureau (Aero):",
+        "FIELD_HEADER_ENABLE_DRIVE"    : "Activer lecteur réseau:",
+        "FIELD_HEADER_ENABLE_FONT_SMOOTHING" : "Enable font smoothing (ClearType):",
+        "FIELD_HEADER_ENABLE_FULL_WINDOW_DRAG" : "Activer pleine fenêtre de glisser:",
+        "FIELD_HEADER_ENABLE_MENU_ANIMATIONS" : "Activer les animations de menu:",
+        "FIELD_HEADER_ENABLE_PRINTING" : "Activer imprimante:",
+        "FIELD_HEADER_ENABLE_SFTP"     : "Activer SFTP:",
+        "FIELD_HEADER_ENABLE_THEMING"  : "Activer thématisation:",
+        "FIELD_HEADER_ENABLE_WALLPAPER" : "Activer fond d'écran:",
+        "FIELD_HEADER_HEIGHT"          : "Hauteur:",
+        "FIELD_HEADER_HOSTNAME"        : "Nom d'hôte:",
+        "FIELD_HEADER_IGNORE_CERT"     : "Ignorer le certificat du serveur:",
+        "FIELD_HEADER_INITIAL_PROGRAM" : "Programme de démarrage:",
+        "FIELD_HEADER_PASSWORD"        : "Mot de passe:",
+        "FIELD_HEADER_PORT"            : "Port:",
+        "FIELD_HEADER_REMOTE_APP_ARGS" : "Paramètres:",
+        "FIELD_HEADER_REMOTE_APP_DIR"  : "Répertoire de travail:",
+        "FIELD_HEADER_REMOTE_APP"      : "Programme:",
+        "FIELD_HEADER_SECURITY"        : "Mode de Sécurité:",
+        "FIELD_HEADER_SERVER_LAYOUT"   : "Agencement clavier:",
+        "FIELD_HEADER_SFTP_HOSTNAME"    : "Nom d'hôte:",
+        "FIELD_HEADER_SFTP_PASSPHRASE"  : "Phrase secrète:",
+        "FIELD_HEADER_SFTP_PASSWORD"    : "Mot de passe:",
+        "FIELD_HEADER_SFTP_PORT"        : "Port:",
+        "FIELD_HEADER_SFTP_PRIVATE_KEY" : "Clé privée:",
+        "FIELD_HEADER_SFTP_USERNAME"    : "Identifiant:",
+        "FIELD_HEADER_STATIC_CHANNELS" : "Static channel names:",
+        "FIELD_HEADER_USERNAME"        : "Identifiant:",
+        "FIELD_HEADER_WIDTH"           : "Largeur:",
+
+        "FIELD_OPTION_COLOR_DEPTH_16"    : "Faibles couleurs (16-bit)",
+        "FIELD_OPTION_COLOR_DEPTH_24"    : "Vraies couleurs (24-bit)",
+        "FIELD_OPTION_COLOR_DEPTH_32"    : "Vraies couleurs (32-bit)",
+        "FIELD_OPTION_COLOR_DEPTH_8"     : "256 couleurs",
+        "FIELD_OPTION_COLOR_DEPTH_EMPTY" : "",
+
+        "FIELD_OPTION_SECURITY_ANY"   : "Aucune",
+        "FIELD_OPTION_SECURITY_EMPTY" : "",
+        "FIELD_OPTION_SECURITY_NLA"   : "NLA (Network Level Authentication)",
+        "FIELD_OPTION_SECURITY_RDP"   : "Chiffrement RDP",
+        "FIELD_OPTION_SECURITY_TLS"   : "Chiffrement TLS",
+
+        "FIELD_OPTION_SERVER_LAYOUT_DE_DE_QWERTZ" : "German (Qwertz)",
+        "FIELD_OPTION_SERVER_LAYOUT_EMPTY"        : "",
+        "FIELD_OPTION_SERVER_LAYOUT_EN_US_QWERTY" : "US English (Qwerty)",
+        "FIELD_OPTION_SERVER_LAYOUT_FAILSAFE"     : "Unicode",
+        "FIELD_OPTION_SERVER_LAYOUT_FR_FR_AZERTY" : "French (Azerty)",
+        "FIELD_OPTION_SERVER_LAYOUT_IT_IT_QWERTY" : "Italian (Qwerty)",
+        "FIELD_OPTION_SERVER_LAYOUT_SV_SE_QWERTY" : "Swedish (Qwerty)",
+
+        "NAME" : "RDP",
+
+        "SECTION_HEADER_AUTHENTICATION"     : "Authentification",
+        "SECTION_HEADER_BASIC_PARAMETERS"   : "Paramètres de base",
+        "SECTION_HEADER_DEVICE_REDIRECTION" : "Redirection Périphérique",
+        "SECTION_HEADER_DISPLAY"            : "Affichage",
+        "SECTION_HEADER_NETWORK"            : "Réseau",
+        "SECTION_HEADER_PERFORMANCE"        : "Performance",
+        "SECTION_HEADER_REMOTEAPP"          : "RemoteApp",
+        "SECTION_HEADER_SFTP"               : "SFTP"
+
+    },
+
+    "PROTOCOL_SSH" : {
+
+        "FIELD_HEADER_FONT_NAME"   : "Nom police:",
+        "FIELD_HEADER_FONT_SIZE"   : "Taille police:",
+        "FIELD_HEADER_ENABLE_SFTP" : "Activer SFTP:",
+        "FIELD_HEADER_HOSTNAME"    : "Nom d'hôte:",
+        "FIELD_HEADER_USERNAME"    : "Identifiant:",
+        "FIELD_HEADER_PASSWORD"    : "Mot de passe:",
+        "FIELD_HEADER_PASSPHRASE"  : "Phrase secrète:",
+        "FIELD_HEADER_PORT"        : "Port:",
+        "FIELD_HEADER_PRIVATE_KEY" : "Clé privée:",
+
+        "FIELD_OPTION_FONT_SIZE_8"     : "8",
+        "FIELD_OPTION_FONT_SIZE_9"     : "9",
+        "FIELD_OPTION_FONT_SIZE_10"    : "10",
+        "FIELD_OPTION_FONT_SIZE_11"    : "11",
+        "FIELD_OPTION_FONT_SIZE_12"    : "12",
+        "FIELD_OPTION_FONT_SIZE_14"    : "14",
+        "FIELD_OPTION_FONT_SIZE_18"    : "18",
+        "FIELD_OPTION_FONT_SIZE_24"    : "24",
+        "FIELD_OPTION_FONT_SIZE_30"    : "30",
+        "FIELD_OPTION_FONT_SIZE_36"    : "36",
+        "FIELD_OPTION_FONT_SIZE_48"    : "48",
+        "FIELD_OPTION_FONT_SIZE_60"    : "60",
+        "FIELD_OPTION_FONT_SIZE_72"    : "72",
+        "FIELD_OPTION_FONT_SIZE_96"    : "96",
+        "FIELD_OPTION_FONT_SIZE_EMPTY" : "",
+
+        "NAME" : "SSH",
+
+        "SECTION_HEADER_AUTHENTICATION" : "Authentification",
+        "SECTION_HEADER_DISPLAY"        : "Affichage",
+        "SECTION_HEADER_NETWORK"        : "Réseau",
+        "SECTION_HEADER_SFTP"           : "SFTP"
+
+    },
+
+    "PROTOCOL_TELNET" : {
+
+        "FIELD_HEADER_FONT_NAME"      : "Nom police:",
+        "FIELD_HEADER_FONT_SIZE"      : "Taille police:",
+        "FIELD_HEADER_HOSTNAME"       : "Nom d'hôte:",
+        "FIELD_HEADER_USERNAME"       : "Identifiant:",
+        "FIELD_HEADER_PASSWORD"       : "Mot de passe:",
+        "FIELD_HEADER_PASSWORD_REGEX" : "Expression régulière Mot de passe:",
+        "FIELD_HEADER_PORT"           : "Port:",
+
+        "FIELD_OPTION_FONT_SIZE_8"     : "8",
+        "FIELD_OPTION_FONT_SIZE_9"     : "9",
+        "FIELD_OPTION_FONT_SIZE_10"    : "10",
+        "FIELD_OPTION_FONT_SIZE_11"    : "11",
+        "FIELD_OPTION_FONT_SIZE_12"    : "12",
+        "FIELD_OPTION_FONT_SIZE_14"    : "14",
+        "FIELD_OPTION_FONT_SIZE_18"    : "18",
+        "FIELD_OPTION_FONT_SIZE_24"    : "24",
+        "FIELD_OPTION_FONT_SIZE_30"    : "30",
+        "FIELD_OPTION_FONT_SIZE_36"    : "36",
+        "FIELD_OPTION_FONT_SIZE_48"    : "48",
+        "FIELD_OPTION_FONT_SIZE_60"    : "60",
+        "FIELD_OPTION_FONT_SIZE_72"    : "72",
+        "FIELD_OPTION_FONT_SIZE_96"    : "96",
+        "FIELD_OPTION_FONT_SIZE_EMPTY" : "",
+
+        "NAME" : "Telnet",
+
+        "SECTION_HEADER_AUTHENTICATION" : "Authentification",
+        "SECTION_HEADER_DISPLAY"        : "Affichage",
+        "SECTION_HEADER_NETWORK"        : "Réseau"
+
+    },
+
+    "PROTOCOL_VNC" : {
+
+        "FIELD_HEADER_AUDIO_SERVERNAME" : "Serveur de son:",
+        "FIELD_HEADER_COLOR_DEPTH"      : "Qualité couleur:",
+        "FIELD_HEADER_CURSOR"           : "Curseur:",
+        "FIELD_HEADER_DEST_HOST"        : "Hôte distant:",
+        "FIELD_HEADER_DEST_PORT"        : "Port distant:",
+        "FIELD_HEADER_ENABLE_AUDIO"     : "Activer son:",
+        "FIELD_HEADER_ENABLE_SFTP"      : "Activer SFTP:",
+        "FIELD_HEADER_HOSTNAME"         : "Nom d'hôte:",
+        "FIELD_HEADER_PASSWORD"         : "Mot de passe:",
+        "FIELD_HEADER_PORT"             : "Port:",
+        "FIELD_HEADER_READ_ONLY"        : "Lecture seule:",
+        "FIELD_HEADER_SFTP_HOSTNAME"    : "Nom d'hôte:",
+        "FIELD_HEADER_SFTP_PASSPHRASE"  : "Phrase secrète:",
+        "FIELD_HEADER_SFTP_PASSWORD"    : "Mot de passe:",
+        "FIELD_HEADER_SFTP_PORT"        : "Port:",
+        "FIELD_HEADER_SFTP_PRIVATE_KEY" : "Clé privée:",
+        "FIELD_HEADER_SFTP_USERNAME"    : "Identifiant:",
+        "FIELD_HEADER_SWAP_RED_BLUE"    : "Swap red/blue components:",
+
+        "FIELD_OPTION_COLOR_DEPTH_8"     : "256 couleurs",
+        "FIELD_OPTION_COLOR_DEPTH_16"    : "Faibles couleurs (16-bit)",
+        "FIELD_OPTION_COLOR_DEPTH_24"    : "Vraies couleurs (24-bit)",
+        "FIELD_OPTION_COLOR_DEPTH_32"    : "Vraies couleurs (32-bit)",
+        "FIELD_OPTION_COLOR_DEPTH_EMPTY" : "",
+
+        "FIELD_OPTION_CURSOR_EMPTY"  : "",
+        "FIELD_OPTION_CURSOR_LOCAL"  : "Local",
+        "FIELD_OPTION_CURSOR_REMOTE" : "Distant",
+
+        "NAME" : "VNC",
+
+        "SECTION_HEADER_AUDIO"          : "Son",
+        "SECTION_HEADER_AUTHENTICATION" : "Authentification",
+        "SECTION_HEADER_CLIPBOARD"      : "Presse-papiers",
+        "SECTION_HEADER_DISPLAY"        : "Affichage",
+        "SECTION_HEADER_NETWORK"        : "Réseau",
+        "SECTION_HEADER_REPEATER"       : "Répéteur VNC",
+        "SECTION_HEADER_SFTP"           : "SFTP"
+
+    },
+
+    "SETTINGS" : {
+
+        "SECTION_HEADER_SETTINGS" : "Paramètres"
+
+    },
+
+    "SETTINGS_CONNECTIONS" : {
+
+        "ACTION_ACKNOWLEDGE"          : "@:APP.ACTION_ACKNOWLEDGE",
+        "ACTION_NEW_CONNECTION"       : "Nouvelle Connexion",
+        "ACTION_NEW_CONNECTION_GROUP" : "Nouveau Groupe",
+
+        "DIALOG_HEADER_ERROR"          : "Erreur",
+
+        "FIELD_PLACEHOLDER_FILTER" : "@:APP.FIELD_PLACEHOLDER_FILTER",
+
+        "HELP_CONNECTIONS"   : "Cliquer ou appuyer sur une connexion en dessous pour la gérer. Selon vos permissions, les connexions peuvent être ajoutées, supprimées, leur propriétés (protocole, nom d'hôte, port, etc) changées.",
+        
+        "INFO_ACTIVE_USER_COUNT" : "@:APP.INFO_ACTIVE_USER_COUNT",
+
+        "SECTION_HEADER_CONNECTIONS"     : "Connexions"
+
+    },
+
+    "SETTINGS_CONNECTION_HISTORY" : {
+
+        "FIELD_PLACEHOLDER_FILTER" : "@:APP.FIELD_PLACEHOLDER_FILTER",
+
+        "FORMAT_DATE" : "@:APP.FORMAT_DATE_TIME_PRECISE",
+
+        "INFO_CONNECTION_DURATION_UNKNOWN" : "--",
+
+        "TABLE_HEADER_SESSION_CONNECTION_NAME" : "Nom de connexion",
+        "TABLE_HEADER_SESSION_DURATION"        : "Durée",
+        "TABLE_HEADER_SESSION_STARTDATE"       : "Ouvert depuis",
+        "TABLE_HEADER_SESSION_USERNAME"        : "Identifiant",
+
+        "TEXT_HISTORY_DURATION" : "@:APP.TEXT_HISTORY_DURATION"
+
+    },
+
+    "SETTINGS_PREFERENCES" : {
+
+        "ACTION_ACKNOWLEDGE"        : "@:APP.ACTION_ACKNOWLEDGE",
+        "ACTION_CANCEL"             : "@:APP.ACTION_CANCEL",
+        "ACTION_UPDATE_PASSWORD"    : "@:APP.ACTION_UPDATE_PASSWORD",
+
+        "DIALOG_HEADER_ERROR"    : "@:APP.DIALOG_HEADER_ERROR",
+
+        "ERROR_PASSWORD_BLANK"    : "Votre mot de passe ne peut être vide.",
+        "ERROR_PASSWORD_MISMATCH" : "@:APP.ERROR_PASSWORD_MISMATCH",
+
+        "FIELD_HEADER_LANGUAGE"           : "Langue affichée:",
+        "FIELD_HEADER_PASSWORD"           : "Mot de passe:",
+        "FIELD_HEADER_PASSWORD_OLD"       : "Mot de passe actuel:",
+        "FIELD_HEADER_PASSWORD_NEW"       : "Nouveau mot de passe:",
+        "FIELD_HEADER_PASSWORD_NEW_AGAIN" : "Confirmer nouveau mot de passe:",
+        "FIELD_HEADER_USERNAME"           : "Identifiant:",
+        
+        "HELP_DEFAULT_INPUT_METHOD" : "La méthode de saisie par défaut détermine comme les événements clavier sont reçus par Guacamole. Modifier ces paramètres peut être nécessaire pour l'utilisateur des smartphone/tablette. Ces paramètres peuvent être écrasés pour chaque connexion dans le menu de Guacamole.",
+        "HELP_DEFAULT_MOUSE_MODE"   : "Le mode d'émulation de la souris détermine comment la souris distante se comportera dans les nouvelles connexions. Ce paramètre peut être définit dans chaque connexion dans le menu de Guacamole.", 
+        "HELP_INPUT_METHOD_NONE"    : "@:CLIENT.HELP_INPUT_METHOD_NONE",
+        "HELP_INPUT_METHOD_OSK"     : "@:CLIENT.HELP_INPUT_METHOD_OSK",
+        "HELP_INPUT_METHOD_TEXT"    : "@:CLIENT.HELP_INPUT_METHOD_TEXT",
+        "HELP_LANGUAGE"             : "Selectionner une langue différente pour changer tout le texte dans Guacamole. Les choix dépendent des langues qui sont installées.", 
+        "HELP_MOUSE_MODE_ABSOLUTE"  : "@:CLIENT.HELP_MOUSE_MODE_ABSOLUTE",
+        "HELP_MOUSE_MODE_RELATIVE"  : "@:CLIENT.HELP_MOUSE_MODE_RELATIVE",
+        "HELP_UPDATE_PASSWORD"      : "Si vous souhaitez changer votre mot de passe, entrer vos mot de passe actuel et le nouveau mot de passe en dessous puis cliquer sur \"Mettre à jour Mot de passe\". Le changement prendra effet immédiatement.",
+
+        "INFO_PASSWORD_CHANGED" : "Mot de passe changé.",
+
+        "NAME_INPUT_METHOD_NONE" : "@:CLIENT.NAME_INPUT_METHOD_NONE",
+        "NAME_INPUT_METHOD_OSK"  : "@:CLIENT.NAME_INPUT_METHOD_OSK",
+        "NAME_INPUT_METHOD_TEXT" : "@:CLIENT.NAME_INPUT_METHOD_TEXT",
+
+        "SECTION_HEADER_DEFAULT_INPUT_METHOD" : "Méthode de saisie par défaut", 
+        "SECTION_HEADER_DEFAULT_MOUSE_MODE"   : "Mode émulation souris par défaut", 
+        "SECTION_HEADER_UPDATE_PASSWORD"      : "Modifier Mot de passe"
+
+    },
+
+    "SETTINGS_USERS" : {
+
+        "ACTION_ACKNOWLEDGE"   : "@:APP.ACTION_ACKNOWLEDGE",
+        "ACTION_NEW_USER"      : "Nouvel Utilisateur",
+
+        "DIALOG_HEADER_ERROR"           : "Erreur",
+
+        "FIELD_PLACEHOLDER_FILTER" : "@:APP.FIELD_PLACEHOLDER_FILTER",
+
+        "HELP_USERS" : "Cliquer ou appuyer sur un utilisateur en dessous pour le gérer. Selon vos permissions, les utilisateurs peuvent être ajoutés, supprimés, leur mot de passe changé.",
+
+        "SECTION_HEADER_USERS"       : "Utilisateur"
+
+    },
+    
+    "SETTINGS_SESSIONS" : {
+        
+        "ACTION_ACKNOWLEDGE" : "@:APP.ACTION_ACKNOWLEDGE",
+        "ACTION_CANCEL"      : "@:APP.ACTION_CANCEL",
+        "ACTION_DELETE"      : "Fermer Sessions",
+        
+        "DIALOG_HEADER_CONFIRM_DELETE" : "Fermer Sessions",
+        "DIALOG_HEADER_ERROR"          : "Erreur",
+        
+        "FIELD_PLACEHOLDER_FILTER" : "@:APP.FIELD_PLACEHOLDER_FILTER",
+        
+        "FORMAT_STARTDATE" : "@:APP.FORMAT_DATE_TIME_PRECISE",
+
+        "HELP_SESSIONS" : "Toutes les connexions actives Guacamole sont listées ici. Si vous souhaitez en fermer une ou plusieurs, sélectionner les et cliquer sur \"Fermer Sessions\". La fermeture d'une session déconnectera immédiatement l'utilisateur.", 
+        
+        "INFO_NO_SESSIONS" : "Pas de session ouverte",
+
+        "SECTION_HEADER_SESSIONS" : "Sessions Ouvertes",
+        
+        "TABLE_HEADER_SESSION_USERNAME"        : "Identifiant",
+        "TABLE_HEADER_SESSION_STARTDATE"       : "Ouvert depuis",
+        "TABLE_HEADER_SESSION_REMOTEHOST"      : "Hôte distant",
+        "TABLE_HEADER_SESSION_CONNECTION_NAME" : "Nom de connexion",
+        
+        "TEXT_CONFIRM_DELETE" : "Êtes-vous certains de vouloir fermer toutes les connexions sélectionnées ? Les utilisateurs utilisant ces sessions seront immédiatement déconnectés." 
+
+    },
+
+    "USER_MENU" : {
+
+        "ACTION_LOGOUT"             : "@:APP.ACTION_LOGOUT",
+        "ACTION_MANAGE_CONNECTIONS" : "@:APP.ACTION_MANAGE_CONNECTIONS",
+        "ACTION_MANAGE_PREFERENCES" : "@:APP.ACTION_MANAGE_PREFERENCES",
+        "ACTION_MANAGE_SESSIONS"    : "@:APP.ACTION_MANAGE_SESSIONS",
+        "ACTION_MANAGE_SETTINGS"    : "@:APP.ACTION_MANAGE_SETTINGS",
+        "ACTION_MANAGE_USERS"       : "@:APP.ACTION_MANAGE_USERS",
+        "ACTION_NAVIGATE_HOME"      : "@:APP.ACTION_NAVIGATE_HOME"
+
+    }
+
+}
diff --git a/guacamole/src/main/webapp/translations/it.json b/guacamole/src/main/webapp/translations/it.json
new file mode 100644
index 0000000..0dea25c
--- /dev/null
+++ b/guacamole/src/main/webapp/translations/it.json
@@ -0,0 +1,577 @@
+{
+    
+    "NAME" : "Italiano",
+    
+    "APP" : {
+
+        "ACTION_ACKNOWLEDGE"        : "OK",
+        "ACTION_CANCEL"             : "Annulla",
+        "ACTION_CLONE"              : "Clona",
+        "ACTION_CONTINUE"           : "Continua",
+        "ACTION_DELETE"             : "Cancella",
+        "ACTION_DELETE_SESSIONS"    : "Termina Sessione",
+        "ACTION_LOGIN"              : "Entra",
+        "ACTION_LOGOUT"             : "Esci",
+        "ACTION_MANAGE_CONNECTIONS" : "Connessioni",
+        "ACTION_MANAGE_PREFERENCES" : "Preferenze",
+        "ACTION_MANAGE_SETTINGS"    : "Opzioni",
+        "ACTION_MANAGE_SESSIONS"    : "Sessioni Attive",
+        "ACTION_MANAGE_USERS"       : "Utenti",
+        "ACTION_NAVIGATE_BACK"      : "Indietro",
+        "ACTION_NAVIGATE_HOME"      : "Home",
+        "ACTION_SAVE"               : "Salva",
+        "ACTION_UPDATE_PASSWORD"    : "Aggiorna Password",
+
+        "DIALOG_HEADER_ERROR" : "Errore",
+
+        "ERROR_PASSWORD_BLANK"    : "La password non può essere vuota.",
+        "ERROR_PASSWORD_MISMATCH" : "Le password inserite sono diverse!",
+        
+        "FIELD_HEADER_PASSWORD"       : "Password:",
+        "FIELD_HEADER_PASSWORD_AGAIN" : "Re-inserisci la password:",
+
+        "FIELD_PLACEHOLDER_FILTER" : "Filtro",
+
+        "FORMAT_DATE_TIME_PRECISE" : "dd-MM-yyyy HH:mm:ss",
+
+        "INFO_ACTIVE_USER_COUNT" : "Ora utilizzato da {USERS} {USERS, plural, one{user} other{users}}.",
+
+        "NAME" : "Guacamole ${project.version}"
+
+    },
+
+    "CLIENT" : {
+
+        "ACTION_ACKNOWLEDGE"               : "@:APP.ACTION_ACKNOWLEDGE",
+        "ACTION_CLEAR_COMPLETED_TRANSFERS" : "Pulisci i trasferimenti completati",
+        "ACTION_DISCONNECT"                : "Disconnetti",
+        "ACTION_LOGOUT"                    : "@:APP.ACTION_LOGOUT",
+        "ACTION_NAVIGATE_BACK"             : "@:APP.ACTION_NAVIGATE_BACK",
+        "ACTION_NAVIGATE_HOME"             : "@:APP.ACTION_NAVIGATE_HOME",
+        "ACTION_RECONNECT"                 : "Riconnetti",
+        "ACTION_SAVE_FILE"                 : "@:APP.ACTION_SAVE",
+        "ACTION_UPLOAD_FILES"              : "Carica un file",
+
+        "DIALOG_HEADER_CONNECTING"       : "Connessione in corso",
+        "DIALOG_HEADER_CONNECTION_ERROR" : "Errore di Connessione",
+        "DIALOG_HEADER_DISCONNECTED"     : "Disconnesso",
+
+        "ERROR_CLIENT_201"     : "La connessione è stata chiusa perchè il server è sovraccarico. Attendi qualche minuto e riprova a collegarti.",
+        "ERROR_CLIENT_202"     : "Il server Guacamole ha chiuso la connessione perchè il desktop remoto risponde troppo lentamente. Per favore riprova o contatta il tuo amministratore di sistema.",
+        "ERROR_CLIENT_203"     : "Il desktop remoto ha riscontrato un errore ed ha chiuso la connessione. Per favore riprova o contatta il tuo amministratore di sistema.",
+        "ERROR_CLIENT_205"     : "Questa connessione è stata chiusa a causa di un conflitto con un'altra connessione. Riprova tra qualche minuto.",
+        "ERROR_CLIENT_301"     : "Autenticazione fallita. Prova ad effettaure nuovamente la connessione.",
+        "ERROR_CLIENT_303"     : "Non hai i permessi per accedere a questa connessione. Se ne hai bisogno, chiedi ai tuoi amministratori di sistema di aggiungerti agli utenti abilitati.",
+        "ERROR_CLIENT_308"     : "Il server Guacamole ha chiuso la connessione perchè il tuo browser non ha risposto per troppo tempo e sembrava essersi disconnesso. Solitamente la causa è un problema di rete, ad esempio un segnale wifi instabile o una connesisone molto lenta. Controlla la tua rete e riprova.",
+        "ERROR_CLIENT_31D"     : "Il server Guacamole rifiuta l'accesso alla connessione perchè hai raggiunto il limite massimo di connessioni simultanee per singolo utente. Chiudi una o più connessioni e riprova.",
+        "ERROR_CLIENT_DEFAULT" : "Si è verificato un errore interno sul server Guacamole, e la connessione è stata terminata. Se il problema persiste avvisa il tuo amministratore di sistema o controlla i file di log.",
+
+        "ERROR_TUNNEL_201"     : "Il server Guacamole ha rifiutato questa connessione perchè ci sono troppe connessioni attive. Attendi qualche minuto e riprova.",
+        "ERROR_TUNNEL_202"     : "La connessione è stata chiusa dal server is taking too long to respond.Solitamente la causa è un problema di rete, ad esempio un segnale wifi instabile o una connesisone molto lenta. Controlla la tua rete e riprova.",
+        "ERROR_TUNNEL_203"     : "Si è verificato un errore sul server e la connessione è stata chiusa. Riprova o contatta il tuo amministratore di sistema.",
+        "ERROR_TUNNEL_204"     : "La connessione richiesta non esiste. Controlla il nome della connessione e riprova. Grazie.",
+        "ERROR_TUNNEL_205"     : "Questa connessione è già in uso, non sono possibili accessi concorrenti. Riprova più tardi. Grazie.",
+        "ERROR_TUNNEL_301"     : "Non hai i permessi per accedere a questa connessione perchè non hai effettuato il login. Inserisci nome utente e password e riprova.",
+        "ERROR_TUNNEL_303"     : "Non hai i permessi per accedere a questa connessione. Se ne hai bisogno, chiedi ai tuoi amministratori di sistema di aggiungerti agli utenti abilitati.",
+        "ERROR_TUNNEL_308"     : "Il server Guacamole ha chiuso la connessione perchè il tuo browser non ha risposto per troppo tempo e sembrava essersi disconnesso. Solitamente la causa è un problema di rete, ad esempio un segnale wifi instabile o una connesisone molto lenta. Controlla la tua rete e riprova.",
+        "ERROR_TUNNEL_31D"     : "Il server Guacamole rifiuta l'accesso alla connessione perchè hai raggiunto il limite massimo di connessioni simultanee per singolo utente. Chiudi una o più connessioni e riprova. Grazie.",
+        "ERROR_TUNNEL_DEFAULT" : "Si è verificato un errore interno sul server Guacamole, e la connessione è stata terminata. Se il problema persiste avvisa il tuo amministratore di sistema o controlla i file di log.",
+
+        "ERROR_UPLOAD_100"     : "Il trasferimento di file non è supportato o non è attivo. Contatta il tuo amministratore di sistema.",
+        "ERROR_UPLOAD_201"     : "Ci sono troppi file in coda per il trasferimento. Attendi che siano completati i trasferimenti in atto e riprova.",
+        "ERROR_UPLOAD_202"     : "Trasferimento annullato: il desktop remoto risponde troppo lentamente. Per favore riprova o contatta il tuo amministratore di sistema.",
+        "ERROR_UPLOAD_203"     : "Il desktop remoto ha incontrato un errore durante il trasferimento. Per favore riprova o contatta il tuo amministratore di sistema.",
+        "ERROR_UPLOAD_204"     : "La destinazione del file non esiste. Controlla che la destinazione esista e riprova.",
+        "ERROR_UPLOAD_205"     : "La destinazione del file non è bloccata. Per favore attendi che ogni processo in corso sia termianto e riprova.",
+        "ERROR_UPLOAD_301"     : "Non hai i permessi per caricare i file perchè non hai fatto il login. Inserisci nome utente e password e riprova.",
+        "ERROR_UPLOAD_303"     : "Non hai i permessi per caricare i file. Se ti serve questa funzionalità contatta il tuo amministratore di sistema.",
+        "ERROR_UPLOAD_308"     : "Il trasferimento di file si è fermato. Solitamente la causa è un problema di rete, ad esempio un segnale wifi instabile o una connesisone molto lenta. Controlla la tua rete e riprova.",
+        "ERROR_UPLOAD_31D"     : "Ci sono troppi file in coda per il trasferimento. Attendi che siano completati i trasferimenti in atto e riprova.",
+        "ERROR_UPLOAD_DEFAULT" : "Si è verificato un errore sul server e la connessione è stata chiusa. Riprova o contatta il tuo amministratore di sistema.",
+
+        "HELP_CLIPBOARD"           : "Il testo copiato/tagliato appare qui. I cambiamenti effettuati al testo qui sotto saranno riportati negli appunti remoti.",
+        "HELP_INPUT_METHOD_NONE"   : "Non c'è nessun metodo di immissione. L'input da tastiera è accettato da una tastiera fisica connessa.",
+        "HELP_INPUT_METHOD_OSK"    : "Mostra e accetta input dalla tastiera su schermo. La tastiera su schermo ti permette di scrivere combinazioni di tasti altrimenti mipossibli (ad esempio Ctrl-Alt-Canc).",
+        "HELP_INPUT_METHOD_TEXT"   : "Abilita la battitura di testo ed emula gli eventi da tastiera basati sul testo battuto. Questo è necessario per tablet e smartphone che non hanno una tastiera fisica.",
+        "HELP_MOUSE_MODE"          : "Determina come si deve comportare il mouse remoto in base al touch.",
+        "HELP_MOUSE_MODE_ABSOLUTE" : "Tap to click. Il click è sostituito dal tocco.",
+        "HELP_MOUSE_MODE_RELATIVE" : "Trascina il dito per muovere il puntatore del mouse e fai tap al posto del click. Il click sarà effettuato dove si trova il puntatore.",
+
+        "INFO_NO_FILE_TRANSFERS" : "Nessun trasferimento di file.",
+
+        "NAME_INPUT_METHOD_NONE"   : "Nessuno",
+        "NAME_INPUT_METHOD_OSK"    : "Tastiera su schermo",
+        "NAME_INPUT_METHOD_TEXT"   : "Inserimento Testo",
+        "NAME_KEY_CTRL"            : "Ctrl",
+        "NAME_KEY_ALT"             : "Alt",
+        "NAME_KEY_ESC"             : "Esc",
+        "NAME_KEY_TAB"             : "Tab",
+        "NAME_MOUSE_MODE_ABSOLUTE" : "Touchscreen",
+        "NAME_MOUSE_MODE_RELATIVE" : "Touchpad",
+
+        "SECTION_HEADER_CLIPBOARD"      : "Appunti",
+        "SECTION_HEADER_DEVICES"        : "Dispositivi",
+        "SECTION_HEADER_DISPLAY"        : "Schermo",
+        "SECTION_HEADER_FILE_TRANSFERS" : "Trasferimento file",
+        "SECTION_HEADER_INPUT_METHOD"   : "Metodo di input",
+        "SECTION_HEADER_MOUSE_MODE"     : "Modalità di emulazione del mouse",
+
+        "TEXT_ZOOM_AUTO_FIT"              : "Automatically fit to browser window",
+        "TEXT_CLIENT_STATUS_IDLE"         : "Inattivo.",
+        "TEXT_CLIENT_STATUS_CONNECTING"   : "Connessione in corso a Guacamole...",
+        "TEXT_CLIENT_STATUS_DISCONNECTED" : "Sei stato disconnesso.",
+        "TEXT_CLIENT_STATUS_WAITING"      : "Connesso a Guacamole. Attendi una risposta...",
+        "TEXT_RECONNECT_COUNTDOWN"        : "Riconnessione in {REMAINING} {REMAINING, plural, one{second} other{seconds}}...",
+        "TEXT_FILE_TRANSFER_PROGRESS"     : "{PROGRESS} {UNIT, select, b{B} kb{KB} mb{MB} gb{GB} other{}}",
+
+        "URL_OSK_LAYOUT" : "layouts/it-it-qwerty.json"
+
+    },
+
+    "FORM" : {
+
+        "HELP_SHOW_PASSWORD" : "Click to show password",
+        "HELP_HIDE_PASSWORD" : "Click to hide password"
+
+    },
+
+    "HOME" : {
+
+        "FIELD_PLACEHOLDER_FILTER" : "@:APP.FIELD_PLACEHOLDER_FILTER",
+
+        "INFO_ACTIVE_USER_COUNT" : "@:APP.INFO_ACTIVE_USER_COUNT",
+
+        "INFO_NO_RECENT_CONNECTIONS" : "Nessuna connessione recente.",
+        
+        "PASSWORD_CHANGED" : "Password modificata.",
+
+        "SECTION_HEADER_ALL_CONNECTIONS"    : "Tutte le Connessioni",
+        "SECTION_HEADER_RECENT_CONNECTIONS" : "Connessioni Recenti"
+
+    },
+
+    "LOGIN": {
+
+        "ACTION_ACKNOWLEDGE" : "@:APP.ACTION_ACKNOWLEDGE",
+        "ACTION_CONTINUE"    : "@:APP.ACTION_CONTINUE",
+        "ACTION_LOGIN"       : "@:APP.ACTION_LOGIN",
+
+        "DIALOG_HEADER_ERROR" : "@:APP.DIALOG_HEADER_ERROR",
+
+        "ERROR_INVALID_LOGIN" : "Nome utente e/o password errati.",
+
+        "FIELD_HEADER_USERNAME" : "Username",
+        "FIELD_HEADER_PASSWORD" : "Password"
+
+    },
+
+    "MANAGE_CONNECTION" : {
+
+        "ACTION_ACKNOWLEDGE"          : "@:APP.ACTION_ACKNOWLEDGE",
+        "ACTION_CANCEL"               : "@:APP.ACTION_CANCEL",
+        "ACTION_CLONE"                : "@:APP.ACTION_CLONE",
+        "ACTION_DELETE"               : "@:APP.ACTION_DELETE",
+        "ACTION_SAVE"                 : "@:APP.ACTION_SAVE",
+
+        "DIALOG_HEADER_CONFIRM_DELETE" : "Elimina connessione",
+        "DIALOG_HEADER_ERROR"          : "@:APP.DIALOG_HEADER_ERROR",
+
+        "FIELD_HEADER_LOCATION" : "Location:",
+        "FIELD_HEADER_NAME"     : "Name:",
+        "FIELD_HEADER_PROTOCOL" : "Protocol:",
+
+        "FORMAT_HISTORY_START" : "@:APP.FORMAT_DATE_TIME_PRECISE",
+
+        "INFO_CONNECTION_DURATION_UNKNOWN" : "--",
+        "INFO_CONNECTION_ACTIVE_NOW"       : "Attiva adesso",
+        "INFO_CONNECTION_NOT_USED"         : "Questa connessione non è mai stata usata.",
+
+        "SECTION_HEADER_EDIT_CONNECTION" : "Modifica Connessione",
+        "SECTION_HEADER_HISTORY"         : "Cronologia di utilizzo",
+        "SECTION_HEADER_PARAMETERS"      : "Parametri",
+
+        "TABLE_HEADER_HISTORY_USERNAME" : "Username",
+        "TABLE_HEADER_HISTORY_START"    : "Start Time",
+        "TABLE_HEADER_HISTORY_DURATION" : "Durata",
+
+        "TEXT_CONFIRM_DELETE"   : "Le Connessioni non possono essere ripristinate dopo la loro eliminazione. Sei sicuro di volere eliminare questa connessione?"
+
+    },
+
+    "MANAGE_CONNECTION_GROUP" : {
+
+        "ACTION_ACKNOWLEDGE"   : "@:APP.ACTION_ACKNOWLEDGE",
+        "ACTION_CANCEL"        : "@:APP.ACTION_CANCEL",
+        "ACTION_DELETE"        : "@:APP.ACTION_DELETE",
+        "ACTION_SAVE"          : "@:APP.ACTION_SAVE",
+
+        "DIALOG_HEADER_CONFIRM_DELETE" : "Elimina Gruppo di Connesisoni",
+        "DIALOG_HEADER_ERROR"          : "@:APP.DIALOG_HEADER_ERROR",
+
+        "FIELD_HEADER_LOCATION" : "Location:",
+        "FIELD_HEADER_NAME"     : "Nome:",
+        "FIELD_HEADER_TYPE"     : "Tipo:",
+
+        "NAME_TYPE_BALANCING"       : "Bilanciamento",
+        "NAME_TYPE_ORGANIZATIONAL"  : "Organizzazione",
+
+        "SECTION_HEADER_EDIT_CONNECTION_GROUP" : "Modifica Gruppo di Connessioni",
+
+        "TEXT_CONFIRM_DELETE" : "Un Gruppo di Connessioni non può essere ripristinato dopo l'eliminazione. Sei sicuro di volere eliminare questa gruppo?"
+
+    },
+
+    "MANAGE_USER" : {
+
+        "ACTION_ACKNOWLEDGE"   : "@:APP.ACTION_ACKNOWLEDGE",
+        "ACTION_CANCEL"        : "@:APP.ACTION_CANCEL",
+        "ACTION_DELETE"        : "@:APP.ACTION_DELETE",
+        "ACTION_SAVE"          : "@:APP.ACTION_SAVE",
+
+        "DIALOG_HEADER_CONFIRM_DELETE" : "Elimina utente",
+        "DIALOG_HEADER_ERROR"          : "@:APP.DIALOG_HEADER_ERROR",
+
+        "ERROR_PASSWORD_MISMATCH" : "@:APP.ERROR_PASSWORD_MISMATCH",
+
+        "FIELD_HEADER_ADMINISTER_SYSTEM"             : "Amministartore di sistema:",
+        "FIELD_HEADER_CHANGE_OWN_PASSWORD"           : "Cambia la tua password:",
+        "FIELD_HEADER_CREATE_NEW_USERS"              : "Crea un utente:",
+        "FIELD_HEADER_CREATE_NEW_CONNECTIONS"        : "Crea una connessione:",
+        "FIELD_HEADER_CREATE_NEW_CONNECTION_GROUPS"  : "Crea un ruppo di connessioni:",
+        "FIELD_HEADER_PASSWORD"                      : "@:APP.FIELD_HEADER_PASSWORD",
+        "FIELD_HEADER_PASSWORD_AGAIN"                : "@:APP.FIELD_HEADER_PASSWORD_AGAIN",
+        "FIELD_HEADER_USERNAME"                      : "Username:",
+        
+        "FIELD_PLACEHOLDER_FILTER" : "@:APP.FIELD_PLACEHOLDER_FILTER",
+
+        "SECTION_HEADER_CONNECTIONS" : "Connessioni",
+        "SECTION_HEADER_EDIT_USER"   : "Modifica Utente",
+        "SECTION_HEADER_PERMISSIONS" : "Permessi",
+
+        "TEXT_CONFIRM_DELETE" : "L'utente non può essere ripristinato dopo l'eliminazione. Sei sicuro di volere eliminare l'utente?"
+
+    },
+    
+    "PROTOCOL_RDP" : {
+
+        "FIELD_HEADER_COLOR_DEPTH"     : "Color depth:",
+        "FIELD_HEADER_CONSOLE"         : "Administrator console:",
+        "FIELD_HEADER_CONSOLE_AUDIO"   : "Support audio in console:",
+        "FIELD_HEADER_CLIENT_NAME"     : "Client name:",
+        "FIELD_HEADER_DISABLE_AUDIO"   : "Disable audio:",
+        "FIELD_HEADER_DISABLE_AUTH"    : "Disable authentication:",
+        "FIELD_HEADER_DOMAIN"          : "Domain:",
+        "FIELD_HEADER_DPI"             : "Resolution (DPI):",
+        "FIELD_HEADER_DRIVE_PATH"      : "Drive path:",
+        "FIELD_HEADER_ENABLE_DESKTOP_COMPOSITION" : "Enable desktop composition (Aero):",
+        "FIELD_HEADER_ENABLE_DRIVE"               : "Enable drive:",
+        "FIELD_HEADER_ENABLE_FONT_SMOOTHING"      : "Enable font smoothing (ClearType):",
+        "FIELD_HEADER_ENABLE_FULL_WINDOW_DRAG"    : "Enable full-window drag:",
+        "FIELD_HEADER_ENABLE_MENU_ANIMATIONS"     : "Enable menu animations:",
+        "FIELD_HEADER_ENABLE_PRINTING"            : "Enable printing:",
+        "FIELD_HEADER_ENABLE_SFTP"                : "Enable SFTP:",
+        "FIELD_HEADER_ENABLE_THEMING"             : "Enable theming:",
+        "FIELD_HEADER_ENABLE_WALLPAPER"           : "Enable wallpaper:",
+        "FIELD_HEADER_HEIGHT"          : "Height:",
+        "FIELD_HEADER_HOSTNAME"        : "Hostname:",
+        "FIELD_HEADER_IGNORE_CERT"     : "Ignore server certificate:",
+        "FIELD_HEADER_INITIAL_PROGRAM" : "Initial program:",
+        "FIELD_HEADER_PASSWORD"        : "Password:",
+        "FIELD_HEADER_PORT"            : "Port:",
+        "FIELD_HEADER_REMOTE_APP_ARGS" : "Parameters:",
+        "FIELD_HEADER_REMOTE_APP_DIR"  : "Working directory:",
+        "FIELD_HEADER_REMOTE_APP"      : "Program:",
+        "FIELD_HEADER_SECURITY"        : "Security mode:",
+        "FIELD_HEADER_SERVER_LAYOUT"   : "Keyboard layout:",
+        "FIELD_HEADER_SFTP_HOSTNAME"   : "Hostname:",
+        "FIELD_HEADER_SFTP_PASSPHRASE" : "Passphrase:",
+        "FIELD_HEADER_SFTP_PASSWORD"   : "Password:",
+        "FIELD_HEADER_SFTP_PORT"       : "Port:",
+        "FIELD_HEADER_SFTP_PRIVATE_KEY" : "Private key:",
+        "FIELD_HEADER_SFTP_USERNAME"   : "Username:",        
+        "FIELD_HEADER_STATIC_CHANNELS" : "Static channel names:",
+        "FIELD_HEADER_USERNAME"        : "Username:",
+        "FIELD_HEADER_WIDTH"           : "Width:",        
+
+        "FIELD_OPTION_COLOR_DEPTH_16"    : "Low color (16-bit)",
+        "FIELD_OPTION_COLOR_DEPTH_24"    : "True color (24-bit)",
+        "FIELD_OPTION_COLOR_DEPTH_32"    : "True color (32-bit)",
+        "FIELD_OPTION_COLOR_DEPTH_8"     : "256 color",
+        "FIELD_OPTION_COLOR_DEPTH_EMPTY" : "",
+
+        "FIELD_OPTION_SECURITY_ANY"   : "Any",
+        "FIELD_OPTION_SECURITY_EMPTY" : "",
+        "FIELD_OPTION_SECURITY_NLA"   : "NLA (Network Level Authentication)",
+        "FIELD_OPTION_SECURITY_RDP"   : "RDP encryption",
+        "FIELD_OPTION_SECURITY_TLS"   : "TLS encryption",
+
+        "FIELD_OPTION_SERVER_LAYOUT_DE_DE_QWERTZ" : "German (Qwertz)",
+        "FIELD_OPTION_SERVER_LAYOUT_EMPTY"        : "",
+        "FIELD_OPTION_SERVER_LAYOUT_EN_US_QWERTY" : "US English (Qwerty)",
+        "FIELD_OPTION_SERVER_LAYOUT_FAILSAFE"     : "Unicode",
+        "FIELD_OPTION_SERVER_LAYOUT_FR_FR_AZERTY" : "French (Azerty)",
+        "FIELD_OPTION_SERVER_LAYOUT_IT_IT_QWERTY" : "Italian (Qwerty)",
+        "FIELD_OPTION_SERVER_LAYOUT_SV_SE_QWERTY" : "Swedish (Qwerty)",
+
+        "NAME" : "RDP",
+
+        "SECTION_HEADER_AUTHENTICATION"     : "Authentication",
+        "SECTION_HEADER_BASIC_PARAMETERS"   : "Basic Settings",
+        "SECTION_HEADER_DEVICE_REDIRECTION" : "Device Redirection",
+        "SECTION_HEADER_DISPLAY"            : "Display",
+        "SECTION_HEADER_NETWORK"            : "Network",
+        "SECTION_HEADER_PERFORMANCE"        : "Performance",
+        "SECTION_HEADER_REMOTEAPP"          : "RemoteApp",
+        "SECTION_HEADER_SFTP"               : "SFTP"
+
+    },
+
+    "PROTOCOL_SSH" : {
+
+        "FIELD_HEADER_FONT_NAME"   : "Font name:",
+        "FIELD_HEADER_FONT_SIZE"   : "Font size:",
+        "FIELD_HEADER_ENABLE_SFTP" : "Enable SFTP:",
+        "FIELD_HEADER_HOSTNAME"    : "Hostname:",
+        "FIELD_HEADER_USERNAME"    : "Username:",
+        "FIELD_HEADER_PASSWORD"    : "Password:",
+        "FIELD_HEADER_PASSPHRASE"  : "Passphrase:",
+        "FIELD_HEADER_PORT"        : "Port:",
+        "FIELD_HEADER_PRIVATE_KEY" : "Private key:",
+
+        "FIELD_OPTION_FONT_SIZE_8"     : "8",
+        "FIELD_OPTION_FONT_SIZE_9"     : "9",
+        "FIELD_OPTION_FONT_SIZE_10"    : "10",
+        "FIELD_OPTION_FONT_SIZE_11"    : "11",
+        "FIELD_OPTION_FONT_SIZE_12"    : "12",
+        "FIELD_OPTION_FONT_SIZE_14"    : "14",
+        "FIELD_OPTION_FONT_SIZE_18"    : "18",
+        "FIELD_OPTION_FONT_SIZE_24"    : "24",
+        "FIELD_OPTION_FONT_SIZE_30"    : "30",
+        "FIELD_OPTION_FONT_SIZE_36"    : "36",
+        "FIELD_OPTION_FONT_SIZE_48"    : "48",
+        "FIELD_OPTION_FONT_SIZE_60"    : "60",
+        "FIELD_OPTION_FONT_SIZE_72"    : "72",
+        "FIELD_OPTION_FONT_SIZE_96"    : "96",
+        "FIELD_OPTION_FONT_SIZE_EMPTY" : "",
+
+        "NAME" : "SSH",
+
+        "SECTION_HEADER_AUTHENTICATION" : "Authentication",
+        "SECTION_HEADER_DISPLAY"        : "Display",
+        "SECTION_HEADER_NETWORK"        : "Network",
+        "SECTION_HEADER_SFTP"           : "SFTP"
+
+    },
+
+    "PROTOCOL_TELNET" : {
+
+        "FIELD_HEADER_FONT_NAME"      : "Font name:",
+        "FIELD_HEADER_FONT_SIZE"      : "Font size:",
+        "FIELD_HEADER_HOSTNAME"       : "Hostname:",
+        "FIELD_HEADER_USERNAME"       : "Username:",
+        "FIELD_HEADER_PASSWORD"       : "Password:",
+        "FIELD_HEADER_PASSWORD_REGEX" : "Password regular expression:",
+        "FIELD_HEADER_PORT"           : "Port:",
+
+        "FIELD_OPTION_FONT_SIZE_8"     : "8",
+        "FIELD_OPTION_FONT_SIZE_9"     : "9",
+        "FIELD_OPTION_FONT_SIZE_10"    : "10",
+        "FIELD_OPTION_FONT_SIZE_11"    : "11",
+        "FIELD_OPTION_FONT_SIZE_12"    : "12",
+        "FIELD_OPTION_FONT_SIZE_14"    : "14",
+        "FIELD_OPTION_FONT_SIZE_18"    : "18",
+        "FIELD_OPTION_FONT_SIZE_24"    : "24",
+        "FIELD_OPTION_FONT_SIZE_30"    : "30",
+        "FIELD_OPTION_FONT_SIZE_36"    : "36",
+        "FIELD_OPTION_FONT_SIZE_48"    : "48",
+        "FIELD_OPTION_FONT_SIZE_60"    : "60",
+        "FIELD_OPTION_FONT_SIZE_72"    : "72",
+        "FIELD_OPTION_FONT_SIZE_96"    : "96",
+        "FIELD_OPTION_FONT_SIZE_EMPTY" : "",
+
+        "NAME" : "Telnet",
+
+        "SECTION_HEADER_AUTHENTICATION" : "Authentication",
+        "SECTION_HEADER_DISPLAY"        : "Display",
+        "SECTION_HEADER_NETWORK"        : "Network"
+
+    },
+
+    "PROTOCOL_VNC" : {
+
+        "FIELD_HEADER_AUDIO_SERVERNAME" : "Audio server name:",
+        "FIELD_HEADER_COLOR_DEPTH"      : "Color depth:",
+        "FIELD_HEADER_CURSOR"           : "Cursor:",
+        "FIELD_HEADER_DEST_HOST"        : "Destination host:",
+        "FIELD_HEADER_DEST_PORT"        : "Destination port:",
+        "FIELD_HEADER_ENABLE_AUDIO"     : "Enable audio:",
+        "FIELD_HEADER_ENABLE_SFTP"      : "Enable SFTP:",
+        "FIELD_HEADER_HOSTNAME"         : "Hostname:",
+        "FIELD_HEADER_PASSWORD"         : "Password:",
+        "FIELD_HEADER_PORT"             : "Port:",
+        "FIELD_HEADER_READ_ONLY"        : "Read-only:",
+        "FIELD_HEADER_SFTP_HOSTNAME"    : "Hostname:",
+        "FIELD_HEADER_SFTP_PASSPHRASE"  : "Passphrase:",
+        "FIELD_HEADER_SFTP_PASSWORD"    : "Password:",
+        "FIELD_HEADER_SFTP_PORT"        : "Port:",
+        "FIELD_HEADER_SFTP_PRIVATE_KEY" : "Private key:",
+        "FIELD_HEADER_SFTP_USERNAME"    : "Username:",
+        "FIELD_HEADER_SWAP_RED_BLUE"    : "Swap red/blue components:",
+
+        "FIELD_OPTION_COLOR_DEPTH_8"     : "256 color",
+        "FIELD_OPTION_COLOR_DEPTH_16"    : "Low color (16-bit)",
+        "FIELD_OPTION_COLOR_DEPTH_24"    : "True color (24-bit)",
+        "FIELD_OPTION_COLOR_DEPTH_32"    : "True color (32-bit)",
+        "FIELD_OPTION_COLOR_DEPTH_EMPTY" : "",
+
+        "FIELD_OPTION_CURSOR_EMPTY"  : "",
+        "FIELD_OPTION_CURSOR_LOCAL"  : "Local",
+        "FIELD_OPTION_CURSOR_REMOTE" : "Remote",
+
+        "NAME" : "VNC",
+
+        "SECTION_HEADER_AUDIO"          : "Audio",
+        "SECTION_HEADER_AUTHENTICATION" : "Authentication",
+        "SECTION_HEADER_CLIPBOARD"      : "Appunti",
+        "SECTION_HEADER_DISPLAY"        : "Display",
+        "SECTION_HEADER_NETWORK"        : "Network",
+        "SECTION_HEADER_REPEATER"       : "VNC Repeater",
+        "SECTION_HEADER_SFTP"           : "SFTP"
+
+    },
+
+    "SETTINGS" : {
+
+        "SECTION_HEADER_SETTINGS" : "Settings"
+
+    },
+
+    "SETTINGS_CONNECTIONS" : {
+
+        "ACTION_ACKNOWLEDGE"          : "@:APP.ACTION_ACKNOWLEDGE",
+        "ACTION_NEW_CONNECTION"       : "Nuova Connessione",
+        "ACTION_NEW_CONNECTION_GROUP" : "Nuovo Gruppo",
+
+        "DIALOG_HEADER_ERROR" : "@:APP.DIALOG_HEADER_ERROR",
+
+        "FIELD_PLACEHOLDER_FILTER" : "@:APP.FIELD_PLACEHOLDER_FILTER",
+
+        "HELP_CONNECTIONS"   : "Fai click o tap sulla connessione qui sotto per gestire quella connessione. In base al tuo livello di accesso, le connessioni possono essere craete, eliminate, e le relative proprietà (protocol, hostname, port, etc.) possono essere cambiate.",
+        
+        "INFO_ACTIVE_USER_COUNT" : "@:APP.INFO_ACTIVE_USER_COUNT",
+
+        "SECTION_HEADER_CONNECTIONS"     : "Connessioni"
+
+    },
+
+    "SETTINGS_CONNECTION_HISTORY" : {
+
+        "FIELD_PLACEHOLDER_FILTER" : "@:APP.FIELD_PLACEHOLDER_FILTER",
+
+        "FORMAT_DATE" : "@:APP.FORMAT_DATE_TIME_PRECISE",
+
+        "INFO_CONNECTION_DURATION_UNKNOWN" : "--",
+
+        "TABLE_HEADER_SESSION_CONNECTION_NAME" : "Nome della connessione",
+        "TABLE_HEADER_SESSION_STARTDATE"       : "Start Time",
+        "TABLE_HEADER_SESSION_DURATION"        : "Durata",
+        "TABLE_HEADER_SESSION_USERNAME"        : "Username"
+
+    },
+
+    "SETTINGS_PREFERENCES" : {
+
+        "ACTION_ACKNOWLEDGE"        : "@:APP.ACTION_ACKNOWLEDGE",
+        "ACTION_CANCEL"             : "@:APP.ACTION_CANCEL",
+        "ACTION_UPDATE_PASSWORD"    : "@:APP.ACTION_UPDATE_PASSWORD",
+
+        "DIALOG_HEADER_ERROR"    : "@:APP.DIALOG_HEADER_ERROR",
+
+        "ERROR_PASSWORD_BLANK"    : "@:APP.ERROR_PASSWORD_BLANK",
+        "ERROR_PASSWORD_MISMATCH" : "@:APP.ERROR_PASSWORD_MISMATCH",
+
+        "FIELD_HEADER_LANGUAGE"           : "Lingua dell'interfaccia:",
+        "FIELD_HEADER_PASSWORD"           : "Password:",
+        "FIELD_HEADER_PASSWORD_OLD"       : "Password Attuale:",
+        "FIELD_HEADER_PASSWORD_NEW"       : "Nuova Password:",
+        "FIELD_HEADER_PASSWORD_NEW_AGAIN" : "Conferma Nuova Password:",
+        "FIELD_HEADER_USERNAME"           : "Username:",
+        
+        "HELP_DEFAULT_INPUT_METHOD" : "The default input method determines how keyboard events are received by Guacamole. Changing this setting may be necessary when using a mobile device, or when typing through an IME. This setting can be overridden on a per-connection basis within the Guacamole menu.",
+        "HELP_DEFAULT_MOUSE_MODE"   : "The default mouse emulation mode determines how the remote mouse will behave in new connections with respect to touches. This setting can be overridden on a per-connection basis within the Guacamole menu.",
+        "HELP_INPUT_METHOD_NONE"    : "@:CLIENT.HELP_INPUT_METHOD_NONE",
+        "HELP_INPUT_METHOD_OSK"     : "@:CLIENT.HELP_INPUT_METHOD_OSK",
+        "HELP_INPUT_METHOD_TEXT"    : "@:CLIENT.HELP_INPUT_METHOD_TEXT",
+        "HELP_LANGUAGE"             : "Select a different language below to change the language of all text within Guacamole. Available choices will depend on which languages are installed.",
+        "HELP_MOUSE_MODE_ABSOLUTE"  : "@:CLIENT.HELP_MOUSE_MODE_ABSOLUTE",
+        "HELP_MOUSE_MODE_RELATIVE"  : "@:CLIENT.HELP_MOUSE_MODE_RELATIVE",
+        "HELP_UPDATE_PASSWORD"      : "Se desideri cambiare la tua password, inserisci la tua password attuale e sotto scriti quella che desideri come nuova password, clicca \"Modifica Password\". La modifica avrà effetto immediato.",
+
+        "INFO_PASSWORD_CHANGED" : "Password Modificata.",
+
+        "NAME_INPUT_METHOD_NONE" : "@:CLIENT.NAME_INPUT_METHOD_NONE",
+        "NAME_INPUT_METHOD_OSK"  : "@:CLIENT.NAME_INPUT_METHOD_OSK",
+        "NAME_INPUT_METHOD_TEXT" : "@:CLIENT.NAME_INPUT_METHOD_TEXT",
+
+        "SECTION_HEADER_DEFAULT_INPUT_METHOD" : "Default Input Method",
+        "SECTION_HEADER_DEFAULT_MOUSE_MODE"   : "Default Mouse Emulation Mode",
+        "SECTION_HEADER_UPDATE_PASSWORD"      : "Modifica Password"
+
+    },
+
+    "SETTINGS_USERS" : {
+
+        "ACTION_ACKNOWLEDGE"   : "@:APP.ACTION_ACKNOWLEDGE",
+        "ACTION_NEW_USER"      : "Nuovo utente",
+
+        "DIALOG_HEADER_ERROR" : "@:APP.DIALOG_HEADER_ERROR",
+
+        "FIELD_PLACEHOLDER_FILTER" : "@:APP.FIELD_PLACEHOLDER_FILTER",
+
+        "HELP_USERS" : "Click or tap on a user below to manage that user. Depending on your access level, users can be added and deleted, and their passwords can be changed.",
+
+        "SECTION_HEADER_USERS"       : "Utenti"
+
+    },
+    
+    "SETTINGS_SESSIONS" : {
+        
+        "ACTION_ACKNOWLEDGE" : "@:APP.ACTION_ACKNOWLEDGE",
+        "ACTION_CANCEL"      : "@:APP.ACTION_CANCEL",
+        "ACTION_DELETE"      : "Termian Sessione",
+        
+        "DIALOG_HEADER_CONFIRM_DELETE" : "Termina Sessione",
+        "DIALOG_HEADER_ERROR"          : "@:APP.DIALOG_HEADER_ERROR",
+        
+        "FIELD_PLACEHOLDER_FILTER" : "@:APP.FIELD_PLACEHOLDER_FILTER",
+        
+        "FORMAT_STARTDATE" : "@:APP.FORMAT_DATE_TIME_PRECISE",
+
+        "HELP_SESSIONS" : "All currently-active Guacamole sessions are listed here. If you wish to kill one or more sessions, check the box next to those sessions and click \"Kill Sessions\". Killing a session will immediately disconnect the user from the associated connection.",
+        
+        "INFO_NO_SESSIONS" : "Nessuna sessione attiva",
+
+        "SECTION_HEADER_SESSIONS" : "Sessioni Attive",
+        
+        "TABLE_HEADER_SESSION_USERNAME"        : "Username",
+        "TABLE_HEADER_SESSION_STARTDATE"       : "Attivo da",
+        "TABLE_HEADER_SESSION_REMOTEHOST"      : "Remote host",
+        "TABLE_HEADER_SESSION_CONNECTION_NAME" : "Nome della connessione",
+        
+        "TEXT_CONFIRM_DELETE" : "Sei sicuro di voler termianre la sessione selezionata? L'utente che sta utilizzando questa sessione sarà immediatamente disconnesso."
+
+    },
+
+    "USER_MENU" : {
+
+        "ACTION_LOGOUT"             : "@:APP.ACTION_LOGOUT",
+        "ACTION_MANAGE_CONNECTIONS" : "@:APP.ACTION_MANAGE_CONNECTIONS",
+        "ACTION_MANAGE_PREFERENCES" : "@:APP.ACTION_MANAGE_PREFERENCES",
+        "ACTION_MANAGE_SESSIONS"    : "@:APP.ACTION_MANAGE_SESSIONS",
+        "ACTION_MANAGE_SETTINGS"    : "@:APP.ACTION_MANAGE_SETTINGS",
+        "ACTION_MANAGE_USERS"       : "@:APP.ACTION_MANAGE_USERS",
+        "ACTION_NAVIGATE_HOME"      : "@:APP.ACTION_NAVIGATE_HOME"
+
+    }
+
+}
diff --git a/guacamole/src/main/webapp/translations/nl.json b/guacamole/src/main/webapp/translations/nl.json
new file mode 100644
index 0000000..4dbdd8c
--- /dev/null
+++ b/guacamole/src/main/webapp/translations/nl.json
@@ -0,0 +1,625 @@
+{
+    
+    "NAME" : "Nederlands",
+    
+    "APP" : {
+
+        "ACTION_ACKNOWLEDGE"        : "OK",
+        "ACTION_CANCEL"             : "Annuleer",
+        "ACTION_CLONE"              : "Kloon",
+        "ACTION_CONTINUE"           : "Verder",
+        "ACTION_DELETE"             : "Verwijder",
+        "ACTION_DELETE_SESSIONS"    : "Verwijder Sessies",
+        "ACTION_LOGIN"              : "Inloggen",
+        "ACTION_LOGOUT"             : "Uitloggen",
+        "ACTION_MANAGE_CONNECTIONS" : "Verbindingen",
+        "ACTION_MANAGE_PREFERENCES" : "Voorkeuren",
+        "ACTION_MANAGE_SETTINGS"    : "Instellingen",
+        "ACTION_MANAGE_SESSIONS"    : "Actieve Sessies",
+        "ACTION_MANAGE_USERS"       : "Gebruikers",
+        "ACTION_NAVIGATE_BACK"      : "Terug",
+        "ACTION_NAVIGATE_HOME"      : "Home",
+        "ACTION_SAVE"               : "Opslaan",
+        "ACTION_SEARCH"             : "Zoeken",
+        "ACTION_UPDATE_PASSWORD"    : "Wijzig Wachtwoord",
+        "ACTION_VIEW_HISTORY"       : "Gebruikgeschiedenis",
+
+        "DIALOG_HEADER_ERROR" : "Fout",
+
+        "ERROR_PASSWORD_BLANK"    : "Uw wachtwoord mag niet leeg zijn.",
+        "ERROR_PASSWORD_MISMATCH" : "De opgegeven wachtwoorden komen niet overeen.",
+
+        "FIELD_HEADER_PASSWORD"       : "Wachtwoord:",
+        "FIELD_HEADER_PASSWORD_AGAIN" : "Nogmaals uw wachtwoord:",
+
+        "FIELD_PLACEHOLDER_FILTER" : "Filter",
+
+        "FORMAT_DATE_TIME_PRECISE" : "yyyy-MM-dd HH:mm:ss",
+
+        "INFO_ACTIVE_USER_COUNT" : "Op dit moment in gebruik door {USERS} {USERS, plural, one{gebruiker} other{gebruikers}}.",
+
+        "NAME" : "Guacamole ${project.version}",
+
+        "TEXT_HISTORY_DURATION" : "{VALUE} {UNIT, select, second{{VALUE, plural, one{seconde} other{seconden}}} minute{{VALUE, plural, one{minuut} other{minuten}}} hour{{VALUE, plural, one{uur} other{uren}}} day{{VALUE, plural, one{dag} other{dagen}}} other{}}"
+
+    },
+
+    "CLIENT" : {
+
+        "ACTION_ACKNOWLEDGE"               : "@:APP.ACTION_ACKNOWLEDGE",
+        "ACTION_CLEAR_COMPLETED_TRANSFERS" : "Wis lijst",
+        "ACTION_DISCONNECT"                : "Verbreek Verbinding",
+        "ACTION_LOGOUT"                    : "@:APP.ACTION_LOGOUT",
+        "ACTION_NAVIGATE_BACK"             : "@:APP.ACTION_NAVIGATE_BACK",
+        "ACTION_NAVIGATE_HOME"             : "@:APP.ACTION_NAVIGATE_HOME",
+        "ACTION_RECONNECT"                 : "Verbind Opnieuw",
+        "ACTION_SAVE_FILE"                 : "@:APP.ACTION_SAVE",
+        "ACTION_UPLOAD_FILES"              : "Upload Bestanden",
+
+        "DIALOG_HEADER_CONNECTING"       : "Aan Het Verbinden",
+        "DIALOG_HEADER_CONNECTION_ERROR" : "Verbindingsfout",
+        "DIALOG_HEADER_DISCONNECTED"     : "Niet Verbonden",
+
+        "ERROR_CLIENT_201"     : "Deze verbinding is gesloten omdat de server druk is. Wacht een paar minuten en probeer het opnieuw.",
+        "ERROR_CLIENT_202"     : "De Guacamole server heeft de verbinding gesloten omdat het externe bureaublad te lang niet heeft gereageerd. Probeer het altublieft opnieuw of neem contact op met uw systeembeheerder.",
+        "ERROR_CLIENT_203"     : "Er is een fout opgetreden bij de externe bureaublad server en heeft de verbinding gesloten. Probeer het alstublieft opnieuw of neem contact op met uw systeembeheerder.",
+        "ERROR_CLIENT_205"     : "Deze verbinding is beeindigd omdat het conflicteerd met een andere verbinding. Probeer het alstublieft later nog eens.",
+        "ERROR_CLIENT_301"     : "Inloggen is mislukt. Verbind opnieuw en probeer het nog eens.",
+        "ERROR_CLIENT_303"     : "U heeft geen toestemming om deze verbinding tot stand te brengen. Heeft u toegang nodig, vraag dan aan uw systeembeheerder om u toe te voegen aan de lijst van geauthoriseerde gebruikers of controleer uw systeem instellingen.",
+        "ERROR_CLIENT_308"     : "De Guacamole server heeft de sessie gesloten omdat uw browser zo lang niet heeft gereageerd dat het er op leek dat uw verbinding verbroken was. Dit komt in de regel door netwerk problemen zoals een slecht draadloos signaal of een erg langzame netwerk snelheid. Controleer uw netwerk en probeer het opnieuw.",
+        "ERROR_CLIENT_31D"     : "De Guacamole server geeft geen toegang tot deze verbinding omdat u de limiet heeft bereikt van het aantal toegestane gelijktijdige verbindingen door een individuele gebruiker. Sluit u alstublieft een of meer verbindingen en probeer het opnieuw.",
+        "ERROR_CLIENT_DEFAULT" : "Een interne fout is opgetreden op de Guacamole server en de connectie is beeindigd. Als dit probleem zich voor blijft doen, neem dan contact op met uw systeembeheerder of controleer uw systeem logs.",
+
+        "ERROR_TUNNEL_201"     : "De Guacamole server heeft deze verbinding geweigerd omdat er al te veel actieve verbindingen zijn. Wacht u alstublieft een paar minuten en probeer het opnieuw.",
+        "ERROR_TUNNEL_202"     : "De verbinding is gesloten omdat de server niet op tijd reageerd. Dit komt in de regel door netwerk problemen zoals een slecht draadloos signaal of langzame netwerk snelheden. Controleer alstublieft uw netwerk verbinding en probeer het opnieuw of neem contact op met uw systeembeheerde.",
+        "ERROR_TUNNEL_203"     : "Er heeft een fout plaats gevonden op de server en deze heeft de verbinding gesloten. Probeert u het nog eens of neem contact op met uw systeembeheerder.",
+        "ERROR_TUNNEL_204"     : "De gevraagde verbinding bestaat niet. Controleert u altublieft de verbindingsnaam en probeer het nog eens.",
+        "ERROR_TUNNEL_205"     : "Deze verbinding is op dit moment in gebruik en gelijktijdige toegang is niet toegestaan. Probeert u het later nog eens.",
+        "ERROR_TUNNEL_301"     : "U heeft geen toestemming deze verbinding te gebruiken omdat u niet ingelogd bent. Log eerst in en probeer het nog eens.",
+        "ERROR_TUNNEL_303"     : "U heeft geen toestemming deze verbinding te gebruiken. Als u toegang nodig heeft, vraag dan aan de systeembeheerder om u toe te voegen aan de lijst van geauthoriseerde gebruikers of controleer uw systeem instellingen.",
+        "ERROR_TUNNEL_308"     : "De Guacamole server heeft de sessie gesloten omdat uw browser zo lang niet heeft gereageerd dat het er op leek dat uw verbinding verbroken was. Dit komt in de regel door netwerk problemen zoals een slecht draadloos signaal of een erg langzame netwerk snelheid. Controleer uw netwerk en probeer het opnieuw.",
+        "ERROR_TUNNEL_31D"     : "De Guacamole server geeft geen toegang tot deze verbinding omdat u de limiet heeft bereikt van het aantal toegestane gelijktijdige verbindingen door een individuele gebruiker. Sluit u alstublieft een of meer verbindingen en probeer het opnieuw.",
+        "ERROR_TUNNEL_DEFAULT" : "Een interne fout is opgetreden op de Guacamole server en de connectie is beeindigd. Als dit probleem zich voor blijft doen, neem dan contact op met uw systeembeheerder of controleer uw systeem logs.",
+
+        "ERROR_UPLOAD_100"     : "Bestandsoverdracht is ofwel niet ondersteund of niet ingeschakeld. Neem contact op met uw systeembeheerder of kijk in uw systeem logs.",
+        "ERROR_UPLOAD_201"     : "Er worden momenteel te veel bestanden overdragen. Gelieve te wachten tot de bestaande bestandsoverdracht is voltooid, en probeer het opnieuw.",
+        "ERROR_UPLOAD_202"     : "Het bestand kan niet worden overgedragen, omdat de extern bureaublad server te lang niet reageert. Probeer het opnieuw of neem contact op met uw systeembeheerder.",
+        "ERROR_UPLOAD_203"     : "Er is een fout opgetreden op de extern bureaublad server tijdens de overdracht. Probeer het opnieuw of neem contact op met uw systeembeheerder.",
+        "ERROR_UPLOAD_204"     : "De bestemming voor de overdracht van bestanden bestaat niet. Controleer of de bestemming bestaat en probeer het opnieuw.",
+        "ERROR_UPLOAD_205"     : "De bestemming voor de overdracht van bestanden is momenteel vergrendeld. Gelieve te wachten tot alle taken zijn voltooid en probeer het opnieuw.",
+        "ERROR_UPLOAD_301"     : "U heeft geen toestemming om dit bestand te uploaden, omdat u niet ingelogd bent. Gelieve in te loggen en probeer het opnieuw.",
+        "ERROR_UPLOAD_303"     : "U heeft geen toestemming om dit bestand te uploaden. Als u toegang nodig heeft, controleer dan uw systeeminstellingen of neem contact op met uw systeembeheerder.",
+        "ERROR_UPLOAD_308"     : "De bestandsoverdracht is vastgelopen. Dit wordt meestal veroorzaakt door netwerkproblemen, zoals een instabiel draadloos signaal of gewoon een erg trage netwerkverbinding. Controleer uw netwerk en probeer het opnieuw.",
+        "ERROR_UPLOAD_31D"     : "Er worden momenteel te veel bestanden overdragen. Gelieve te wachten tot de bestaande bestandsoverdracht is voltooid, en probeer het opnieuw.",
+        "ERROR_UPLOAD_DEFAULT" : "Er is een interne fout opgetreden op de Guacamole server, en de verbinding is beëindigd. Als het probleem aanhoudt, neem dan contact op met uw systeembeheerder of kijk in uw systeem logs.",
+
+        "HELP_CLIPBOARD"           : "Tekst gekopieerd / geknipt binnen Guacamole zal hier verschijnen. Wijzigingen in onderstaande tekst zal externe klembord beïnvloeden.",
+        "HELP_INPUT_METHOD_NONE"   : "Geen invoer methode gebruiken. Toetsenbord invoer wordt geaccepteerd van een aangesloten, fysiek toetsenbord.",
+        "HELP_INPUT_METHOD_OSK"    : "Weergave en accepteren van invoer via het ingebouwde Guacamole on-screen toetsenbord. Dit toetsenbord op het scherm maakt toetscombinaties mogelijk die anders onmogelijk zijn (zoals Ctrl-Alt-Del).",
+        "HELP_INPUT_METHOD_TEXT"   : "Laat het typen van tekst en het emuleren toetsenbord gebeurtenissen toe gebaseerd op de getypte tekst. Dit is nodig voor apparaten zoals mobiele telefoons die geen fysiek toetsenbord hebben.",
+        "HELP_MOUSE_MODE"          : "Bepaalt hoe de muis met aanraak-klikken omgaat.",
+        "HELP_MOUSE_MODE_ABSOLUTE" : "Tik om te klikken. De klik vindt plaats op de locatie van de tik.",
+        "HELP_MOUSE_MODE_RELATIVE" : "Sleep om de aanwijzer te bewegen en tik om te klikken. De klik vindt plaats op de locatie van de aanwijzer.",
+
+        "INFO_NO_FILE_TRANSFERS" : "Geen bestandsoverdrachten.",
+
+        "NAME_INPUT_METHOD_NONE"   : "Geen",
+        "NAME_INPUT_METHOD_OSK"    : "Scherm toetsenbord",
+        "NAME_INPUT_METHOD_TEXT"   : "Text invoer",
+        "NAME_KEY_CTRL"            : "Ctrl",
+        "NAME_KEY_ALT"             : "Alt",
+        "NAME_KEY_ESC"             : "Esc",
+        "NAME_KEY_TAB"             : "Tab",
+        "NAME_MOUSE_MODE_ABSOLUTE" : "Aanraakscherm",
+        "NAME_MOUSE_MODE_RELATIVE" : "Touchpad",
+
+        "SECTION_HEADER_CLIPBOARD"      : "Klembord",
+        "SECTION_HEADER_DEVICES"        : "Apparaten",
+        "SECTION_HEADER_DISPLAY"        : "Scherm",
+        "SECTION_HEADER_FILE_TRANSFERS" : "Bestandsoverdrachten",
+        "SECTION_HEADER_INPUT_METHOD"   : "Invoer methode",
+        "SECTION_HEADER_MOUSE_MODE"     : "Muis emulatie modus",
+
+        "TEXT_ZOOM_AUTO_FIT"              : "Automatisch aan browser venster aanpassen",
+        "TEXT_CLIENT_STATUS_IDLE"         : "Inactief.",
+        "TEXT_CLIENT_STATUS_CONNECTING"   : "Verbinden met Guacamole...",
+        "TEXT_CLIENT_STATUS_DISCONNECTED" : "De verbinding is verbroken.",
+        "TEXT_CLIENT_STATUS_WAITING"      : "Verbonden met Guacamole. Aan het wachten op reactie...",
+        "TEXT_RECONNECT_COUNTDOWN"        : "opnieuw verbinden over {REMAINING} {REMAINING, plural, one{seconde} other{seconden}}...",
+        "TEXT_FILE_TRANSFER_PROGRESS"     : "{PROGRESS} {UNIT, select, b{B} kb{KB} mb{MB} gb{GB} other{}}",
+
+        "URL_OSK_LAYOUT" : "layouts/en-us-qwerty.json"
+
+    },
+
+    "DATA_SOURCE_DEFAULT" : {
+        "NAME" : "Standaard (XML)"
+    },
+
+    "FORM" : {
+
+        "FIELD_PLACEHOLDER_DATE" : "YYYY-MM-DD",
+        "FIELD_PLACEHOLDER_TIME" : "HH:MM:SS",
+
+        "HELP_SHOW_PASSWORD" : "Klik om wachtwoord te tonen",
+        "HELP_HIDE_PASSWORD" : "Klik om wachtwoord te verbergen"
+
+    },
+
+    "HOME" : {
+
+        "FIELD_PLACEHOLDER_FILTER" : "@:APP.FIELD_PLACEHOLDER_FILTER",
+
+        "INFO_ACTIVE_USER_COUNT" : "@:APP.INFO_ACTIVE_USER_COUNT",
+
+        "INFO_NO_RECENT_CONNECTIONS" : "Geen recente verbindingen.",
+        
+        "PASSWORD_CHANGED" : "Wachtwoord gewijzigd.",
+
+        "SECTION_HEADER_ALL_CONNECTIONS"    : "Alle Verbindingen",
+        "SECTION_HEADER_RECENT_CONNECTIONS" : "Recente Verbindingen"
+
+    },
+
+    "LOGIN": {
+
+        "ACTION_ACKNOWLEDGE" : "@:APP.ACTION_ACKNOWLEDGE",
+        "ACTION_CONTINUE"    : "@:APP.ACTION_CONTINUE",
+        "ACTION_LOGIN"       : "@:APP.ACTION_LOGIN",
+
+        "DIALOG_HEADER_ERROR" : "@:APP.DIALOG_HEADER_ERROR",
+
+        "ERROR_INVALID_LOGIN" : "Ongeldige Login",
+
+        "FIELD_HEADER_USERNAME" : "Gebruikersnaam",
+        "FIELD_HEADER_PASSWORD" : "Wachtwoord"
+
+    },
+
+    "MANAGE_CONNECTION" : {
+
+        "ACTION_ACKNOWLEDGE"          : "@:APP.ACTION_ACKNOWLEDGE",
+        "ACTION_CANCEL"               : "@:APP.ACTION_CANCEL",
+        "ACTION_CLONE"                : "@:APP.ACTION_CLONE",
+        "ACTION_DELETE"               : "@:APP.ACTION_DELETE",
+        "ACTION_SAVE"                 : "@:APP.ACTION_SAVE",
+
+        "DIALOG_HEADER_CONFIRM_DELETE" : "Verwijder Verbinding",
+        "DIALOG_HEADER_ERROR"          : "@:APP.DIALOG_HEADER_ERROR",
+
+        "FIELD_HEADER_LOCATION" : "Locatie:",
+        "FIELD_HEADER_NAME"     : "Naam:",
+        "FIELD_HEADER_PROTOCOL" : "Protocol:",
+
+        "FORMAT_HISTORY_START" : "@:APP.FORMAT_DATE_TIME_PRECISE",
+
+        "INFO_CONNECTION_DURATION_UNKNOWN" : "--",
+        "INFO_CONNECTION_ACTIVE_NOW"       : "Nu Actief",
+        "INFO_CONNECTION_NOT_USED"         : "Deze verbinding is nog niet gebruikt.",
+
+        "SECTION_HEADER_EDIT_CONNECTION" : "Bewerk Verbinding",
+        "SECTION_HEADER_HISTORY"         : "Gebruikgeschiedenis",
+        "SECTION_HEADER_PARAMETERS"      : "Parameters",
+
+        "TABLE_HEADER_HISTORY_USERNAME" : "Gebruikersnaam",
+        "TABLE_HEADER_HISTORY_START"    : "Starttijd",
+        "TABLE_HEADER_HISTORY_DURATION" : "Tijdsduur",
+
+        "TEXT_CONFIRM_DELETE"   : "Verbindingen kunnen niet worden hersteld nadat ze zijn verwijderd. Weet u zeker dat u deze verbinding wilt verwijderen?",
+        "TEXT_HISTORY_DURATION" : "@:APP.TEXT_HISTORY_DURATION"
+
+    },
+
+    "MANAGE_CONNECTION_GROUP" : {
+
+        "ACTION_ACKNOWLEDGE"   : "@:APP.ACTION_ACKNOWLEDGE",
+        "ACTION_CANCEL"        : "@:APP.ACTION_CANCEL",
+        "ACTION_DELETE"        : "@:APP.ACTION_DELETE",
+        "ACTION_SAVE"          : "@:APP.ACTION_SAVE",
+
+        "DIALOG_HEADER_CONFIRM_DELETE" : "Verwijder Verbindingsgroep",
+        "DIALOG_HEADER_ERROR"          : "@:APP.DIALOG_HEADER_ERROR",
+
+        "FIELD_HEADER_LOCATION" : "Locatie:",
+        "FIELD_HEADER_NAME"     : "Naam:",
+        "FIELD_HEADER_TYPE"     : "Soort:",
+
+        "NAME_TYPE_BALANCING"       : "Verdeling",
+        "NAME_TYPE_ORGANIZATIONAL"  : "Organizatorisch",
+
+        "SECTION_HEADER_EDIT_CONNECTION_GROUP" : "Bewerk Verbindingsgroep",
+
+        "TEXT_CONFIRM_DELETE" : "Verbindingsgroepen kunnen niet worden hersteld nadat ze zijn verwijderd. Weet u zeker dat u deze verbindingsgroep wilt verwijderen?"
+
+    },
+
+    "MANAGE_USER" : {
+
+        "ACTION_ACKNOWLEDGE"   : "@:APP.ACTION_ACKNOWLEDGE",
+        "ACTION_CANCEL"        : "@:APP.ACTION_CANCEL",
+        "ACTION_DELETE"        : "@:APP.ACTION_DELETE",
+        "ACTION_SAVE"          : "@:APP.ACTION_SAVE",
+
+        "DIALOG_HEADER_CONFIRM_DELETE" : "Verwijder Gebruiker",
+        "DIALOG_HEADER_ERROR"          : "@:APP.DIALOG_HEADER_ERROR",
+
+        "ERROR_PASSWORD_MISMATCH" : "@:APP.ERROR_PASSWORD_MISMATCH",
+
+        "FIELD_HEADER_ADMINISTER_SYSTEM"             : "Systeem Beheer:",
+        "FIELD_HEADER_CHANGE_OWN_PASSWORD"           : "Wijzigen eigen wachtwoord:",
+        "FIELD_HEADER_CREATE_NEW_USERS"              : "Nieuwe gebruikers aanmaken:",
+        "FIELD_HEADER_CREATE_NEW_CONNECTIONS"        : "Nieuwe verbindingen aanmaken:",
+        "FIELD_HEADER_CREATE_NEW_CONNECTION_GROUPS"  : "Nieuwe verbindingsgroepen aanmaken:",
+        "FIELD_HEADER_PASSWORD"                      : "@:APP.FIELD_HEADER_PASSWORD",
+        "FIELD_HEADER_PASSWORD_AGAIN"                : "@:APP.FIELD_HEADER_PASSWORD_AGAIN",
+        "FIELD_HEADER_USERNAME"                      : "Gebruikersnaam:",
+
+        "FIELD_PLACEHOLDER_FILTER" : "@:APP.FIELD_PLACEHOLDER_FILTER",
+
+        "INFO_READ_ONLY" : "Sorry, maar dit gebruikers account kan niet gewijzigd worden",
+
+        "SECTION_HEADER_CONNECTIONS" : "Verbindingen",
+        "SECTION_HEADER_EDIT_USER"   : "Bewerk Gebruiker",
+        "SECTION_HEADER_PERMISSIONS" : "Rechten",
+
+        "TEXT_CONFIRM_DELETE" : "Gebruikers kunnen niet worden hersteld nadat ze zijn verwijderd. Weet u zeker dat u deze gebruiker wilt verwijderen?"
+
+    },
+    
+    "PROTOCOL_RDP" : {
+
+        "FIELD_HEADER_CLIENT_NAME"     : "Client naam:",
+        "FIELD_HEADER_COLOR_DEPTH"     : "Kleurdiepte:",
+        "FIELD_HEADER_CONSOLE"         : "Administrator console:",
+        "FIELD_HEADER_CONSOLE_AUDIO"   : "Support audio in console:",
+        "FIELD_HEADER_CREATE_DRIVE_PATH" : "Automatisch genereren station:",
+        "FIELD_HEADER_DISABLE_AUDIO"   : "Uitschakelen geluid:",
+        "FIELD_HEADER_DISABLE_AUTH"    : "Uitschakelen authenticatie:",
+        "FIELD_HEADER_DOMAIN"          : "Domein:",
+        "FIELD_HEADER_DPI"             : "Resolutie (DPI):",
+        "FIELD_HEADER_DRIVE_PATH"      : "Station map:",
+        "FIELD_HEADER_ENABLE_DESKTOP_COMPOSITION" : "Inschakelen bureaublad compositie (Aero):",
+        "FIELD_HEADER_ENABLE_DRIVE"    : "Inschakelen station:",
+        "FIELD_HEADER_ENABLE_FONT_SMOOTHING"      : "Inschakelen font smoothing (ClearType):",
+        "FIELD_HEADER_ENABLE_FULL_WINDOW_DRAG"    : "Inschakelen verslepen compleet venster:",
+        "FIELD_HEADER_ENABLE_MENU_ANIMATIONS"     : "Inschakelen menu animaties:",
+        "FIELD_HEADER_ENABLE_PRINTING" : "Printen mogelijk maken:",
+        "FIELD_HEADER_ENABLE_SFTP"     : "Inschakelen SFTP:",
+        "FIELD_HEADER_ENABLE_THEMING"             : "Inschakelen thema's:",
+        "FIELD_HEADER_ENABLE_WALLPAPER"           : "Inschakelen achtergrond:",
+        "FIELD_HEADER_HEIGHT"          : "Hoogte:",
+        "FIELD_HEADER_HOSTNAME"        : "Servernaam:",
+        "FIELD_HEADER_IGNORE_CERT"     : "Negeer server certificaat:",
+        "FIELD_HEADER_INITIAL_PROGRAM" : "Eerste programma:",
+        "FIELD_HEADER_PASSWORD"        : "Wachtwoord:",
+        "FIELD_HEADER_PORT"            : "Poort:",
+        "FIELD_HEADER_REMOTE_APP_ARGS" : "Parameters:",
+        "FIELD_HEADER_REMOTE_APP_DIR"  : "Werk map:",
+        "FIELD_HEADER_REMOTE_APP"      : "Programma:",
+        "FIELD_HEADER_SECURITY"        : "Beveiligings modus:",
+        "FIELD_HEADER_SERVER_LAYOUT"   : "Toetsenbord lay-out:",
+        "FIELD_HEADER_SFTP_DIRECTORY"   : "Standaard upload map:",
+        "FIELD_HEADER_SFTP_HOSTNAME"    : "Servernaam:",
+        "FIELD_HEADER_SFTP_PASSPHRASE"  : "Wachtwoordzin:",
+        "FIELD_HEADER_SFTP_PASSWORD"    : "Wachtwoord:",
+        "FIELD_HEADER_SFTP_PORT"        : "Poort:",
+        "FIELD_HEADER_SFTP_PRIVATE_KEY" : "Persoonlijke sleutel:",
+        "FIELD_HEADER_SFTP_USERNAME"    : "Gebruikersnaam:",
+        "FIELD_HEADER_STATIC_CHANNELS" : "Vaste kanaalnamen:",
+        "FIELD_HEADER_USERNAME"        : "Gebruikersnaam:",
+        "FIELD_HEADER_WIDTH"           : "Breedte:",
+
+        "FIELD_OPTION_COLOR_DEPTH_16"    : "Minder Kleuren (16-bit)",
+        "FIELD_OPTION_COLOR_DEPTH_24"    : "Echte Kleuren (24-bit)",
+        "FIELD_OPTION_COLOR_DEPTH_32"    : "Echte Kleuren (32-bit)",
+        "FIELD_OPTION_COLOR_DEPTH_8"     : "256 kleuren",
+        "FIELD_OPTION_COLOR_DEPTH_EMPTY" : "",
+
+        "FIELD_OPTION_SECURITY_ANY"   : "Ieder",
+        "FIELD_OPTION_SECURITY_EMPTY" : "",
+        "FIELD_OPTION_SECURITY_NLA"   : "NLA (Network Level Authentication)",
+        "FIELD_OPTION_SECURITY_RDP"   : "RDP encryptie",
+        "FIELD_OPTION_SECURITY_TLS"   : "TLS encryptie",
+
+        "FIELD_OPTION_SERVER_LAYOUT_DE_DE_QWERTZ" : "Duits (Qwertz)",
+        "FIELD_OPTION_SERVER_LAYOUT_EMPTY"        : "",
+        "FIELD_OPTION_SERVER_LAYOUT_EN_US_QWERTY" : "Amerikaans Engels (Qwerty)",
+        "FIELD_OPTION_SERVER_LAYOUT_FAILSAFE"     : "Unicode",
+        "FIELD_OPTION_SERVER_LAYOUT_FR_FR_AZERTY" : "Frans (Azerty)",
+        "FIELD_OPTION_SERVER_LAYOUT_IT_IT_QWERTY" : "Italiaans (Qwerty)",
+        "FIELD_OPTION_SERVER_LAYOUT_SV_SE_QWERTY" : "Zweeds (Qwerty)",
+
+        "NAME" : "RDP",
+
+        "SECTION_HEADER_AUTHENTICATION"     : "Authenticatie",
+        "SECTION_HEADER_BASIC_PARAMETERS"   : "Basis Instellingen",
+        "SECTION_HEADER_DEVICE_REDIRECTION" : "Apparaat Verbindingen",
+        "SECTION_HEADER_DISPLAY"            : "Scherm",
+        "SECTION_HEADER_NETWORK"            : "Netwerk",
+        "SECTION_HEADER_PERFORMANCE"        : "Prestatie instellingen",
+        "SECTION_HEADER_REMOTEAPP"          : "ExterneApp",
+        "SECTION_HEADER_SFTP"               : "SFTP"
+
+    },
+
+    "PROTOCOL_SSH" : {
+
+        "FIELD_HEADER_COLOR_SCHEME" : "Kleuren combinatie:",
+        "FIELD_HEADER_COMMAND"     : "Uitvoeren opdracht:",
+        "FIELD_HEADER_FONT_NAME"   : "Fontnaam:",
+        "FIELD_HEADER_FONT_SIZE"   : "Fontgrootte:",
+        "FIELD_HEADER_ENABLE_SFTP" : "SFTP mogelijk maken:",
+        "FIELD_HEADER_HOSTNAME"    : "Servernaam:",
+        "FIELD_HEADER_USERNAME"    : "Gebruikersnaam:",
+        "FIELD_HEADER_PASSWORD"    : "Wachtwoord:",
+        "FIELD_HEADER_PASSPHRASE"  : "Wachtwoordzin:",
+        "FIELD_HEADER_PORT"        : "Poort:",
+        "FIELD_HEADER_PRIVATE_KEY" : "Persoonlijke sleutel:",
+
+        "FIELD_OPTION_COLOR_SCHEME_BLACK_WHITE" : "Zwart op wit",
+        "FIELD_OPTION_COLOR_SCHEME_EMPTY"       : "",
+        "FIELD_OPTION_COLOR_SCHEME_GRAY_BLACK"  : "Grijs op zwart",
+        "FIELD_OPTION_COLOR_SCHEME_GREEN_BLACK" : "Groen op zwart",
+        "FIELD_OPTION_COLOR_SCHEME_WHITE_BLACK" : "Wit op zwart",
+
+        "FIELD_OPTION_FONT_SIZE_8"     : "8",
+        "FIELD_OPTION_FONT_SIZE_9"     : "9",
+        "FIELD_OPTION_FONT_SIZE_10"    : "10",
+        "FIELD_OPTION_FONT_SIZE_11"    : "11",
+        "FIELD_OPTION_FONT_SIZE_12"    : "12",
+        "FIELD_OPTION_FONT_SIZE_14"    : "14",
+        "FIELD_OPTION_FONT_SIZE_18"    : "18",
+        "FIELD_OPTION_FONT_SIZE_24"    : "24",
+        "FIELD_OPTION_FONT_SIZE_30"    : "30",
+        "FIELD_OPTION_FONT_SIZE_36"    : "36",
+        "FIELD_OPTION_FONT_SIZE_48"    : "48",
+        "FIELD_OPTION_FONT_SIZE_60"    : "60",
+        "FIELD_OPTION_FONT_SIZE_72"    : "72",
+        "FIELD_OPTION_FONT_SIZE_96"    : "96",
+        "FIELD_OPTION_FONT_SIZE_EMPTY" : "",
+
+        "NAME" : "SSH",
+
+        "SECTION_HEADER_AUTHENTICATION" : "Authenticatie",
+        "SECTION_HEADER_DISPLAY"        : "Scherm",
+        "SECTION_HEADER_NETWORK"        : "Netwerk",
+        "SECTION_HEADER_SESSION"        : "Sessie / Omgeving",
+        "SECTION_HEADER_SFTP"           : "SFTP"
+
+    },
+
+    "PROTOCOL_TELNET" : {
+
+        "FIELD_HEADER_COLOR_SCHEME"   : "Kleuren combinatie:",
+        "FIELD_HEADER_FONT_NAME"      : "Fontnaam:",
+        "FIELD_HEADER_FONT_SIZE"      : "Fontgrootte:",
+        "FIELD_HEADER_HOSTNAME"       : "Servernaam:",
+        "FIELD_HEADER_USERNAME"       : "Gebruikersnaam:",
+        "FIELD_HEADER_PASSWORD"       : "Wachtwoord:",
+        "FIELD_HEADER_PASSWORD_REGEX" : "Wachtwoord reguliere expressie:",
+        "FIELD_HEADER_PORT"           : "Poort:",
+
+        "FIELD_OPTION_COLOR_SCHEME_BLACK_WHITE" : "Zwart op wit",
+        "FIELD_OPTION_COLOR_SCHEME_EMPTY"       : "",
+        "FIELD_OPTION_COLOR_SCHEME_GRAY_BLACK"  : "Grijs op zwart",
+        "FIELD_OPTION_COLOR_SCHEME_GREEN_BLACK" : "Groen op zwart",
+        "FIELD_OPTION_COLOR_SCHEME_WHITE_BLACK" : "Wit op zwart",
+
+        "FIELD_OPTION_FONT_SIZE_8"     : "8",
+        "FIELD_OPTION_FONT_SIZE_9"     : "9",
+        "FIELD_OPTION_FONT_SIZE_10"    : "10",
+        "FIELD_OPTION_FONT_SIZE_11"    : "11",
+        "FIELD_OPTION_FONT_SIZE_12"    : "12",
+        "FIELD_OPTION_FONT_SIZE_14"    : "14",
+        "FIELD_OPTION_FONT_SIZE_18"    : "18",
+        "FIELD_OPTION_FONT_SIZE_24"    : "24",
+        "FIELD_OPTION_FONT_SIZE_30"    : "30",
+        "FIELD_OPTION_FONT_SIZE_36"    : "36",
+        "FIELD_OPTION_FONT_SIZE_48"    : "48",
+        "FIELD_OPTION_FONT_SIZE_60"    : "60",
+        "FIELD_OPTION_FONT_SIZE_72"    : "72",
+        "FIELD_OPTION_FONT_SIZE_96"    : "96",
+        "FIELD_OPTION_FONT_SIZE_EMPTY" : "",
+
+        "NAME" : "Telnet",
+
+        "SECTION_HEADER_AUTHENTICATION" : "Authenticatie",
+        "SECTION_HEADER_DISPLAY"        : "Scherm",
+        "SECTION_HEADER_NETWORK"        : "Netwerk"
+
+    },
+
+    "PROTOCOL_VNC" : {
+
+        "FIELD_HEADER_AUDIO_SERVERNAME" : "Audio server naam:",
+        "FIELD_HEADER_CLIPBOARD_ENCODING" : "Encodering:",
+        "FIELD_HEADER_COLOR_DEPTH"      : "Kleurdiepte:",
+        "FIELD_HEADER_CURSOR"           : "Cursor:",
+        "FIELD_HEADER_DEST_HOST"        : "Externe server:",
+        "FIELD_HEADER_DEST_PORT"        : "Externe poort:",
+        "FIELD_HEADER_ENABLE_AUDIO"     : "Inschakelen geluid:",
+        "FIELD_HEADER_ENABLE_SFTP"      : "Inschakelen SFTP:",
+        "FIELD_HEADER_HOSTNAME"         : "Servernaam:",
+        "FIELD_HEADER_PASSWORD"         : "Wachtwoord:",
+        "FIELD_HEADER_PORT"             : "Poort:",
+        "FIELD_HEADER_READ_ONLY"        : "Alleen lezen:",
+        "FIELD_HEADER_SFTP_DIRECTORY"   : "Standaard upload map:",
+        "FIELD_HEADER_SFTP_HOSTNAME"    : "Servernaam:",
+        "FIELD_HEADER_SFTP_PASSPHRASE"  : "Wachtwoordzin:",
+        "FIELD_HEADER_SFTP_PASSWORD"    : "Wachtwoord:",
+        "FIELD_HEADER_SFTP_PORT"        : "Poort:",
+        "FIELD_HEADER_SFTP_PRIVATE_KEY" : "Persoonlijke sleutel:",
+        "FIELD_HEADER_SFTP_USERNAME"    : "Gebruikersnaam:",
+        "FIELD_HEADER_SWAP_RED_BLUE"    : "Verwissel rood/blauw componenten:",
+
+        "FIELD_OPTION_COLOR_DEPTH_8"     : "256 kleuren",
+        "FIELD_OPTION_COLOR_DEPTH_16"    : "Minder kleuren (16-bit)",
+        "FIELD_OPTION_COLOR_DEPTH_24"    : "Echte kleuren (24-bit)",
+        "FIELD_OPTION_COLOR_DEPTH_32"    : "Echte kleuren (32-bit)",
+        "FIELD_OPTION_COLOR_DEPTH_EMPTY" : "",
+
+        "FIELD_OPTION_CURSOR_EMPTY"  : "",
+        "FIELD_OPTION_CURSOR_LOCAL"  : "Lokaal",
+        "FIELD_OPTION_CURSOR_REMOTE" : "Extern",
+
+        "FIELD_OPTION_CLIPBOARD_ENCODING_CP1252"    : "CP1252",
+        "FIELD_OPTION_CLIPBOARD_ENCODING_EMPTY"     : "",
+        "FIELD_OPTION_CLIPBOARD_ENCODING_ISO8859_1" : "ISO 8859-1",
+        "FIELD_OPTION_CLIPBOARD_ENCODING_UTF_8"     : "UTF-8",
+        "FIELD_OPTION_CLIPBOARD_ENCODING_UTF_16"    : "UTF-16",
+
+        "NAME" : "VNC",
+
+        "SECTION_HEADER_AUDIO"          : "Geluid",
+        "SECTION_HEADER_AUTHENTICATION" : "Authenticatie",
+        "SECTION_HEADER_CLIPBOARD"      : "Klembord",
+        "SECTION_HEADER_DISPLAY"        : "Scherm",
+        "SECTION_HEADER_NETWORK"        : "Netwerk",
+        "SECTION_HEADER_REPEATER"       : "VNC Repeater",
+        "SECTION_HEADER_SFTP"           : "SFTP"
+
+    },
+
+    "SETTINGS" : {
+
+        "SECTION_HEADER_SETTINGS" : "Instellingen"
+
+    },
+
+    "SETTINGS_CONNECTION_HISTORY" : {
+
+        "ACTION_SEARCH" : "@:APP.ACTION_SEARCH",
+
+        "FIELD_PLACEHOLDER_FILTER" : "@:APP.FIELD_PLACEHOLDER_FILTER",
+
+        "FORMAT_DATE" : "@:APP.FORMAT_DATE_TIME_PRECISE",
+
+        "HELP_CONNECTION_HISTORY" : "De gebruikgeschiedenis van verbindingen wordt hier onder getoond en kan gesorteerd worden door op de titel van de kolom te klikken. Voer een zoekterm in en klik op \"Zoeken\", om op specifieke resultaten te zoeken. Alleen de resultaten die voldoen aan de zoekterm zullen dan getoond worden.",
+
+        "INFO_CONNECTION_DURATION_UNKNOWN" : "--",
+        "INFO_NO_HISTORY"                  : "Geen resultaten gevonden",
+
+        "TABLE_HEADER_SESSION_CONNECTION_NAME" : "Verbindingsnaam",
+        "TABLE_HEADER_SESSION_DURATION"        : "Tijdsduur",
+        "TABLE_HEADER_SESSION_STARTDATE"       : "Starttijd",
+        "TABLE_HEADER_SESSION_USERNAME"        : "Gebruikersnaam",
+
+        "TEXT_HISTORY_DURATION" : "@:APP.TEXT_HISTORY_DURATION"
+
+    },
+
+    "SETTINGS_CONNECTIONS" : {
+
+        "ACTION_ACKNOWLEDGE"          : "@:APP.ACTION_ACKNOWLEDGE",
+        "ACTION_NEW_CONNECTION"       : "Nieuwe Verbinding",
+        "ACTION_NEW_CONNECTION_GROUP" : "Nieuwe Verbindingsgroep",
+
+        "DIALOG_HEADER_ERROR" : "@:APP.DIALOG_HEADER_ERROR",
+
+        "FIELD_PLACEHOLDER_FILTER" : "@:APP.FIELD_PLACEHOLDER_FILTER",
+
+        "HELP_CONNECTIONS"   : "Klik of tik op een verbinding hieronder om die verbinding te beheren. Afhankelijk van uw toegangsniveau kunnen verbindingen worden toegevoegd en verwijderd en hun eigenschappen (protocol, hostname, port, etc.) worden gewijzigd. ",
+
+        "INFO_ACTIVE_USER_COUNT" : "@:APP.INFO_ACTIVE_USER_COUNT",
+
+        "SECTION_HEADER_CONNECTIONS"     : "Verbindingen"
+
+    },
+
+    "SETTINGS_PREFERENCES" : {
+
+        "ACTION_ACKNOWLEDGE"        : "@:APP.ACTION_ACKNOWLEDGE",
+        "ACTION_CANCEL"             : "@:APP.ACTION_CANCEL",
+        "ACTION_UPDATE_PASSWORD"    : "@:APP.ACTION_UPDATE_PASSWORD",
+
+        "DIALOG_HEADER_ERROR"    : "@:APP.DIALOG_HEADER_ERROR",
+
+        "ERROR_PASSWORD_BLANK"    : "@:APP.ERROR_PASSWORD_BLANK",
+        "ERROR_PASSWORD_MISMATCH" : "@:APP.ERROR_PASSWORD_MISMATCH",
+
+        "FIELD_HEADER_LANGUAGE"           : "Taal Keuze:",
+        "FIELD_HEADER_PASSWORD"           : "Wachtwoord:",
+        "FIELD_HEADER_PASSWORD_OLD"       : "Huidig Wachtwoord:",
+        "FIELD_HEADER_PASSWORD_NEW"       : "Nieuw Wachtwoord:",
+        "FIELD_HEADER_PASSWORD_NEW_AGAIN" : "Bevestig Nieuw Wachtwoord:",
+        "FIELD_HEADER_USERNAME"           : "Gebruikersnaam:",
+        
+        "HELP_DEFAULT_INPUT_METHOD" : "De standaard invoer methode bepaalt hoe toetsenbord gebeurtenissen ontvangen worden door Guacamole. Het veranderen van deze instelling kan nodig zijn bij gebruik van een mobiel apparaat of wanneer er via een IME getypt wordt. Deze instelling kan per verbinding worden overschreven via het Guacamole menu.",
+        "HELP_DEFAULT_MOUSE_MODE"   : "De standaard muis emulatie modus bepaalt hoe de externe muis in nieuwe verbindingen omgaat met touch. Deze instelling kan per verbinding worden overschreven via het Guacamole menu.",
+        "HELP_INPUT_METHOD_NONE"    : "@:CLIENT.HELP_INPUT_METHOD_NONE",
+        "HELP_INPUT_METHOD_OSK"     : "@:CLIENT.HELP_INPUT_METHOD_OSK",
+        "HELP_INPUT_METHOD_TEXT"    : "@:CLIENT.HELP_INPUT_METHOD_TEXT",
+        "HELP_LANGUAGE"             : "Selecteer onderstaand een andere taal om alle text in Guacamole hieraan aan te passen. De beschikbare keuzes zijn afhankelijk van welke talen er geinstalleerd zijn.",
+        "HELP_MOUSE_MODE_ABSOLUTE"  : "@:CLIENT.HELP_MOUSE_MODE_ABSOLUTE",
+        "HELP_MOUSE_MODE_RELATIVE"  : "@:CLIENT.HELP_MOUSE_MODE_RELATIVE",
+        "HELP_UPDATE_PASSWORD"      : "Als u uw wachtwoord wilt wijzigen, voer dan uw huidige wachtwoord en uw nieuwe wachtwoord hieronder in en klik op \"Wijzig Wachtwoord\". Deze wijziging zal meteen actief zijn.",
+
+        "INFO_PASSWORD_CHANGED" : "Wachtwoord gewijzigd.",
+
+        "NAME_INPUT_METHOD_NONE" : "@:CLIENT.NAME_INPUT_METHOD_NONE",
+        "NAME_INPUT_METHOD_OSK"  : "@:CLIENT.NAME_INPUT_METHOD_OSK",
+        "NAME_INPUT_METHOD_TEXT" : "@:CLIENT.NAME_INPUT_METHOD_TEXT",
+
+        "SECTION_HEADER_DEFAULT_INPUT_METHOD" : "Standaard Invoer Methode",
+        "SECTION_HEADER_DEFAULT_MOUSE_MODE"   : "Standaard Muis Emulatie Modus",
+        "SECTION_HEADER_UPDATE_PASSWORD"      : "Wijzig Wachtwoord"
+
+    },
+
+    "SETTINGS_USERS" : {
+
+        "ACTION_ACKNOWLEDGE"   : "@:APP.ACTION_ACKNOWLEDGE",
+        "ACTION_NEW_USER"      : "Nieuwe Gebruiker",
+
+        "DIALOG_HEADER_ERROR" : "@:APP.DIALOG_HEADER_ERROR",
+
+        "FIELD_PLACEHOLDER_FILTER" : "@:APP.FIELD_PLACEHOLDER_FILTER",
+
+        "HELP_USERS" : "Klik of tik op een van de onderstaande gebruikers om die te beheren. Afhankelijk van uw toegangsniveau kunnen gebruikers worden toegevoegd, verwijderd en hun wachtwoorden gewijzigd.",
+
+        "SECTION_HEADER_USERS"       : "Gebruikers"
+
+    },
+    
+    "SETTINGS_SESSIONS" : {
+        
+        "ACTION_ACKNOWLEDGE" : "@:APP.ACTION_ACKNOWLEDGE",
+        "ACTION_CANCEL"      : "@:APP.ACTION_CANCEL",
+        "ACTION_DELETE"      : "Beeindig Sessies",
+        
+        "DIALOG_HEADER_CONFIRM_DELETE" : "Beeindig Sessie",
+        "DIALOG_HEADER_ERROR"          : "@:APP.DIALOG_HEADER_ERROR",
+        
+        "FIELD_PLACEHOLDER_FILTER" : "@:APP.FIELD_PLACEHOLDER_FILTER",
+        
+        "FORMAT_STARTDATE" : "@:APP.FORMAT_DATE_TIME_PRECISE",
+
+        "HELP_SESSIONS" : "Alle Guacamole sessies die op dit moment actief zijn worden hier getoond. Als u een of meerdere sessies wilt beeindigen, vink die sessie(s) dan aan en klik op \"Beeindig Sessies\". Door het verbreken van een sessie verliest de gebruiker ogenblikkelijk het contact met die sessie(s).",
+        
+        "INFO_NO_SESSIONS" : "Geen actieve sessies",
+
+        "SECTION_HEADER_SESSIONS" : "Actieve Sessies",
+
+        "TABLE_HEADER_SESSION_CONNECTION_NAME" : "Verbindingnaam",
+        "TABLE_HEADER_SESSION_REMOTEHOST"      : "Servernaam",
+        "TABLE_HEADER_SESSION_STARTDATE"       : "Actief sinds",
+        "TABLE_HEADER_SESSION_USERNAME"        : "Gebruikersnaam",
+
+        "TEXT_CONFIRM_DELETE" : "Weet u zeker dat u alle geselecteerde sessies wilt beeindigen? De gebruikers van deze sessies zullen ogenblikkelijk hun verbinding met deze sessies verliezen."
+
+    },
+
+    "USER_MENU" : {
+
+        "ACTION_LOGOUT"             : "@:APP.ACTION_LOGOUT",
+        "ACTION_MANAGE_CONNECTIONS" : "@:APP.ACTION_MANAGE_CONNECTIONS",
+        "ACTION_MANAGE_PREFERENCES" : "@:APP.ACTION_MANAGE_PREFERENCES",
+        "ACTION_MANAGE_SESSIONS"    : "@:APP.ACTION_MANAGE_SESSIONS",
+        "ACTION_MANAGE_SETTINGS"    : "@:APP.ACTION_MANAGE_SETTINGS",
+        "ACTION_MANAGE_USERS"       : "@:APP.ACTION_MANAGE_USERS",
+        "ACTION_NAVIGATE_HOME"      : "@:APP.ACTION_NAVIGATE_HOME",
+        "ACTION_VIEW_HISTORY"       : "@:APP.ACTION_VIEW_HISTORY"
+
+    }
+
+}
diff --git a/guacamole/src/main/webapp/translations/ru.json b/guacamole/src/main/webapp/translations/ru.json
new file mode 100644
index 0000000..6ac7fbe
--- /dev/null
+++ b/guacamole/src/main/webapp/translations/ru.json
@@ -0,0 +1,550 @@
+{
+
+    "NAME" : "Русский",
+
+    "APP" : {
+
+        "ACTION_ACKNOWLEDGE"        : "ОК",
+        "ACTION_CANCEL"             : "Отмена",
+        "ACTION_CLONE"              : "Скопировать",
+        "ACTION_DELETE"             : "Удалить",
+        "ACTION_DELETE_SESSIONS"    : "Завершить сессии",
+        "ACTION_LOGIN"              : "Вход",
+        "ACTION_LOGOUT"             : "Выход",
+        "ACTION_MANAGE_CONNECTIONS" : "Подключения",
+        "ACTION_MANAGE_PREFERENCES" : "Настройки",
+        "ACTION_MANAGE_SETTINGS"    : "Опции",
+        "ACTION_MANAGE_SESSIONS"    : "Активные сессии",
+        "ACTION_MANAGE_USERS"       : "Пользователи",
+        "ACTION_NAVIGATE_BACK"      : "Назад",
+        "ACTION_NAVIGATE_HOME"      : "Главная",
+        "ACTION_SAVE"               : "Сохранить",
+        "ACTION_UPDATE_PASSWORD"    : "Обновить пароль",
+
+        "DIALOG_HEADER_ERROR" : "Ошибка",
+
+        "ERROR_PASSWORD_BLANK"    : "Пароль не может быть пустым.",
+        "ERROR_PASSWORD_MISMATCH" : "Указанные пароли не совпадают.",
+
+        "FIELD_HEADER_PASSWORD"       : "Пароль:",
+        "FIELD_HEADER_PASSWORD_AGAIN" : "Повтор пароля:",
+
+        "FIELD_PLACEHOLDER_FILTER" : "Фильтр",
+
+        "FORMAT_DATE_TIME_PRECISE" : "yyyy-MM-dd HH:mm:ss",
+
+        "INFO_ACTIVE_USER_COUNT" : "Подключено пользователей {USERS}.",
+
+        "NAME" : "Guacamole ${project.version}",
+
+        "TEXT_HISTORY_DURATION" : "{VALUE} {UNIT, select, second{{VALUE, plural, one{секунда} other{сек}}} minute{{VALUE, plural, one{минута} other{мин}}} hour{{VALUE, plural, one{час} other{ч}}} day{{VALUE, plural, one{день} other{дн}}} other{}}"
+
+    },
+
+    "CLIENT" : {
+
+        "ACTION_ACKNOWLEDGE"               : "@:APP.ACTION_ACKNOWLEDGE",
+        "ACTION_CLEAR_COMPLETED_TRANSFERS" : "Очистить",
+        "ACTION_DISCONNECT"                : "Отключиться",
+        "ACTION_LOGOUT"                    : "@:APP.ACTION_LOGOUT",
+        "ACTION_NAVIGATE_BACK"             : "@:APP.ACTION_NAVIGATE_BACK",
+        "ACTION_NAVIGATE_HOME"             : "@:APP.ACTION_NAVIGATE_HOME",
+        "ACTION_RECONNECT"                 : "Переподключиться",
+        "ACTION_SAVE_FILE"                 : "@:APP.ACTION_SAVE",
+        "ACTION_UPLOAD_FILES"              : "Загрузка файлов",
+
+        "DIALOG_HEADER_CONNECTING"       : "Подключение",
+        "DIALOG_HEADER_CONNECTION_ERROR" : "Ошибка подключения",
+        "DIALOG_HEADER_DISCONNECTED"     : "Отключено",
+
+        "ERROR_CLIENT_201"     : "Соединение было закрыто, так как сервер перегружен. Пожалуйста, попробуйте повторить попытку позднее.",
+        "ERROR_CLIENT_202"     : "Сервер закрыл соединение, так как удаленные рабочий стол не отвечает. Пожалуйста, попробуйте повторить попытку позднее или обратитесь к администратору..",
+        "ERROR_CLIENT_203"     : "На сервере удаленные рабочих столов произошла ошибка, и соединение было закрыто. Пожалуйста, попробуйте повторить попытку позднее или обратитесь к администратору.",
+        "ERROR_CLIENT_205"     : "Соединение было закрыто, так как оно конфликтовало с другим соединением. Пожалуйста, попробуйте повторить попытку позднее.",
+        "ERROR_CLIENT_301"     : "Не удалось выполнить вход. Пожалуйста, переподключитесь и повторите попытку.",
+        "ERROR_CLIENT_303"     : "У вас нет разрешения для доступа к этому соединению. Для получения доступа, пожалуйста, обратитесь к администратору.",
+        "ERROR_CLIENT_308"     : "Сервер Guacamole закрыл соединение, так как браузер не ответил в течение отведенного времени. Такое бывает в случае возникновения проблем с доступом в сеть, связанных со слабым беспроводным сигналом или низкими скоростями передачи данных. Пожалуйста, проверьте ваше сетевое подключение и повторите попытку снова.",
+        "ERROR_CLIENT_31D"     : "Сервер Guacamole отклонил доступ к соединению, так как вы превысили максимальное количество одновременных подключений. Пожалуйста, закройте ненужные соединения и повторите попытку.",
+        "ERROR_CLIENT_DEFAULT" : "Соединение было прервано из-за внутренней ошибки сервера. Пожалуйста, попробуйте повторить попытку позднее или обратитесь к администратору.",
+
+        "ERROR_TUNNEL_201"     : "Сервер запретил соединение, так как открыто слишком много соединений. Пожалуйста, попробуйте повторить попытку позднее.",
+        "ERROR_TUNNEL_202"     : "Сервер закрыл соединение, так как удаленные рабочий стол не отвечает. Пожалуйста, попробуйте повторить попытку позднее или обратитесь к администратору",
+        "ERROR_TUNNEL_203"     : "На сервере удаленные рабочих столов произошла ошибка, и соединение было закрыто. Пожалуйста, попробуйте повторить попытку позднее или обратитесь к администратору.",
+        "ERROR_TUNNEL_204"     : "Соединение не существует. Проверьте, пожалуйста, название соединения.",
+        "ERROR_TUNNEL_205"     : "Соединение в настоящий момент занято, и доступ к этому соединению не разрешен. ожалуйста, попробуйте повторить попытку позднее.",
+        "ERROR_TUNNEL_301"     : "У вас нет разрешения для доступа к этому соединению, так как вы не выполнили вход. Пожалуйста, выполните вход и повторите попытку.",
+        "ERROR_TUNNEL_303"     : "У вас нет разрешения для доступа к этому соединению. Для получения доступа, пожалуйста, обратитесь к администратору.",
+        "ERROR_TUNNEL_308"     : "Сервер Guacamole закрыл соединение, так как браузер не ответил в течение отведенного времени. Такое бывает в случае возникновения проблем с доступом в сеть, связанных со слабым беспроводным сигналом или низкими скоростями передачи данных. Пожалуйста, проверьте ваше сетевое подключение и повторите попытку снова.",
+        "ERROR_TUNNEL_31D"     : "Сервер Guacamole отклонил доступ к соединению, так как вы превысили максимальное количество одновременных подключений. Пожалуйста, закройте ненужные соединения и повторите попытку.",
+        "ERROR_TUNNEL_DEFAULT" : "Соединение было прервано из-за внутренней ошибки сервера. Пожалуйста, попробуйте повторить попытку позднее или обратитесь к администратору.",
+
+        "ERROR_UPLOAD_100"     : "Передача файлов либо не поддерживается, либо не включена. Пожалуйста, обратитесь к администратору.",
+        "ERROR_UPLOAD_201"     : "Слишком много задач передачи файлов активно. Подождите завершения текущих передач и повторите попытку снова.",
+        "ERROR_UPLOAD_202"     : "Файл не может быть передан, так как удаленный рабочий стол не отвечает. Пожалуйста, попробуйте повторить попытку позднее или обратитесь к администратору.",
+        "ERROR_UPLOAD_203"     : "Произошла ошибка при передаче файла. Пожалуйста, попробуйте повторить попытку позднее или обратитесь к администратору.",
+        "ERROR_UPLOAD_204"     : "Назначение для передаваемого файла не существует. Пожалуйста, проверьте назначение и повторите попытку снова.",
+        "ERROR_UPLOAD_205"     : "Назнечение для передаваемого файла заблокировано. Пожалуйста, дождитесь завершения других передач и повторите попытку снова.",
+        "ERROR_UPLOAD_301"     : "У вас нет разрешения на загрузку файла, так как вы не выполнили вход. Пожалуйста, выполните вход и повторите попытку передачи файла.",
+        "ERROR_UPLOAD_303"     : "У вас нет разрешения на загрузку файла. Для получения разрешения проверьте нестройки или обратитесь к администратору.",
+        "ERROR_UPLOAD_308"     : "Передача файла зависла. Такое бывает в случае возникновения проблем с доступом в сеть, связанных со слабым беспроводным сигналом или низкими скоростями передачи данных. Пожалуйста, проверьте ваше сетевое подключение и повторите попытку снова.",
+        "ERROR_UPLOAD_31D"     : "Слишком много файлов передается в настоящий момент. Подождите завершения текущих передач и повторите попытку снова.",
+        "ERROR_UPLOAD_DEFAULT" : "Соединение было прервано из-за внутренней ошибки сервера. Пожалуйста, попробуйте повторить попытку позднее или обратитесь к администратору.",
+
+        "HELP_CLIPBOARD"           : "Текст, скопированный или вырезанный внутри сеанса, появится в этом поле. Изменение текста также отразиться на буфере обмена удаленного рабочего стола.",
+        "HELP_INPUT_METHOD_NONE"   : "Не выбран метод ввода. Ввод разрешен для физической клавиатуры.",
+        "HELP_INPUT_METHOD_OSK"    : "Отображать и принимать ввод со встроенной экранной клавиатуры. Экранная клавиатура позволяет вводить любые комбинации, недоступные в других режимах (например Alt-Ctrl-Del).",
+        "HELP_INPUT_METHOD_TEXT"   : "Разрешить ввод текста и эмулировать события клавиатуры в зависимости от нажатых клавиш. Это необходимо для устройств без физической клавиатуры (смартфоны, планшеты).",
+        "HELP_MOUSE_MODE"          : "Определяет поведение курсора мыши при прикосновении.",
+        "HELP_MOUSE_MODE_ABSOLUTE" : "Прикоснитесь, чтобы сделать клик. Клик происходит в точке прикосновения к экрану.",
+        "HELP_MOUSE_MODE_RELATIVE" : "Потяните, чтобы перемещать курсор. Прикоснитесь, чтобы сделать клик. Клик происходит в точке нахождения курсора.",
+
+        "INFO_NO_FILE_TRANSFERS" : "Нет загрузок.",
+
+        "NAME_INPUT_METHOD_NONE"   : "Нет",
+        "NAME_INPUT_METHOD_OSK"    : "Экранная клавиатура",
+        "NAME_INPUT_METHOD_TEXT"   : "Ввод текста",
+        "NAME_KEY_CTRL"            : "Ctrl",
+        "NAME_KEY_ALT"             : "Alt",
+        "NAME_KEY_ESC"             : "Esc",
+        "NAME_KEY_TAB"             : "Tab",
+        "NAME_MOUSE_MODE_ABSOLUTE" : "Touchscreen",
+        "NAME_MOUSE_MODE_RELATIVE" : "Touchpad",
+
+        "SECTION_HEADER_CLIPBOARD"      : "Буфер обмена",
+        "SECTION_HEADER_DISPLAY"        : "Экран",
+        "SECTION_HEADER_FILE_TRANSFERS" : "Загрузки файлов",
+        "SECTION_HEADER_INPUT_METHOD"   : "Метод ввода",
+        "SECTION_HEADER_MOUSE_MODE"     : "Режим эмуляции мыши",
+
+        "TEXT_ZOOM_AUTO_FIT"              : "Автоматически умещать в окне",
+        "TEXT_CLIENT_STATUS_IDLE"         : "Бездействие.",
+        "TEXT_CLIENT_STATUS_CONNECTING"   : "Подключение к Guacamole...",
+        "TEXT_CLIENT_STATUS_DISCONNECTED" : "Вы были успешно подключены.",
+        "TEXT_CLIENT_STATUS_WAITING"      : "Подключено к Guacamole. Ожидание ответа...",
+        "TEXT_RECONNECT_COUNTDOWN"        : "Переподключение через {REMAINING} сек...",
+        "TEXT_FILE_TRANSFER_PROGRESS"     : "{PROGRESS} {UNIT, select, b{B} kb{KB} mb{MB} gb{GB} other{}}",
+
+        "URL_OSK_LAYOUT" : "layouts/ru-ru-qwerty.json"
+
+    },
+
+    "FORM" : {
+
+        "HELP_SHOW_PASSWORD" : "Нажмите, чтобы посмотреть пароль",
+        "HELP_HIDE_PASSWORD" : "Нажмите, чтобы спрятать пароль"
+
+    },
+
+    "HOME" : {
+
+        "FIELD_PLACEHOLDER_FILTER" : "@:APP.FIELD_PLACEHOLDER_FILTER",
+
+        "INFO_ACTIVE_USER_COUNT" : "@:APP.INFO_ACTIVE_USER_COUNT",
+
+        "INFO_NO_RECENT_CONNECTIONS" : "Нет недавних подключения.",
+
+        "PASSWORD_CHANGED" : "Пароль был успешно изменен.",
+
+        "SECTION_HEADER_ALL_CONNECTIONS"    : "Все подключения",
+        "SECTION_HEADER_RECENT_CONNECTIONS" : "Недавние подключения"
+
+    },
+
+    "LOGIN": {
+
+        "ACTION_ACKNOWLEDGE" : "@:APP.ACTION_ACKNOWLEDGE",
+        "ACTION_LOGIN"       : "@:APP.ACTION_LOGIN",
+
+        "DIALOG_HEADER_ERROR" : "@:APP.DIALOG_HEADER_ERROR",
+
+        "ERROR_INVALID_LOGIN" : "Неверные данные для входа",
+
+        "FIELD_HEADER_USERNAME" : "Имя пользователя",
+        "FIELD_HEADER_PASSWORD" : "Пароль"
+
+    },
+
+    "MANAGE_CONNECTION" : {
+
+        "ACTION_ACKNOWLEDGE"          : "@:APP.ACTION_ACKNOWLEDGE",
+        "ACTION_CANCEL"               : "@:APP.ACTION_CANCEL",
+        "ACTION_CLONE"                : "@:APP.ACTION_CLONE",
+        "ACTION_DELETE"               : "@:APP.ACTION_DELETE",
+        "ACTION_SAVE"                 : "@:APP.ACTION_SAVE",
+
+        "DIALOG_HEADER_CONFIRM_DELETE" : "Удалить подключение",
+        "DIALOG_HEADER_ERROR"          : "@:APP.DIALOG_HEADER_ERROR",
+
+        "FIELD_HEADER_LOCATION" : "Размещение:",
+        "FIELD_HEADER_NAME"     : "Название:",
+        "FIELD_HEADER_PROTOCOL" : "Протокол:",
+
+        "FORMAT_HISTORY_START" : "@:APP.FORMAT_DATE_TIME_PRECISE",
+
+        "INFO_CONNECTION_DURATION_UNKNOWN" : "--",
+        "INFO_CONNECTION_ACTIVE_NOW"       : "Активно",
+        "INFO_CONNECTION_NOT_USED"         : "Это подключение еще не использовалось.",
+
+        "SECTION_HEADER_EDIT_CONNECTION" : "Редактировать подключение",
+        "SECTION_HEADER_HISTORY"         : "История использования",
+        "SECTION_HEADER_PARAMETERS"      : "Настройки",
+
+        "TABLE_HEADER_HISTORY_USERNAME" : "Имя пользователя",
+        "TABLE_HEADER_HISTORY_START"    : "Время начала",
+        "TABLE_HEADER_HISTORY_DURATION" : "Продолжительность",
+
+        "TEXT_CONFIRM_DELETE"   : "Подключения не могут быть восстановлены после удаления. Вы уверены, что хотите удалить подключение?",
+        "TEXT_HISTORY_DURATION" : "@:APP.TEXT_HISTORY_DURATION"
+
+    },
+
+    "MANAGE_CONNECTION_GROUP" : {
+
+        "ACTION_ACKNOWLEDGE"   : "@:APP.ACTION_ACKNOWLEDGE",
+        "ACTION_CANCEL"        : "@:APP.ACTION_CANCEL",
+        "ACTION_DELETE"        : "@:APP.ACTION_DELETE",
+        "ACTION_SAVE"          : "@:APP.ACTION_SAVE",
+
+        "DIALOG_HEADER_CONFIRM_DELETE" : "Удалить группу подключений",
+        "DIALOG_HEADER_ERROR"          : "@:APP.DIALOG_HEADER_ERROR",
+
+        "FIELD_HEADER_LOCATION" : "Размещение:",
+        "FIELD_HEADER_NAME"     : "Название:",
+        "FIELD_HEADER_TYPE"     : "Тип:",
+
+        "NAME_TYPE_BALANCING"       : "Балансировщик",
+        "NAME_TYPE_ORGANIZATIONAL"  : "Корпоративный",
+
+        "SECTION_HEADER_EDIT_CONNECTION_GROUP" : "Редактировать группу подключений",
+
+        "TEXT_CONFIRM_DELETE" : "Группы подключений не могут быть восстановлены после удаления. Вы уверены, что хотите удалить группу подключений?"
+
+    },
+
+    "MANAGE_USER" : {
+
+        "ACTION_ACKNOWLEDGE"   : "@:APP.ACTION_ACKNOWLEDGE",
+        "ACTION_CANCEL"        : "@:APP.ACTION_CANCEL",
+        "ACTION_DELETE"        : "@:APP.ACTION_DELETE",
+        "ACTION_SAVE"          : "@:APP.ACTION_SAVE",
+
+        "DIALOG_HEADER_CONFIRM_DELETE" : "Удалить пользователя",
+        "DIALOG_HEADER_ERROR"          : "@:APP.DIALOG_HEADER_ERROR",
+
+        "ERROR_PASSWORD_MISMATCH" : "@:APP.ERROR_PASSWORD_MISMATCH",
+
+        "FIELD_HEADER_ADMINISTER_SYSTEM"             : "Администрирование системы:",
+        "FIELD_HEADER_CHANGE_OWN_PASSWORD"           : "Изменить собственный пароль:",
+        "FIELD_HEADER_CREATE_NEW_USERS"              : "Создать нового пользователя:",
+        "FIELD_HEADER_CREATE_NEW_CONNECTIONS"        : "Создать новое подключение:",
+        "FIELD_HEADER_CREATE_NEW_CONNECTION_GROUPS"  : "Создать новую группу подключений:",
+        "FIELD_HEADER_PASSWORD"                      : "@:APP.FIELD_HEADER_PASSWORD",
+        "FIELD_HEADER_PASSWORD_AGAIN"                : "@:APP.FIELD_HEADER_PASSWORD_AGAIN",
+        "FIELD_HEADER_USERNAME"                      : "Имя пользователя:",
+
+        "FIELD_PLACEHOLDER_FILTER" : "@:APP.FIELD_PLACEHOLDER_FILTER",
+
+        "SECTION_HEADER_CONNECTIONS" : "Подключения",
+        "SECTION_HEADER_EDIT_USER"   : "Редактировать пользователя",
+        "SECTION_HEADER_PERMISSIONS" : "Разрешения",
+
+        "TEXT_CONFIRM_DELETE" : "Пользователи не могут быть восстановлены после удаления. Вы уверены, что хотите удалить пользователя?"
+
+    },
+
+    "PROTOCOL_RDP" : {
+
+        "FIELD_HEADER_CLIENT_NAME"     : "Имя клиента:",
+        "FIELD_HEADER_COLOR_DEPTH"     : "Глубина цвета:",
+        "FIELD_HEADER_CONSOLE_AUDIO"   : "Поддержка звука:",
+        "FIELD_HEADER_CONSOLE"         : "Консоль администратора:",
+        "FIELD_HEADER_DISABLE_AUDIO"   : "Отключить звук:",
+        "FIELD_HEADER_DISABLE_AUTH"    : "Отключить аутентификацию:",
+        "FIELD_HEADER_DOMAIN"          : "Домен:",
+        "FIELD_HEADER_DPI"             : "Разрешение экрана (DPI):",
+        "FIELD_HEADER_DRIVE_PATH"      : "Путь до диска:",
+        "FIELD_HEADER_ENABLE_DRIVE"    : "Включить диск:",
+        "FIELD_HEADER_ENABLE_PRINTING" : "Включить печать:",
+        "FIELD_HEADER_ENABLE_SFTP"      : "Включить SFTP:",
+        "FIELD_HEADER_HEIGHT"          : "Высота:",
+        "FIELD_HEADER_HOSTNAME"        : "Название сервера:",
+        "FIELD_HEADER_IGNORE_CERT"     : "Игнорировать сертификат сервера:",
+        "FIELD_HEADER_INITIAL_PROGRAM" : "Запуск программ при подключении:",
+        "FIELD_HEADER_PASSWORD"        : "Пароль:",
+        "FIELD_HEADER_PORT"            : "Порт:",
+        "FIELD_HEADER_REMOTE_APP_ARGS" : "Параметры RemoteApp:",
+        "FIELD_HEADER_REMOTE_APP_DIR"  : "Рабочий каталог RemoteApp:",
+        "FIELD_HEADER_REMOTE_APP"      : "Программы RemoteApp:",
+        "FIELD_HEADER_SECURITY"        : "Режим безопасности:",
+        "FIELD_HEADER_SERVER_LAYOUT"   : "Раскладка клавиатуры:",
+        "FIELD_HEADER_SFTP_HOSTNAME"    : "Название сервера:",
+        "FIELD_HEADER_SFTP_PASSPHRASE"  : "Секретная фраза:",
+        "FIELD_HEADER_SFTP_PASSWORD"    : "Пароль:",
+        "FIELD_HEADER_SFTP_PORT"        : "Порт:",
+        "FIELD_HEADER_SFTP_PRIVATE_KEY" : "Приватный ключ:",
+        "FIELD_HEADER_SFTP_USERNAME"    : "Имя пользователя:",
+        "FIELD_HEADER_STATIC_CHANNELS" : "Статичное название канала:",
+        "FIELD_HEADER_USERNAME"        : "Имя пользователя:",
+        "FIELD_HEADER_WIDTH"           : "Ширина:",
+
+        "FIELD_OPTION_COLOR_DEPTH_16"    : "Low color (16-бит)",
+        "FIELD_OPTION_COLOR_DEPTH_24"    : "True color (24-бит)",
+        "FIELD_OPTION_COLOR_DEPTH_32"    : "True color (32-бит)",
+        "FIELD_OPTION_COLOR_DEPTH_8"     : "256 цветов",
+        "FIELD_OPTION_COLOR_DEPTH_EMPTY" : "",
+
+        "FIELD_OPTION_SECURITY_ANY"   : "Любой",
+        "FIELD_OPTION_SECURITY_EMPTY" : "",
+        "FIELD_OPTION_SECURITY_NLA"   : "NLA (Network Level Authentication)",
+        "FIELD_OPTION_SECURITY_RDP"   : "RDP шифрование",
+        "FIELD_OPTION_SECURITY_TLS"   : "TLS шифрование",
+
+        "FIELD_OPTION_SERVER_LAYOUT_DE_DE_QWERTZ" : "German (Qwertz)",
+        "FIELD_OPTION_SERVER_LAYOUT_EMPTY"        : "",
+        "FIELD_OPTION_SERVER_LAYOUT_EN_US_QWERTY" : "US English (Qwerty)",
+        "FIELD_OPTION_SERVER_LAYOUT_FAILSAFE"     : "Unicode",
+        "FIELD_OPTION_SERVER_LAYOUT_FR_FR_AZERTY" : "French (Azerty)",
+        "FIELD_OPTION_SERVER_LAYOUT_IT_IT_QWERTY" : "Italian (Qwerty)",
+        "FIELD_OPTION_SERVER_LAYOUT_SV_SE_QWERTY" : "Swedish (Qwerty)",
+	      "FIELD_OPTION_SERVER_LAYOUT_RU_RU_QWERTY" : "Russian (Qwerty)",
+
+        "NAME" : "RDP"
+
+    },
+
+    "PROTOCOL_SSH" : {
+
+        "FIELD_HEADER_FONT_NAME"   : "Шрифт:",
+        "FIELD_HEADER_FONT_SIZE"   : "Размер шрифта:",
+        "FIELD_HEADER_ENABLE_SFTP" : "Включить SFTP:",
+        "FIELD_HEADER_HOSTNAME"    : "Название сервера:",
+        "FIELD_HEADER_USERNAME"    : "Имя пользователя:",
+        "FIELD_HEADER_PASSWORD"    : "Пароль:",
+        "FIELD_HEADER_PASSPHRASE"  : "Секретная фраза:",
+        "FIELD_HEADER_PORT"        : "Порт:",
+        "FIELD_HEADER_PRIVATE_KEY" : "Приватный ключ:",
+
+        "FIELD_OPTION_FONT_SIZE_8"     : "8",
+        "FIELD_OPTION_FONT_SIZE_9"     : "9",
+        "FIELD_OPTION_FONT_SIZE_10"    : "10",
+        "FIELD_OPTION_FONT_SIZE_11"    : "11",
+        "FIELD_OPTION_FONT_SIZE_12"    : "12",
+        "FIELD_OPTION_FONT_SIZE_14"    : "14",
+        "FIELD_OPTION_FONT_SIZE_18"    : "18",
+        "FIELD_OPTION_FONT_SIZE_24"    : "24",
+        "FIELD_OPTION_FONT_SIZE_30"    : "30",
+        "FIELD_OPTION_FONT_SIZE_36"    : "36",
+        "FIELD_OPTION_FONT_SIZE_48"    : "48",
+        "FIELD_OPTION_FONT_SIZE_60"    : "60",
+        "FIELD_OPTION_FONT_SIZE_72"    : "72",
+        "FIELD_OPTION_FONT_SIZE_96"    : "96",
+        "FIELD_OPTION_FONT_SIZE_EMPTY" : "",
+
+        "NAME" : "SSH"
+
+    },
+
+    "PROTOCOL_TELNET" : {
+
+        "FIELD_HEADER_FONT_NAME"      : "Шрифт:",
+        "FIELD_HEADER_FONT_SIZE"      : "Размер шрифта:",
+        "FIELD_HEADER_HOSTNAME"       : "Название сервера:",
+        "FIELD_HEADER_USERNAME"       : "Имя пользователя:",
+        "FIELD_HEADER_PASSWORD"       : "Пароль:",
+        "FIELD_HEADER_PASSWORD_REGEX" : "Регулярное выражение для пароля:",
+        "FIELD_HEADER_PORT"           : "Порт:",
+
+        "FIELD_OPTION_FONT_SIZE_8"     : "8",
+        "FIELD_OPTION_FONT_SIZE_9"     : "9",
+        "FIELD_OPTION_FONT_SIZE_10"    : "10",
+        "FIELD_OPTION_FONT_SIZE_11"    : "11",
+        "FIELD_OPTION_FONT_SIZE_12"    : "12",
+        "FIELD_OPTION_FONT_SIZE_14"    : "14",
+        "FIELD_OPTION_FONT_SIZE_18"    : "18",
+        "FIELD_OPTION_FONT_SIZE_24"    : "24",
+        "FIELD_OPTION_FONT_SIZE_30"    : "30",
+        "FIELD_OPTION_FONT_SIZE_36"    : "36",
+        "FIELD_OPTION_FONT_SIZE_48"    : "48",
+        "FIELD_OPTION_FONT_SIZE_60"    : "60",
+        "FIELD_OPTION_FONT_SIZE_72"    : "72",
+        "FIELD_OPTION_FONT_SIZE_96"    : "96",
+        "FIELD_OPTION_FONT_SIZE_EMPTY" : "",
+
+        "NAME" : "Telnet"
+
+    },
+
+    "PROTOCOL_VNC" : {
+
+        "FIELD_HEADER_AUDIO_SERVERNAME" : "Название аудио-сервера:",
+        "FIELD_HEADER_COLOR_DEPTH"      : "Глубина цвета:",
+        "FIELD_HEADER_CURSOR"           : "Курсор:",
+        "FIELD_HEADER_DEST_HOST"        : "Repeater destination host:",
+        "FIELD_HEADER_DEST_PORT"        : "Repeater destination port:",
+        "FIELD_HEADER_ENABLE_AUDIO"     : "Включить звук:",
+        "FIELD_HEADER_ENABLE_SFTP"      : "Включить SFTP:",
+        "FIELD_HEADER_HOSTNAME"         : "Название сервера:",
+        "FIELD_HEADER_PASSWORD"         : "Пароль:",
+        "FIELD_HEADER_PORT"             : "Порт:",
+        "FIELD_HEADER_READ_ONLY"        : "Только просмотр:",
+        "FIELD_HEADER_SFTP_HOSTNAME"    : "Название сервера:",
+        "FIELD_HEADER_SFTP_PASSPHRASE"  : "Секретная фраза:",
+        "FIELD_HEADER_SFTP_PASSWORD"    : "Пароль:",
+        "FIELD_HEADER_SFTP_PORT"        : "Порт:",
+        "FIELD_HEADER_SFTP_PRIVATE_KEY" : "Приватный ключ:",
+        "FIELD_HEADER_SFTP_USERNAME"    : "Имя пользователя:",
+        "FIELD_HEADER_SWAP_RED_BLUE"    : "Поменять синий и красный компоненты:",
+
+        "FIELD_OPTION_COLOR_DEPTH_8"     : "256 цветов",
+        "FIELD_OPTION_COLOR_DEPTH_16"    : "Low color (16-бит)",
+        "FIELD_OPTION_COLOR_DEPTH_24"    : "True color (24-бит)",
+        "FIELD_OPTION_COLOR_DEPTH_32"    : "True color (32-бит)",
+        "FIELD_OPTION_COLOR_DEPTH_EMPTY" : "",
+
+        "FIELD_OPTION_CURSOR_EMPTY"  : "",
+        "FIELD_OPTION_CURSOR_LOCAL"  : "Локальный",
+        "FIELD_OPTION_CURSOR_REMOTE" : "Удаленный",
+
+        "NAME" : "VNC",
+
+        "SECTION_HEADER_CLIPBOARD" : "Буфер обмена"
+
+    },
+
+    "SETTINGS" : {
+
+        "SECTION_HEADER_SETTINGS" : "Настройки"
+
+    },
+
+    "SETTINGS_CONNECTIONS" : {
+
+        "ACTION_ACKNOWLEDGE"          : "@:APP.ACTION_ACKNOWLEDGE",
+        "ACTION_NEW_CONNECTION"       : "Новое подключение",
+        "ACTION_NEW_CONNECTION_GROUP" : "Новая группа",
+
+        "DIALOG_HEADER_ERROR" : "@:APP.DIALOG_HEADER_ERROR",
+
+        "FIELD_PLACEHOLDER_FILTER" : "@:APP.FIELD_PLACEHOLDER_FILTER",
+
+        "HELP_CONNECTIONS"   : "Нажмите на подключение, чтобы управлять им. В зависимости от прав доступа возможно добавление и удаление подключений, а также изменение их свойств (протокол, название сервера, порт и пр.).",
+
+        "INFO_ACTIVE_USER_COUNT" : "@:APP.INFO_ACTIVE_USER_COUNT",
+
+        "SECTION_HEADER_CONNECTIONS"     : "Подключения"
+
+    },
+
+    "SETTINGS_CONNECTION_HISTORY" : {
+
+        "FIELD_PLACEHOLDER_FILTER" : "@:APP.FIELD_PLACEHOLDER_FILTER",
+
+        "FORMAT_DATE" : "@:APP.FORMAT_DATE_TIME_PRECISE",
+
+        "INFO_CONNECTION_DURATION_UNKNOWN" : "--",
+
+        "TABLE_HEADER_SESSION_CONNECTION_NAME" : "Название подключения",
+        "TABLE_HEADER_SESSION_DURATION"        : "Продолжительность",
+        "TABLE_HEADER_SESSION_STARTDATE"       : "Время начала",
+        "TABLE_HEADER_SESSION_USERNAME"        : "Имя пользователя",
+
+        "TEXT_HISTORY_DURATION" : "@:APP.TEXT_HISTORY_DURATION"
+
+    },
+
+    "SETTINGS_PREFERENCES" : {
+
+        "ACTION_ACKNOWLEDGE"        : "@:APP.ACTION_ACKNOWLEDGE",
+        "ACTION_CANCEL"             : "@:APP.ACTION_CANCEL",
+        "ACTION_UPDATE_PASSWORD"    : "@:APP.ACTION_UPDATE_PASSWORD",
+
+        "DIALOG_HEADER_ERROR" : "@:APP.DIALOG_HEADER_ERROR",
+
+        "ERROR_PASSWORD_BLANK"    : "@:APP.ERROR_PASSWORD_BLANK",
+        "ERROR_PASSWORD_MISMATCH" : "@:APP.ERROR_PASSWORD_MISMATCH",
+
+        "FIELD_HEADER_LANGUAGE"           : "Язык:",
+        "FIELD_HEADER_PASSWORD"           : "Пароль:",
+        "FIELD_HEADER_PASSWORD_OLD"       : "Текущий пароль:",
+        "FIELD_HEADER_PASSWORD_NEW"       : "Новый пароль:",
+        "FIELD_HEADER_PASSWORD_NEW_AGAIN" : "Подтверждение пароля:",
+        "FIELD_HEADER_USERNAME"           : "Имя пользователя:",
+
+        "HELP_DEFAULT_INPUT_METHOD" : "Режим ввода по умолчанию определяет, каким образом нажатия на клавиатуру будут передаваться Guacamole. Изменение данной настройки может быть полезным при работе с мобильных устройств или при вводе через IME. Данная настройка может быть сделана для каждого подключения через основное меню Guacamole.",
+        "HELP_DEFAULT_MOUSE_MODE"   : "Режим эмуляции мыши по умолчанию определяет, каким образом мышь на удаленном сервере будет реагировать на прикосновения для новых подключений. Данная настройка может быть переопределена для каждого подключения через основное меню Guacamole.",
+        "HELP_INPUT_METHOD_NONE"    : "@:CLIENT.HELP_INPUT_METHOD_NONE",
+        "HELP_INPUT_METHOD_OSK"     : "@:CLIENT.HELP_INPUT_METHOD_OSK",
+        "HELP_INPUT_METHOD_TEXT"    : "@:CLIENT.HELP_INPUT_METHOD_TEXT",
+        "HELP_LANGUAGE"             : "Выберите другой язык из списка ниже, чтобы использовать его в Guacamole. Доступность опций зависит от установленных в системе языков.",
+        "HELP_MOUSE_MODE_ABSOLUTE"  : "@:CLIENT.HELP_MOUSE_MODE_ABSOLUTE",
+        "HELP_MOUSE_MODE_RELATIVE"  : "@:CLIENT.HELP_MOUSE_MODE_RELATIVE",
+        "HELP_UPDATE_PASSWORD"      : "Если вы хотите изменить пароль, укажите ваш текущий пароль и дважды новый пароль. Затем нажмите на \"Изменить пароль\". Изменения вступят в силу моментально.",
+
+        "INFO_PASSWORD_CHANGED" : "Пароль успешно изменен.",
+
+        "NAME_INPUT_METHOD_NONE" : "@:CLIENT.NAME_INPUT_METHOD_NONE",
+        "NAME_INPUT_METHOD_OSK"  : "@:CLIENT.NAME_INPUT_METHOD_OSK",
+        "NAME_INPUT_METHOD_TEXT" : "@:CLIENT.NAME_INPUT_METHOD_TEXT",
+
+        "SECTION_HEADER_DEFAULT_INPUT_METHOD" : "Метод ввода по умолчанию",
+        "SECTION_HEADER_DEFAULT_MOUSE_MODE"   : "Режим эмуляции мыши по умолчанию",
+        "SECTION_HEADER_UPDATE_PASSWORD"      : "Изменить пароль"
+
+    },
+
+    "SETTINGS_USERS" : {
+
+        "ACTION_ACKNOWLEDGE"   : "@:APP.ACTION_ACKNOWLEDGE",
+        "ACTION_NEW_USER"      : "Новый пользователь",
+
+        "DIALOG_HEADER_ERROR" : "@:APP.DIALOG_HEADER_ERROR",
+
+        "FIELD_PLACEHOLDER_FILTER" : "@:APP.FIELD_PLACEHOLDER_FILTER",
+
+        "HELP_USERS" : "Нажмите на пользователя, чтобы управлять им. В зависимости от прав доступа возможно добавление и удаление пользователей, а также изменение паролей.",
+
+        "SECTION_HEADER_USERS"       : "Пользователи"
+
+    },
+
+    "SETTINGS_SESSIONS" : {
+
+        "ACTION_ACKNOWLEDGE" : "@:APP.ACTION_ACKNOWLEDGE",
+        "ACTION_CANCEL"      : "@:APP.ACTION_CANCEL",
+        "ACTION_DELETE"      : "Завершить сессии",
+
+        "DIALOG_HEADER_CONFIRM_DELETE" : "Завершение сессий",
+        "DIALOG_HEADER_ERROR"          : "@:APP.DIALOG_HEADER_ERROR",
+
+        "FIELD_PLACEHOLDER_FILTER" : "@:APP.FIELD_PLACEHOLDER_FILTER",
+
+        "FORMAT_STARTDATE" : "@:APP.FORMAT_DATE_TIME_PRECISE",
+
+        "HELP_SESSIONS" : "Все активные в настоящий момент сессии Guacamole представлены здесь. Если вы хотите завершить одну или несколько сессий, выберите нужные сессии и нажмите на \"Завершить сессии\". Принудительное завершение сессий приведет к немедленному отключению пользователей, которые ими пользуются.",
+
+        "INFO_NO_SESSIONS" : "Нет активных сессий",
+
+        "SECTION_HEADER_SESSIONS" : "Активные сессии",
+
+        "TABLE_HEADER_SESSION_USERNAME"        : "Имя пользователя",
+        "TABLE_HEADER_SESSION_STARTDATE"       : "Активен с",
+        "TABLE_HEADER_SESSION_REMOTEHOST"      : "Удаленный сервер",
+        "TABLE_HEADER_SESSION_CONNECTION_NAME" : "Название подключения",
+
+        "TEXT_CONFIRM_DELETE" : "Вы уверены, что хотите завершить все выбранные сессии? Пользователи, работающие в этих сессиях, будут немедленно отключены."
+
+    },
+
+    "USER_MENU" : {
+
+        "ACTION_LOGOUT"             : "@:APP.ACTION_LOGOUT",
+        "ACTION_MANAGE_CONNECTIONS" : "@:APP.ACTION_MANAGE_CONNECTIONS",
+        "ACTION_MANAGE_PREFERENCES" : "@:APP.ACTION_MANAGE_PREFERENCES",
+        "ACTION_MANAGE_SESSIONS"    : "@:APP.ACTION_MANAGE_SESSIONS",
+        "ACTION_MANAGE_SETTINGS"    : "@:APP.ACTION_MANAGE_SETTINGS",
+        "ACTION_MANAGE_USERS"       : "@:APP.ACTION_MANAGE_USERS",
+        "ACTION_NAVIGATE_HOME"      : "@:APP.ACTION_NAVIGATE_HOME"
+
+    }
+
+}
diff --git a/pom.xml b/pom.xml
index 8e47164..e7bb72a 100644
--- a/pom.xml
+++ b/pom.xml
@@ -5,7 +5,7 @@
     <groupId>org.glyptodon.guacamole</groupId>
     <artifactId>guacamole-client</artifactId>
     <packaging>pom</packaging>
-    <version>0.8.3</version>
+    <version>0.9.9</version>
     <name>guacamole-client</name>
     <url>http://guac-dev.org/</url>
 
@@ -27,6 +27,11 @@
         <!-- Guacamole JavaScript API -->
         <module>guacamole-common-js</module>
 
+        <!-- Authentication extensions -->
+        <module>extensions/guacamole-auth-jdbc</module>
+        <module>extensions/guacamole-auth-ldap</module>
+        <module>extensions/guacamole-auth-noauth</module>
+
     </modules>
 
     <build>
@@ -35,12 +40,13 @@
             <!-- Assembly plugin - for easy distribution -->
             <plugin>
                 <artifactId>maven-assembly-plugin</artifactId>
-                <version>2.4</version>
+                <version>2.5.3</version>
 
                 <!-- Build project archive -->
                 <configuration>
                     <finalName>${project.artifactId}-${project.version}</finalName>
                     <appendAssemblyId>false</appendAssemblyId>
+                    <tarLongFileMode>gnu</tarLongFileMode>
                     <descriptors>
                         <descriptor>project-assembly.xml</descriptor>
                     </descriptors>
diff --git a/project-assembly.xml b/project-assembly.xml
index ec1d724..7745bb9 100644
--- a/project-assembly.xml
+++ b/project-assembly.xml
@@ -12,7 +12,6 @@
     <fileSets>
         <fileSet>
             <directory>${project.basedir}</directory>
-            <outputDirectory>/</outputDirectory>
             <useDefaultExcludes>true</useDefaultExcludes>
             <excludes>
                 <exclude>**/*.log</exclude>

-- 
Alioth's /usr/local/bin/git-commit-notice on /srv/git.debian.org/git/debian-edu/pkg-team/guacamole-client.git



More information about the debian-edu-commits mailing list