GUACAMOLE-462: Add extension for automatically associated session recordings, logs, etc. with history entries.

This commit is contained in:
Michael Jumper
2022-02-10 17:17:48 -08:00
parent c386845f24
commit a123eacab5
12 changed files with 777 additions and 0 deletions

View File

@@ -0,0 +1,3 @@
src/main/resources/generated/
target/
*~

View File

@@ -0,0 +1,52 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<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.apache.guacamole</groupId>
<artifactId>guacamole-history-recording-storage</artifactId>
<packaging>jar</packaging>
<version>1.4.0</version>
<name>guacamole-history-recording-storage</name>
<url>http://guacamole.apache.org/</url>
<parent>
<groupId>org.apache.guacamole</groupId>
<artifactId>extensions</artifactId>
<version>1.4.0</version>
<relativePath>../</relativePath>
</parent>
<dependencies>
<!-- Guacamole Extension API -->
<dependency>
<groupId>org.apache.guacamole</groupId>
<artifactId>guacamole-ext</artifactId>
<version>1.4.0</version>
<scope>provided</scope>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<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 licenses and extension .jar -->
<fileSets>
<!-- Include licenses -->
<fileSet>
<outputDirectory></outputDirectory>
<directory>target/licenses</directory>
</fileSet>
<!-- Include extension .jar -->
<fileSet>
<directory>target</directory>
<outputDirectory></outputDirectory>
<includes>
<include>*.jar</include>
</includes>
</fileSet>
</fileSets>
</assembly>

View File

@@ -0,0 +1,49 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.guacamole.history;
import org.apache.guacamole.history.user.HistoryUserContext;
import org.apache.guacamole.GuacamoleException;
import org.apache.guacamole.net.auth.AbstractAuthenticationProvider;
import org.apache.guacamole.net.auth.AuthenticatedUser;
import org.apache.guacamole.net.auth.Credentials;
import org.apache.guacamole.net.auth.UserContext;
/**
* AuthenticationProvider implementation which automatically associates history
* entries with session recordings, typescripts, etc. History association is
* determined by matching the history entry UUID with the filenames of files
* located within a standardized/configurable directory.
*/
public class HistoryAuthenticationProvider extends AbstractAuthenticationProvider {
@Override
public String getIdentifier() {
return "recording-storage";
}
@Override
public UserContext decorate(UserContext context,
AuthenticatedUser authenticatedUser, Credentials credentials)
throws GuacamoleException {
return new HistoryUserContext(context.self(), context);
}
}

View File

@@ -0,0 +1,71 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.guacamole.history.connection;
import org.apache.guacamole.GuacamoleException;
import org.apache.guacamole.net.auth.ActivityRecordSet;
import org.apache.guacamole.net.auth.Connection;
import org.apache.guacamole.net.auth.ConnectionRecord;
import org.apache.guacamole.net.auth.DelegatingConnection;
import org.apache.guacamole.net.auth.User;
/**
* Connection implementation that automatically defines ActivityLogs for
* files that relate to history entries associated with the wrapped connection.
*/
public class HistoryConnection extends DelegatingConnection {
/**
* The current Guacamole user.
*/
private final User currentUser;
/**
* Creates a new HistoryConnection that wraps the given connection,
* automatically associating history entries with ActivityLogs based on
* related files (session recordings, typescripts, etc.).
*
* @param currentUser
* The current Guacamole user.
*
* @param connection
* The connection to wrap.
*/
public HistoryConnection(User currentUser, Connection connection) {
super(connection);
this.currentUser = currentUser;
}
/**
* Returns the connection wrapped by this HistoryConnection.
*
* @return
* The connection wrapped by this HistoryConnection.
*/
public Connection getWrappedConnection() {
return getDelegateConnection();
}
@Override
public ActivityRecordSet<ConnectionRecord> getConnectionHistory() throws GuacamoleException {
return new RecordedConnectionActivityRecordSet(currentUser, super.getConnectionHistory());
}
}

View File

@@ -0,0 +1,309 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.guacamole.history.connection;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.Reader;
import java.net.MalformedURLException;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import org.apache.guacamole.GuacamoleException;
import org.apache.guacamole.environment.Environment;
import org.apache.guacamole.environment.LocalEnvironment;
import org.apache.guacamole.io.GuacamoleReader;
import org.apache.guacamole.io.ReaderGuacamoleReader;
import org.apache.guacamole.language.TranslatableMessage;
import org.apache.guacamole.net.auth.ActivityLog;
import org.apache.guacamole.net.auth.ConnectionRecord;
import org.apache.guacamole.net.auth.DelegatingConnectionRecord;
import org.apache.guacamole.net.auth.FileActivityLog;
import org.apache.guacamole.properties.FileGuacamoleProperty;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* ConnectionRecord implementation that automatically defines ActivityLogs for
* files that relate to the wrapped record.
*/
public class HistoryConnectionRecord extends DelegatingConnectionRecord {
/**
* Logger for this class.
*/
private static final Logger logger = LoggerFactory.getLogger(HistoryConnectionRecord.class);
/**
* The namespace for URL UUIDs as defined by RFC 4122.
*/
private static final UUID UUID_NAMESPACE_URL = UUID.fromString("6ba7b811-9dad-11d1-80b4-00c04fd430c8");
/**
* The filename suffix of typescript timing files.
*/
private static final String TIMING_FILE_SUFFIX = ".timing";
/**
* The default directory to search for associated session recordings, if
* not overridden with the "recording-search-path" property.
*/
private static final File DEFAULT_RECORDING_SEARCH_PATH = new File("/var/lib/guacamole/recordings");
/**
* The directory to search for associated session recordings. By default,
* "/var/lib/guacamole/recordings" will be used.
*/
private static final FileGuacamoleProperty RECORDING_SEARCH_PATH = new FileGuacamoleProperty() {
@Override
public String getName() {
return "recording-search-path";
}
};
/**
* The recording file associated with the wrapped connection record. This
* may be a single file or a directory that may contain any number of
* relevant recordings.
*/
private final File recording;
/**
* Creates a new HistoryConnectionRecord that wraps the given
* ConnectionRecord, automatically associating ActivityLogs based on
* related files (session recordings, typescripts, etc.).
*
* @param record
* The ConnectionRecord to wrap.
*
* @throws GuacamoleException
* If the configured path for stored recordings cannot be read.
*/
public HistoryConnectionRecord(ConnectionRecord record) throws GuacamoleException {
super(record);
Environment environment = LocalEnvironment.getInstance();
File recordingPath = environment.getProperty(RECORDING_SEARCH_PATH,
DEFAULT_RECORDING_SEARCH_PATH);
String uuid = record.getUUID().toString();
File recordingFile = new File(recordingPath, uuid);
this.recording = recordingFile.canRead() ? recordingFile : null;
}
/**
* Returns whether the given file appears to be a Guacamole session
* recording. As there is no standard extension for session recordings,
* this is determined by attempting to read a single Guacamole instruction
* from the file.
*
* @param file
* The file to test.
*
* @return
* true if the file appears to be a Guacamole session recording, false
* otherwise.
*/
private boolean isSessionRecording(File file) {
try (Reader reader = new InputStreamReader(new FileInputStream(file), StandardCharsets.UTF_8)) {
GuacamoleReader guacReader = new ReaderGuacamoleReader(reader);
if (guacReader.readInstruction() != null)
return true;
}
catch (GuacamoleException e) {
logger.debug("File \"{}\" does not appear to be a session "
+ "recording, as it could not be parsed as Guacamole "
+ "protocol data.", file, e);
}
catch (IOException e) {
logger.warn("Possible session recording \"{}\" could not be "
+ "identified as it cannot be read: {}", file, e.getMessage());
logger.debug("Possible session recording \"{}\" could not be read.", file, e);
}
return false;
}
/**
* Returns whether the given file appears to be a typescript (text
* recording of a terminal session). As there is no standard extension for
* session recordings, this is determined by testing whether there is an
* associated timing file. Guacamole will always include a timing file for
* its typescripts.
*
* @param file
* The file to test.
*
* @return
* true if the file appears to be a typescript, false otherwise.
*/
private boolean isTypescript(File file) {
return new File(file.getAbsolutePath() + TIMING_FILE_SUFFIX).exists();
}
/**
* Returns whether the given file appears to be a typescript timing file.
* Typescript timing files have the standard extension ".timing".
*
* @param file
* The file to test.
*
* @return
* true if the file appears to be a typescript timing file, false
* otherwise.
*/
private boolean isTypescriptTiming(File file) {
return file.getName().endsWith(TIMING_FILE_SUFFIX);
}
/**
* Returns the type of session recording or log contained within the given
* file by inspecting its name and contents.
*
* @param file
* The file to test.
*
* @return
* The type of session recording or log contained within the given
* file, or null if this cannot be determined.
*/
private ActivityLog.Type getType(File file) {
if (isSessionRecording(file))
return ActivityLog.Type.GUACAMOLE_SESSION_RECORDING;
if (isTypescript(file))
return ActivityLog.Type.TYPESCRIPT;
if (isTypescriptTiming(file))
return ActivityLog.Type.TYPESCRIPT_TIMING;
return ActivityLog.Type.SERVER_LOG;
}
/**
* Returns a new ActivityLog instance representing the session recording or
* log contained within the given file. If the type of recording/log cannot
* be determined, or if the file is unreadable, null is returned.
*
* @param file
* The file to produce an ActivityLog instance for.
*
* @return
* A new ActivityLog instance representing the recording/log contained
* within the given file, or null if the file is unreadable or cannot
* be identified.
*/
private ActivityLog getActivityLog(File file) {
// Verify file can actually be read
if (!file.canRead()) {
logger.warn("Ignoring file \"{}\" relevant to connection history "
+ "record as it cannot be read.", file);
return null;
}
// Determine type of recording/log by inspecting file
ActivityLog.Type logType = getType(file);
if (logType == null) {
logger.warn("Recording/log type of \"{}\" cannot be determined.", file);
return null;
}
return new FileActivityLog(
logType,
new TranslatableMessage("RECORDING_STORAGE.INFO_" + logType.name()),
file
);
}
/**
* Adds an ActivityLog instance representing the session recording or log
* contained within the given file to the given map of logs. If no
* ActivityLog can be produced for the given file (it is unreadable or
* cannot be identified), this function has no effect.
*
* @param logs
* The map of logs to add the ActivityLog to.
*
* @param file
* The file to produce an ActivityLog instance for.
*/
private void addActivityLog(Map<String, ActivityLog> logs, File file) {
ActivityLog log = getActivityLog(file);
if (log == null)
return;
// Convert file into deterministic name UUID within URL namespace
UUID fileUUID;
try {
byte[] urlBytes = file.toURI().toURL().toString().getBytes(StandardCharsets.UTF_8);
fileUUID = UUID.nameUUIDFromBytes(ByteBuffer.allocate(16 + urlBytes.length)
.putLong(UUID_NAMESPACE_URL.getMostSignificantBits())
.putLong(UUID_NAMESPACE_URL.getLeastSignificantBits())
.put(urlBytes)
.array());
}
catch (MalformedURLException e) {
logger.warn("Ignoring file \"{}\" as a unique URL and UUID for that file could not be generated: {}", e.getMessage());
logger.debug("URL for file \"{}\" could not be determined.", file, e);
return;
}
logs.put(fileUUID.toString(), log);
}
@Override
public Map<String, ActivityLog> getLogs() {
// Do nothing if there are no associated logs
if (recording == null)
return super.getLogs();
// Add associated log (or logs, if this is a directory)
Map<String, ActivityLog> logs = new HashMap<>(super.getLogs());
if (recording.isDirectory()) {
Arrays.asList(recording.listFiles()).stream()
.forEach((file) -> addActivityLog(logs, file));
}
else
addActivityLog(logs, recording);
return logs;
}
}

View File

@@ -0,0 +1,125 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.guacamole.history.connection;
import java.util.Collections;
import java.util.Set;
import org.apache.guacamole.GuacamoleException;
import org.apache.guacamole.net.auth.ActivityRecordSet;
import org.apache.guacamole.net.auth.ConnectionRecord;
import org.apache.guacamole.net.auth.DecoratingActivityRecordSet;
import org.apache.guacamole.net.auth.Permissions;
import org.apache.guacamole.net.auth.User;
import org.apache.guacamole.net.auth.permission.ObjectPermission;
import org.apache.guacamole.net.auth.permission.SystemPermission;
/**
* ActivityRecordSet implementation that automatically defines ActivityLogs for
* files that relate to history entries within the wrapped set.
*/
public class RecordedConnectionActivityRecordSet extends DecoratingActivityRecordSet<ConnectionRecord> {
/**
* Whether the current user is an administrator.
*/
private final boolean isAdmin;
/**
* The overall set of connection permissions defined for the current user.
*/
private final Set<ObjectPermission> connectionPermissions;
/**
* Creates a new RecordedConnectionActivityRecordSet that wraps the given
* ActivityRecordSet, automatically associating history entries with
* ActivityLogs based on related files (session recordings, typescripts,
* etc.).
*
* @param currentUser
* The current Guacamole user.
*
* @param activityRecordSet
* The ActivityRecordSet to wrap.
*
* @throws GuacamoleException
* If the permissions for the current user cannot be retrieved.
*/
public RecordedConnectionActivityRecordSet(User currentUser,
ActivityRecordSet<ConnectionRecord> activityRecordSet)
throws GuacamoleException {
super(activityRecordSet);
// Determine whether current user is an administrator
Permissions perms = currentUser.getEffectivePermissions();
isAdmin = perms.getSystemPermissions().hasPermission(SystemPermission.Type.ADMINISTER);
// If not an admin, additionally pull specific connection permissions
if (isAdmin)
connectionPermissions = Collections.emptySet();
else
connectionPermissions = perms.getConnectionPermissions().getPermissions();
}
/**
* Returns whether the current user has permission to view the logs
* associated with the given history record. It is already given that the
* user has permission to view the history record itself. This extension
* considers a user to have permission to view history logs if they are
* an administrator or if they have permission to edit the associated
* connection.
*
* @param record
* The record to check.
*
* @return
* true if the current user has permission to view the logs associated
* with the given record, false otherwise.
*/
private boolean canViewLogs(ConnectionRecord record) {
// Administrator can always view
if (isAdmin)
return true;
// Non-administrator CANNOT view if permissions cannot be verified
String identifier = record.getConnectionIdentifier();
if (identifier == null)
return false;
// Non-administer can only view if they implicitly have permission to
// configure recordings (they have permission to edit)
ObjectPermission canUpdate = new ObjectPermission(ObjectPermission.Type.UPDATE, identifier);
return connectionPermissions.contains(canUpdate);
}
@Override
protected ConnectionRecord decorate(ConnectionRecord record) throws GuacamoleException {
// Provide access to logs only if permission is granted
if (canViewLogs(record))
return new HistoryConnectionRecord(record);
return record;
}
}

View File

@@ -0,0 +1,84 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.guacamole.history.user;
import org.apache.guacamole.GuacamoleException;
import org.apache.guacamole.history.connection.HistoryConnection;
import org.apache.guacamole.history.connection.RecordedConnectionActivityRecordSet;
import org.apache.guacamole.net.auth.ActivityRecordSet;
import org.apache.guacamole.net.auth.Connection;
import org.apache.guacamole.net.auth.ConnectionRecord;
import org.apache.guacamole.net.auth.DecoratingDirectory;
import org.apache.guacamole.net.auth.DelegatingUserContext;
import org.apache.guacamole.net.auth.Directory;
import org.apache.guacamole.net.auth.User;
import org.apache.guacamole.net.auth.UserContext;
/**
* UserContext implementation that automatically defines ActivityLogs for
* files that relate to history entries.
*/
public class HistoryUserContext extends DelegatingUserContext {
/**
* The current Guacamole user.
*/
private final User currentUser;
/**
* Creates a new HistoryUserContext that wraps the given UserContext,
* automatically associating history entries with ActivityLogs based on
* related files (session recordings, typescripts, etc.).
*
* @param currentUser
* The current Guacamole user.
*
* @param context
* The UserContext to wrap.
*/
public HistoryUserContext(User currentUser, UserContext context) {
super(context);
this.currentUser = currentUser;
}
@Override
public Directory<Connection> getConnectionDirectory() throws GuacamoleException {
return new DecoratingDirectory<Connection>(super.getConnectionDirectory()) {
@Override
protected Connection decorate(Connection object) {
return new HistoryConnection(currentUser, object);
}
@Override
protected Connection undecorate(Connection object) throws GuacamoleException {
return ((HistoryConnection) object).getWrappedConnection();
}
};
}
@Override
public ActivityRecordSet<ConnectionRecord> getConnectionHistory()
throws GuacamoleException {
return new RecordedConnectionActivityRecordSet(currentUser, super.getConnectionHistory());
}
}

View File

@@ -0,0 +1,16 @@
{
"guacamoleVersion" : "1.4.0",
"name" : "Session Recording Storage",
"namespace" : "recording-storage",
"authProviders" : [
"org.apache.guacamole.history.HistoryAuthenticationProvider"
],
"translations" : [
"translations/en.json"
]
}

View File

@@ -0,0 +1,14 @@
{
"DATA_SOURCE_RECORDING_STORAGE" : {
"NAME" : "Session Recording Storage"
},
"RECORDING_STORAGE" : {
"INFO_GUACAMOLE_SESSION_RECORDING" : "Graphical recording of remote desktop session",
"INFO_SERVER_LOG" : "Server/system log",
"INFO_TYPESCRIPT" : "Text recording of terminal session",
"INFO_TYPESCRIPT_TIMING" : "Timing information for text recording of terminal session"
}
}