diff --git a/guacamole/src/main/frontend/src/app/client/directives/guacClientNotification.js b/guacamole/src/main/frontend/src/app/client/directives/guacClientNotification.js index 03344b627f..952e71b2e1 100644 --- a/guacamole/src/main/frontend/src/app/client/directives/guacClientNotification.js +++ b/guacamole/src/main/frontend/src/app/client/directives/guacClientNotification.js @@ -45,15 +45,19 @@ angular.module('client').directive('guacClientNotification', [function guacClien function guacClientNotificationController($scope, $injector, $element) { // Required types + const ClientIdentifier = $injector.get('ClientIdentifier'); const ManagedClient = $injector.get('ManagedClient'); const ManagedClientState = $injector.get('ManagedClientState'); const Protocol = $injector.get('Protocol'); // Required services + const $interval = $injector.get('$interval'); const $location = $injector.get('$location'); const authenticationService = $injector.get('authenticationService'); const guacClientManager = $injector.get('guacClientManager'); const guacTranslate = $injector.get('guacTranslate'); + const connectionReachabilityService = $injector.get('connectionReachabilityService'); + const connectionService = $injector.get('connectionService'); const requestService = $injector.get('requestService'); const userPageService = $injector.get('userPageService'); @@ -66,6 +70,36 @@ angular.module('client').directive('guacClientNotification', [function guacClien */ $scope.status = false; + $scope.wolCountdown = 0; + $scope.wolCountdownActive = false; + $scope.wolMachineWasOff = false; + var wolTimer = null; + + var cancelWolTimer = function cancelWolTimer() { + if (wolTimer) { + $interval.cancel(wolTimer); + wolTimer = null; + } + $scope.wolCountdownActive = false; + // NOTE: wolMachineWasOff is intentionally NOT reset here. + // cancelWolTimer() is called from inside startWolCountdown() before + // wolMachineWasOff is set to true, so resetting it here would + // immediately clear the flag. Visibility is already controlled by + // wolCountdownActive via ng-show. + }; + + var startWolCountdown = function startWolCountdown(waitSeconds) { + cancelWolTimer(); + $scope.wolCountdown = waitSeconds; + $scope.wolCountdownActive = true; + wolTimer = $interval(function() { + $scope.wolCountdown--; + if ($scope.wolCountdown <= 0) { + cancelWolTimer(); + } + }, 1000); + }; + /** * All error codes for which automatic reconnection is appropriate when a * client error occurs. @@ -211,6 +245,28 @@ angular.module('client').directive('guacClientNotification', [function guacClien key : "CLIENT.TEXT_CLIENT_STATUS_" + connectionState.toUpperCase() } }; + + // Start WoL countdown only in the WAITING state + if (connectionState === ManagedClientState.ConnectionState.WAITING) { + try { + var clientId = ClientIdentifier.fromString($scope.client.id); + if (clientId.type === ClientIdentifier.Types.CONNECTION) { + // !== true covers both false (confirmed off) and undefined (not yet polled) + var wasOff = connectionReachabilityService.reachable[clientId.id] !== true; + connectionService.getConnectionParameters(clientId.dataSource, clientId.id) + ['then'](function(params) { + var waitTime = parseInt(params['wol-wait-time'], 10); + if (waitTime > 0 && wasOff) { + $scope.wolMachineWasOff = true; + startWolCountdown(waitTime); + } + }) + ['catch'](requestService.IGNORE); + } + } catch(e) { /* ignore if connection ID cannot be parsed */ } + } else { + cancelWolTimer(); + } } // Client error @@ -283,8 +339,10 @@ angular.module('client').directive('guacClientNotification', [function guacClien } // Hide status for all other states - else + else { + cancelWolTimer(); $scope.status = false; + } }; @@ -402,6 +460,9 @@ angular.module('client').directive('guacClientNotification', [function guacClien $scope.$on('guacBeforeKeydown', preventDefaultDuringNotification); $scope.$on('guacBeforeKeyup', preventDefaultDuringNotification); + // Cancel the WoL timer on scope destruction to prevent interval leaks + $scope.$on('$destroy', cancelWolTimer); + }]; return directive; diff --git a/guacamole/src/main/frontend/src/app/client/styles/client.css b/guacamole/src/main/frontend/src/app/client/styles/client.css index 4d452980b7..048455d3ee 100644 --- a/guacamole/src/main/frontend/src/app/client/styles/client.css +++ b/guacamole/src/main/frontend/src/app/client/styles/client.css @@ -134,4 +134,20 @@ body.client { .client .drop-pending .display > *{ opacity: 0.5; -} \ No newline at end of file +} +.wol-countdown { + text-align: center; + margin: 0.75em 0 0; + opacity: 0.9; +} +.wol-countdown .wol-status { + color: #ffd580; + font-size: 0.95em; + margin: 0 0 0.25em; + font-style: italic; +} +.wol-countdown .wol-timer { + color: #cce5ff; + font-size: 1.1em; + margin: 0; +} diff --git a/guacamole/src/main/frontend/src/app/client/templates/guacClientNotification.html b/guacamole/src/main/frontend/src/app/client/templates/guacClientNotification.html index 9948a2b1c2..76160b26c5 100644 --- a/guacamole/src/main/frontend/src/app/client/templates/guacClientNotification.html +++ b/guacamole/src/main/frontend/src/app/client/templates/guacClientNotification.html @@ -1,5 +1,9 @@
+
+

+

+
diff --git a/guacamole/src/main/frontend/src/app/groupList/directives/guacGroupList.js b/guacamole/src/main/frontend/src/app/groupList/directives/guacGroupList.js index 16ff9ddc0f..09820c2421 100644 --- a/guacamole/src/main/frontend/src/app/groupList/directives/guacGroupList.js +++ b/guacamole/src/main/frontend/src/app/groupList/directives/guacGroupList.js @@ -20,6 +20,7 @@ /** * A directive which displays the contents of a connection group within an * automatically-paginated view. + */ angular.module('groupList').directive('guacGroupList', [function guacGroupList() { @@ -40,7 +41,7 @@ angular.module('groupList').directive('guacGroupList', [function guacGroupList() * Arbitrary object which shall be made available to the connection * and connection group templates within the scope as * context. - * + * * @type Object */ context : '=', @@ -63,7 +64,7 @@ angular.module('groupList').directive('guacGroupList', [function guacGroupList() * 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 : '=', @@ -96,6 +97,8 @@ angular.module('groupList').directive('guacGroupList', [function guacGroupList() var dataSourceService = $injector.get('dataSourceService'); var requestService = $injector.get('requestService'); + var connectionReachabilityService = $injector.get('connectionReachabilityService'); + // Required types var GroupListItem = $injector.get('GroupListItem'); @@ -119,6 +122,19 @@ angular.module('groupList').directive('guacGroupList', [function guacGroupList() */ $scope.rootItems = []; + /** + * Exposes the reachability service to the scope so that the + * connection.html template can read + * reachabilityService.reachable[item.identifier] and apply the + * vm-running / vm-stopped CSS classes. + * + * Child scopes created by ng-repeat and ng-include inherit this + * object through the AngularJS prototype chain. + * + * @type {Object} + */ + $scope.reachabilityService = connectionReachabilityService; + /** * Returns the number of active usages of a given connection. * @@ -154,6 +170,24 @@ angular.module('groupList').directive('guacGroupList', [function guacGroupList() return !!$scope.templates[type]; }; + /** + * Recursively collects connection identifiers from a raw connection + * group and all its descendants, as returned by the REST API. + * + * @param {Object} group A connection group object from the REST API. + * @returns {String[]} Array of connection identifiers. + */ + var collectConnectionGroupIds = function collectConnectionGroupIds(group) { + var ids = []; + angular.forEach(group.childConnections, function connectionFound(connection) { + ids.push(connection.identifier); + }); + angular.forEach(group.childConnectionGroups, function groupFound(childGroup) { + ids = ids.concat(collectConnectionGroupIds(childGroup)); + }); + return ids; + }; + // Set contents whenever the connection group is assigned or changed $scope.$watch('connectionGroups', function setContents(connectionGroups) { @@ -230,11 +264,19 @@ angular.module('groupList').directive('guacGroupList', [function guacGroupList() if ($scope.decorator) $scope.decorator($scope.rootItems); + // Register reachability targets for each data source so that + // the polling service knows which connections to probe via TCP. + angular.forEach(connectionGroups, function targetsRegistered(rootGroup, dataSource) { + var ids = collectConnectionGroupIds(rootGroup); + if (ids.length) + connectionReachabilityService.setTargets(dataSource, ids); + }); + }); /** * Toggle the open/closed status of a group list item. - * + * * @param {GroupListItem} groupListItem * The list item to expand, which should represent a * connection group. diff --git a/guacamole/src/main/frontend/src/app/home/styles/home.css b/guacamole/src/main/frontend/src/app/home/styles/home.css index 8ae0486ed6..7dc81e430a 100644 --- a/guacamole/src/main/frontend/src/app/home/styles/home.css +++ b/guacamole/src/main/frontend/src/app/home/styles/home.css @@ -110,3 +110,13 @@ a.home-connection, .empty.balancer a.home-connection-group { .recent-connections .connection .remove-recent:hover { opacity: 1.0; } + +/* Colorize icon when the connection host is reachable via TCP. */ +.all-connections .icon.vm-running { + filter: invert(52%) sepia(65%) saturate(450%) hue-rotate(100deg) brightness(88%); +} + +/* Dim icon when the connection host is not reachable. */ +.all-connections .icon.vm-stopped { + opacity: 0.35; +} diff --git a/guacamole/src/main/frontend/src/app/home/templates/connection.html b/guacamole/src/main/frontend/src/app/home/templates/connection.html index 85a7f573b5..775c0ff09b 100644 --- a/guacamole/src/main/frontend/src/app/home/templates/connection.html +++ b/guacamole/src/main/frontend/src/app/home/templates/connection.html @@ -2,8 +2,9 @@ ng-href="{{ item.getClientURL() }}" ng-class="{active: item.getActiveConnections()}"> - -
+
{{item.name}} diff --git a/guacamole/src/main/frontend/src/app/rest/services/connectionReachabilityService.js b/guacamole/src/main/frontend/src/app/rest/services/connectionReachabilityService.js new file mode 100644 index 0000000000..aebe57576e --- /dev/null +++ b/guacamole/src/main/frontend/src/app/rest/services/connectionReachabilityService.js @@ -0,0 +1,109 @@ +/* + * 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. + */ + +/** + * Service that maintains the TCP reachability state of Guacamole connections + * and refreshes it every 10 seconds by polling the backend. + * + * Typical usage (from guacGroupList.js): + * connectionReachabilityService.setTargets('mysql', ['1', '2', '14', '15']); + * + * In connection.html template: + * ng-class="{'vm-running': reachabilityService.reachable[item.identifier] === true, + * 'vm-stopped': reachabilityService.reachable[item.identifier] === false}" + */ +angular.module('rest').factory('connectionReachabilityService', ['$injector', + function connectionReachabilityServiceFactory($injector) { + + // Servicios necesarios obtenidos via $injector (patron estandar de este proyecto) + var authenticationService = $injector.get('authenticationService'); + var $interval = $injector.get('$interval'); + + var service = {}; + + /** + * Map of connection ID (String) to Boolean reachability state. + * true = host responds on its TCP port (machine is running). + * false = host is unreachable (machine is off or port is closed). + * No entry = state not yet known (first poll pending). + * + * IMPORTANT: this object is NEVER replaced; it is updated in-place via + * {@code angular.extend()} so that AngularJS bindings in child scopes + * continue to reference the same object. + */ + service.reachable = {}; + + /** Identifier of the current data source (e.g. "mysql"). */ + var currentDataSource = null; + + /** List of connection IDs to probe on each polling cycle. */ + var currentIds = []; + + /** Handle to the active $interval, used to cancel it when targets change. */ + var pollHandle = null; + + /** + * Sends a GET request to the /reachable endpoint and merges the result + * into service.reachable. If the request fails (network error, expired + * session, etc.), it is silently ignored and the last known state is kept. + */ + var pollReachability = function pollReachability() { + if (!currentDataSource || !currentIds.length) + return; + + authenticationService.request({ + method : 'GET', + url : 'api/session/data/' + encodeURIComponent(currentDataSource) + + '/connections/reachable', + params : { ids : currentIds } + }).then(function reachabilityReceived(result) { + // result is the {id: bool} map returned by the Java endpoint. + // angular.extend updates service.reachable without replacing the reference, + // preserving bindings in child scopes. + angular.extend(service.reachable, result); + }); + // Errors are not propagated: if the backend fails transiently, + // the last known state is displayed until the next poll. + }; + + /** + * Registers the list of connections to monitor and (re)starts the polling loop. + * Must be called whenever the connection list changes. + * + * @param {String} dataSource Data source identifier (e.g. "mysql"). + * @param {String[]} ids Array of Guacamole connection IDs to probe. + */ + service.setTargets = function setTargets(dataSource, ids) { + currentDataSource = dataSource; + currentIds = ids; + + // Cancel the previous polling interval if one is active + if (pollHandle) { + $interval.cancel(pollHandle); + pollHandle = null; + } + + // Probe immediately, then again every 10 seconds + pollReachability(); + pollHandle = $interval(pollReachability, 10000); + }; + + return service; + +}]); diff --git a/guacamole/src/main/frontend/src/translations/en.json b/guacamole/src/main/frontend/src/translations/en.json index 05c52fa5f4..8a114e124b 100644 --- a/guacamole/src/main/frontend/src/translations/en.json +++ b/guacamole/src/main/frontend/src/translations/en.json @@ -168,6 +168,8 @@ "TEXT_USER_JOINED" : "{USERNAME} has joined the connection.", "TEXT_USER_LEFT" : "{USERNAME} has left the connection.", "TEXT_RECONNECT_COUNTDOWN" : "Reconnecting in {REMAINING} {REMAINING, plural, one{second} other{seconds}}...", + "INFO_WOL_MACHINE_OFF" : "Machine is powered off — sending Wake-on-LAN signal", + "INFO_WOL_CONNECTING" : "Automatically connecting in approximately {SECONDS} {SECONDS, 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" diff --git a/guacamole/src/main/java/org/apache/guacamole/net/ReachabilityProbe.java b/guacamole/src/main/java/org/apache/guacamole/net/ReachabilityProbe.java new file mode 100644 index 0000000000..0c2727776e --- /dev/null +++ b/guacamole/src/main/java/org/apache/guacamole/net/ReachabilityProbe.java @@ -0,0 +1,145 @@ +/* + * 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.net; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +/** + * Comprueba en paralelo si un conjunto de hosts:puerto responden via TCP. + * + * Cada prueba abre un socket TCP con un timeout de 1 segundo. Si la conexion + * se establece, el host se considera alcanzable (reachable = true). Si falla + * o expira el timeout, se considera inalcanzable (reachable = false). + * + * El probe NO verifica que el servicio Guacamole funcione correctamente, solo + * que el puerto este abierto. Esto es suficiente para saber si la VM esta + * encendida, ya que Windows y Linux dejan el puerto RDP/SSH abierto cuando + * estan activos (a diferencia de ICMP que suele estar bloqueado en Windows). + */ +public class ReachabilityProbe { + + /** Numero maximo de sondas TCP en paralelo. */ + private static final int THREAD_POOL_SIZE = 16; + + /** + * Timeout en milisegundos para cada intento de conexion TCP. + * 1 segundo es suficiente para LAN local; evita bloquear la UI si una VM + * tarda en responder o esta apagada. + */ + private static final int CONNECT_TIMEOUT_MS = 1000; + + /** + * Representa un destino a sondear: el ID de la conexion Guacamole, + * el hostname o IP del host, y el puerto TCP. + */ + public static class Target { + + /** Identificador de la conexion Guacamole (clave en el resultado del probe). */ + public final String id; + + /** Hostname o IP del host a sondear. */ + public final String host; + + /** Puerto TCP a sondear (1-65535). */ + public final int port; + + /** + * Crea un nuevo Target. + * + * @param id Identificador de la conexion Guacamole. + * @param host Hostname o IP del host destino. + * @param port Puerto TCP a sondear (1-65535). + */ + public Target(String id, String host, int port) { + this.id = id; + this.host = host; + this.port = port; + } + } + + /** + * Sondea en paralelo todos los targets de la lista via TCP. + * + * Usa un ExecutorService con pool fijo de hasta THREAD_POOL_SIZE hilos. + * Cada hilo intenta abrir un socket TCP al host:port del target con un + * timeout de CONNECT_TIMEOUT_MS ms. El resultado se recoge con un timeout + * de CONNECT_TIMEOUT_MS + 500 ms para dar margen a la Future. + * + * @param targets Lista de targets a sondear. Puede ser nula o vacia. + * @return Mapa de connectionId (String) a boolean (true = alcanzable via TCP). + */ + public static Map probe(List targets) { + + Map results = new HashMap<>(); + + if (targets == null || targets.isEmpty()) + return results; + + // Pool acotado para no saturar el servidor con demasiados hilos + int poolSize = Math.min(THREAD_POOL_SIZE, targets.size()); + ExecutorService executor = Executors.newFixedThreadPool(poolSize); + + // Lanzar una Future por cada target + Map> futures = new HashMap<>(); + for (Target target : targets) { + final String id = target.id; + final String host = target.host; + final int port = target.port; + + futures.put(id, executor.submit(() -> { + // Intentar conexion TCP; Socket se cierra automaticamente al salir del try-with-resources + try (Socket socket = new Socket()) { + socket.connect(new InetSocketAddress(host, port), CONNECT_TIMEOUT_MS); + return true; + } catch (IOException e) { + // Conexion rechazada, timeout o host desconocido -> no alcanzable + return false; + } + })); + } + + // Cerrar el pool: no acepta mas tareas, pero espera a que las actuales terminen + executor.shutdown(); + + // Recoger resultados; si una Future tarda demasiado, asumir no alcanzable + for (Map.Entry> entry : futures.entrySet()) { + try { + Boolean reachable = entry.getValue().get( + CONNECT_TIMEOUT_MS + 500L, TimeUnit.MILLISECONDS); + results.put(entry.getKey(), reachable != null && reachable); + } catch (Exception e) { + // Timeout de la Future, InterruptedException o error inesperado + results.put(entry.getKey(), false); + } + } + + return results; + } + +} diff --git a/guacamole/src/main/java/org/apache/guacamole/rest/connection/ConnectionDirectoryResource.java b/guacamole/src/main/java/org/apache/guacamole/rest/connection/ConnectionDirectoryResource.java index 612e055021..b93ba6d65c 100644 --- a/guacamole/src/main/java/org/apache/guacamole/rest/connection/ConnectionDirectoryResource.java +++ b/guacamole/src/main/java/org/apache/guacamole/rest/connection/ConnectionDirectoryResource.java @@ -21,10 +21,18 @@ import com.google.inject.assistedinject.Assisted; import com.google.inject.assistedinject.AssistedInject; +import java.util.ArrayList; +import java.util.Collections; +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.Produces; +import javax.ws.rs.QueryParam; import javax.ws.rs.core.MediaType; import org.apache.guacamole.GuacamoleException; +import org.apache.guacamole.net.ReachabilityProbe; import org.apache.guacamole.net.auth.AuthenticatedUser; import org.apache.guacamole.net.auth.Connection; import org.apache.guacamole.net.auth.Directory; @@ -65,12 +73,22 @@ public class ConnectionDirectoryResource * A factory which can be used to create instances of resources * representing Connections. */ + /** + * Factory for creating ConnectionResource instances for individual connections. + * Stored here because {@code getConnectionParameters()} on ConnectionResource is + * the only path that correctly loads connection parameters from the backend + * (hostname, port, etc.). Calling {@code connection.getConfiguration()} directly + * from the directory returns empty parameters. + */ + private final DirectoryObjectResourceFactory resourceFactory; + @AssistedInject public ConnectionDirectoryResource(@Assisted AuthenticatedUser authenticatedUser, @Assisted UserContext userContext, @Assisted Directory directory, DirectoryObjectTranslator translator, DirectoryObjectResourceFactory resourceFactory) { super(authenticatedUser, userContext, Connection.class, directory, translator, resourceFactory); + this.resourceFactory = resourceFactory; } @Override @@ -79,4 +97,121 @@ protected ObjectPermissionSet getObjectPermissions(Permissions permissions) return permissions.getConnectionPermissions(); } + /** + * Returns the default TCP port for the given Guacamole protocol. + * Used when a connection does not have the "port" parameter configured. + * + * @param protocol Guacamole protocol name (e.g. "rdp", "ssh"). + * @return Default TCP port, or -1 if the protocol is not recognised. + */ + private static int defaultPortFor(String protocol) { + if (protocol == null) return -1; + switch (protocol.toLowerCase()) { + case "rdp": return 3389; + case "ssh": return 22; + case "vnc": return 5900; + case "telnet": return 23; + case "kubernetes": return 8080; + default: return -1; + } + } + + /** + * Returns a map indicating whether the host of each specified connection + * responds on its configured TCP port. + * + * Only connections to which the current user has READ permission are probed. + * Connections that do not exist, are inaccessible, or have no hostname + * configured are silently omitted from the result. + * + * Example requests: + * GET /api/session/data/mysql/connections/reachable?ids=1&ids=2&ids=3 + * GET /api/session/data/mysql/connections/reachable?ids=1,2,3 + * + * Example response: + * {"1": true, "2": false, "3": true} + * + * TCP is used (rather than ICMP/ping) because Windows blocks ICMP by default + * while keeping the RDP port open when the machine is running. + * + * @param ids Connection identifiers to check. Both repeated parameters + * ({@code ids=1&ids=2}) and comma-separated values + * ({@code ids=1,2,3}) are accepted. + * @return Map of connection ID (String) to boolean (true = reachable via TCP). + * @throws GuacamoleException If the directory cannot be accessed. + */ + @GET + @Path("reachable") + public Map getReachable( + @QueryParam("ids") List ids) + throws GuacamoleException { + + if (ids == null || ids.isEmpty()) + return Collections.emptyMap(); + + // Normalize: flatten possible comma-separated values (ids=1,2,3) + List flatIds = new ArrayList<>(); + for (String idParam : ids) { + for (String id : idParam.split(",")) { + String trimmed = id.trim(); + if (!trimmed.isEmpty()) + flatIds.add(trimmed); + } + } + + List targets = new ArrayList<>(); + Directory dir = getDirectory(); + + for (String id : flatIds) { + + Connection connection; + try { + // Verify the connection exists and the user has READ access. + // Returns null if not found; throws if permission is denied. + connection = dir.get(id); + } catch (GuacamoleException e) { + continue; + } + + if (connection == null) + continue; + + // NOTE: connection.getConfiguration().getParameter() returns null here + // because the MySQL backend does not load parameters when listing connections + // (only when establishing a tunnel). ConnectionResource.getConnectionParameters() + // uses the same path as GET /connections/{id}/parameters and does load them. + Map params; + try { + ConnectionResource connRes = (ConnectionResource) resourceFactory.create( + getAuthenticatedUser(), getUserContext(), dir, connection); + params = connRes.getConnectionParameters(); + } catch (GuacamoleException e) { + // Insufficient permission to read parameters (UPDATE or ADMINISTER required) + continue; + } + + String hostname = params.get("hostname"); + if (hostname == null || hostname.isEmpty()) + continue; + + String portStr = params.get("port"); + int port; + try { + port = (portStr != null && !portStr.isEmpty()) + ? Integer.parseInt(portStr) + : defaultPortFor(connection.getConfiguration().getProtocol()); + } catch (NumberFormatException e) { + port = defaultPortFor(connection.getConfiguration().getProtocol()); + } + + if (port <= 0 || port > 65535) + continue; + + targets.add(new ReachabilityProbe.Target(id, hostname, port)); + } + + // Run TCP probes in parallel and return the results + return ReachabilityProbe.probe(targets); + } + }