Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ public abstract class AbstractGuacamoleTunnel implements GuacamoleTunnel {
*/
private final ReentrantLock writerLock;

/**
* The time at which this tunnel was created.
*/
private final long creationTime;

/**
* Creates a new GuacamoleTunnel which synchronizes access to the
* Guacamole instruction stream associated with the underlying
Expand All @@ -50,6 +55,7 @@ public abstract class AbstractGuacamoleTunnel implements GuacamoleTunnel {
public AbstractGuacamoleTunnel() {
readerLock = new ReentrantLock();
writerLock = new ReentrantLock();
creationTime = System.currentTimeMillis();
}

/**
Expand Down Expand Up @@ -125,4 +131,9 @@ public boolean isOpen() {
return getSocket().isOpen();
}

@Override
public long getCreationTime() {
return creationTime;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -96,4 +96,9 @@ public boolean isOpen() {
return tunnel.isOpen();
}

@Override
public long getCreationTime() {
return tunnel.getCreationTime();
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -121,4 +121,11 @@ public interface GuacamoleTunnel {
*/
boolean isOpen();

/**
* Returns the creation time of this GuacamoleTunnel.
*
* @return The creation time of this GuacamoleTunnel.
*/
long getCreationTime();

}
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,12 @@

package org.apache.guacamole;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

import org.apache.guacamole.net.GuacamoleTunnel;
import org.apache.guacamole.net.auth.AuthenticatedUser;
import org.apache.guacamole.net.auth.AuthenticationProvider;
Expand Down Expand Up @@ -244,9 +246,11 @@ public Map<String, UserTunnel> getTunnels() {
*
* @param tunnel The tunnel to associate with this session.
*/

public void addTunnel(UserTunnel tunnel) {
this.access();
tunnels.put(tunnel.getUUID().toString(), tunnel);
String tunnelId = tunnel.getUUID().toString();
tunnels.put(tunnelId, tunnel);
}

/**
Expand Down Expand Up @@ -284,6 +288,78 @@ public long getLastAccessedTime() {
return lastAccessedTime;
}


/**
* Returns the age of the oldest tunnel in this session, in milliseconds.
* If no tunnels exist, returns 0. Invoking this function does not affect
* the last access time of this session.
*
* @return
* The age of the oldest tunnel in milliseconds, or 0 if no tunnels exist.
*/
public long getOldestTunnelAge() {
if (tunnels.isEmpty()) {
return 0;
}

long currentTime = System.currentTimeMillis();
long oldestCreationTime = tunnels.values().stream()
.mapToLong(tunnel -> tunnel.getCreationTime())
.min()
.orElse(currentTime);

return currentTime - oldestCreationTime;
}

/**
* Closes and removes any tunnels in this session that exceed the specified
* age limit. Invoking this function does not affect the last access time
* of this session.
*
* @param maxAge
* The maximum allowed age of tunnels in milliseconds. Tunnels older
* than this will be closed and removed.
*
* @return
* The number of tunnels that were closed and removed.
*/
public int closeExpiredTunnels(long maxAge) {
if (maxAge <= 0 || tunnels.isEmpty()) {
return 0;
}

long currentTime = System.currentTimeMillis();
int closedCount = 0;

// Find tunnels that exceed the age limit
List<String> expiredTunnelIds = new ArrayList<>();
for (Map.Entry<String, UserTunnel> entry : tunnels.entrySet()) {
long tunnelAge = currentTime - entry.getValue().getCreationTime();
if (tunnelAge >= maxAge) {
expiredTunnelIds.add(entry.getKey());
}
}

// Close and remove expired tunnels
for (String tunnelId : expiredTunnelIds) {
UserTunnel tunnel = tunnels.get(tunnelId);
if (tunnel != null) {
try {
tunnel.close();
logger.debug("Closed tunnel \"{}\" due to connection timeout.", tunnelId);
}
catch (GuacamoleException e) {
logger.debug("Unable to close expired tunnel \"" + tunnelId + "\".", e);
}
// Remove from the tunnels map regardless of whether close succeeded
tunnels.remove(tunnelId);
closedCount++;
}
}

return closedCount;
}

/**
* Closes all associated tunnels and prevents any further use of this
* session.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,18 @@ public class HashTokenSessionMap implements TokenSessionMap {

};

/**
* The connection timeout for individual Guacamole connections, in minutes.
* If 0, connections will not be automatically terminated based on age.
*/
private final IntegerGuacamoleProperty CONNECTION_TIMEOUT =
new IntegerGuacamoleProperty() {

@Override
public String getName() { return "connection-timeout"; }

};

/**
* Create a new HashTokenSessionMap configured using the given environment.
*
Expand All @@ -75,6 +87,7 @@ public class HashTokenSessionMap implements TokenSessionMap {
public HashTokenSessionMap(Environment environment) {

int sessionTimeoutValue;
int connectionTimeoutValue;

// Read session timeout from guacamole.properties
try {
Expand All @@ -85,10 +98,25 @@ public HashTokenSessionMap(Environment environment) {
logger.debug("Error while reading session timeout value.", e);
sessionTimeoutValue = 60;
}

// Read connection timeout from guacamole.properties
try {
connectionTimeoutValue = environment.getProperty(CONNECTION_TIMEOUT, 0); // Disabled by default
}
catch (GuacamoleException e) {
logger.error("Unable to read guacamole.properties: {}", e.getMessage());
logger.debug("Error while reading connection timeout value.", e);
connectionTimeoutValue = 0;
Copy link

Copilot AI Sep 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Extra whitespace at the end of the line should be removed.

Suggested change
connectionTimeoutValue = 0;
connectionTimeoutValue = 0;

Copilot uses AI. Check for mistakes.
}

// 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);
if (connectionTimeoutValue > 0) {
logger.info("Connections will be terminated after {} minutes regardless of activity.", connectionTimeoutValue);
} else {
logger.info("Connection timeout disabled (set to 0).");
}
executor.scheduleAtFixedRate(new SessionEvictionTask(sessionTimeoutValue * 60000l, connectionTimeoutValue * 60000l), 1, 1, TimeUnit.MINUTES);

}

Expand All @@ -104,16 +132,26 @@ private class SessionEvictionTask implements Runnable {
*/
private final long sessionTimeout;

/**
* The maximum allowed age of any connection, in milliseconds.
* If 0, connections will not be terminated based on age.
*/
private final long connectionTimeout;

/**
* Creates a new task which automatically evicts sessions which are
* older than the specified timeout, or are marked as invalid by an
* extension.
*
* @param sessionTimeout The maximum age of any session, in
* milliseconds.
* @param connectionTimeout The maximum age of any connection, in
* milliseconds. If 0, connections will not be
* terminated based on age.
*/
public SessionEvictionTask(long sessionTimeout) {
public SessionEvictionTask(long sessionTimeout, long connectionTimeout) {
this.sessionTimeout = sessionTimeout;
this.connectionTimeout = connectionTimeout;
}

/**
Expand Down Expand Up @@ -145,6 +183,16 @@ private void evictExpiredOrInvalidSessions() {
entry.getKey());
entries.remove();
session.invalidate();
continue;
}

// Close any connections that have exceeded the connection timeout
if (connectionTimeout > 0) {
int closedConnections = session.closeExpiredTunnels(connectionTimeout);
if (closedConnections > 0) {
logger.debug("Closed {} expired connection(s) in session \"{}\".",
closedConnections, entry.getKey());
}
}

// Do not expire sessions which are active
Expand Down