diff --git a/dev-example/config.yaml b/dev-example/config.yaml index 586a16ef0..2a72bc8f0 100644 --- a/dev-example/config.yaml +++ b/dev-example/config.yaml @@ -75,6 +75,7 @@ admin: - "*" operator: + - org.jdrupes.vmoperator.vmmgmt.VmMgmt - org.jdrupes.vmoperator.vmaccess.VmAccess user: - org.jdrupes.vmoperator.vmaccess.VmAccess diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinition.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinition.java index f577d2891..ffb1bf211 100644 --- a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinition.java +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinition.java @@ -1,6 +1,6 @@ /* * VM-Operator - * Copyright (C) 2024 Michael N. Lipp + * Copyright (C) 2025 Michael N. Lipp * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -22,11 +22,15 @@ import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import io.kubernetes.client.openapi.models.V1Condition; import io.kubernetes.client.openapi.models.V1ObjectMeta; +import io.kubernetes.client.util.Strings; +import java.net.InetAddress; +import java.net.UnknownHostException; import java.time.Instant; import java.util.Collection; import java.util.Collections; import java.util.EnumSet; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Objects; @@ -34,6 +38,8 @@ import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.function.Function; +import java.util.logging.Level; +import java.util.logging.Logger; import java.util.stream.Collectors; import org.jdrupes.vmoperator.util.DataPath; @@ -43,7 +49,11 @@ @SuppressWarnings({ "PMD.DataClass", "PMD.TooManyMethods" }) public class VmDefinition { - private static ObjectMapper objectMapper + @SuppressWarnings("PMD.FieldNamingConventions") + private static final Logger logger + = Logger.getLogger(VmDefinition.class.getName()); + @SuppressWarnings("PMD.FieldNamingConventions") + private static final ObjectMapper objectMapper = new ObjectMapper().registerModule(new JavaTimeModule()); private String kind; @@ -427,6 +437,8 @@ public RequestedVmState vmState() { /** * Collect all permissions for the given user with the given roles. + * If permission "takeConsole" is granted, the result will also + * contain "accessConsole" to simplify checks. * * @param user the user * @param roles the roles @@ -434,7 +446,7 @@ public RequestedVmState vmState() { */ public Set permissionsFor(String user, Collection roles) { - return this.>> fromSpec("permissions") + var result = this.>> fromSpec("permissions") .orElse(Collections.emptyList()).stream() .filter(p -> DataPath.get(p, "user").map(u -> u.equals(user)) .orElse(false) @@ -443,7 +455,29 @@ public Set permissionsFor(String user, .orElse(Collections.emptyList()).stream()) .flatMap(Function.identity()) .map(Permission::parse).map(Set::stream) - .flatMap(Function.identity()).collect(Collectors.toSet()); + .flatMap(Function.identity()) + .collect(Collectors.toCollection(HashSet::new)); + + // Take console implies access console, simplify checks + if (result.contains(Permission.TAKE_CONSOLE)) { + result.add(Permission.ACCESS_CONSOLE); + } + return result; + } + + /** + * Check if the console is accessible. Returns true if the console is + * currently unused, used by the given user or if the permissions + * allow taking over the console. + * + * @param user the user + * @param permissions the permissions + * @return true, if successful + */ + public boolean consoleAccessible(String user, Set permissions) { + return !conditionStatus("ConsoleConnected").orElse(true) + || consoleUser().map(cu -> cu.equals(user)).orElse(true) + || permissions.contains(VmDefinition.Permission.TAKE_CONSOLE); } /** @@ -456,6 +490,78 @@ public Optional displayPasswordSerial() { .map(Number::longValue); } + /** + * Create a connection file. + * + * @param password the password + * @param preferredIpVersion the preferred IP version + * @param deleteConnectionFile the delete connection file + * @return the string + */ + public String connectionFile(String password, + Class preferredIpVersion, boolean deleteConnectionFile) { + var addr = displayIp(preferredIpVersion); + if (addr.isEmpty()) { + logger.severe(() -> "Failed to find display IP for " + name()); + return null; + } + var port = this. fromVm("display", "spice", "port") + .map(Number::longValue); + if (port.isEmpty()) { + logger.severe(() -> "No port defined for display of " + name()); + return null; + } + StringBuffer data = new StringBuffer(100) + .append("[virt-viewer]\ntype=spice\nhost=") + .append(addr.get().getHostAddress()).append("\nport=") + .append(port.get().toString()) + .append('\n'); + if (password != null) { + data.append("password=").append(password).append('\n'); + } + this. fromVm("display", "spice", "proxyUrl") + .ifPresent(u -> { + if (!Strings.isNullOrEmpty(u)) { + data.append("proxy=").append(u).append('\n'); + } + }); + if (deleteConnectionFile) { + data.append("delete-this-file=1\n"); + } + return data.toString(); + } + + private Optional displayIp(Class preferredIpVersion) { + Optional server = fromVm("display", "spice", "server"); + if (server.isPresent()) { + var srv = server.get(); + try { + var addr = InetAddress.getByName(srv); + logger.fine(() -> "Using IP address from CRD for " + + getMetadata().getName() + ": " + addr); + return Optional.of(addr); + } catch (UnknownHostException e) { + logger.log(Level.SEVERE, e, () -> "Invalid server address " + + srv + ": " + e.getMessage()); + return Optional.empty(); + } + } + var addrs = Optional.> ofNullable( + extra("nodeAddresses")).orElse(Collections.emptyList()).stream() + .map(a -> { + try { + return InetAddress.getByName(a); + } catch (UnknownHostException e) { + logger.warning(() -> "Invalid IP address: " + a); + return null; + } + }).filter(a -> a != null).toList(); + logger.fine(() -> "Known IP addresses for " + name() + ": " + addrs); + return addrs.stream() + .filter(a -> preferredIpVersion.isAssignableFrom(a.getClass())) + .findFirst().or(() -> addrs.stream().findFirst()); + } + /** * Hash code. * diff --git a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/ChannelManager.java b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/ChannelManager.java index 2cf7a85e9..ce0e4f094 100644 --- a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/ChannelManager.java +++ b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/ChannelManager.java @@ -62,6 +62,11 @@ public ChannelManager() { this(k -> null); } + /** + * Return all keys. + * + * @return the keys. + */ @Override public Set keys() { return entries.keySet(); @@ -113,6 +118,18 @@ public ChannelManager put(K key, C channel) { return this; } + /** + * Creates a new channel without adding it to the channel manager. + * After fully initializing the channel, it should be added to the + * manager using {@link #put(K, C)}. + * + * @param key the key + * @return the c + */ + public C createChannel(K key) { + return supplier.apply(key); + } + /** * Returns the {@link Channel} for the given name, creating it using * the supplier passed to the constructor if it doesn't exist yet. diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/VmMonitor.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/VmMonitor.java index 102a6c9cd..20601091c 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/VmMonitor.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/VmMonitor.java @@ -30,6 +30,7 @@ import java.util.Comparator; import java.util.Optional; import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.logging.Level; import java.util.stream.Collectors; import static org.jdrupes.vmoperator.common.Constants.VM_OP_GROUP; @@ -125,7 +126,12 @@ private void purge() throws ApiException { protected void handleChange(K8sClient client, Watch.Response response) { V1ObjectMeta metadata = response.object.getMetadata(); - VmChannel channel = channelManager.channelGet(metadata.getName()); + AtomicBoolean toBeAdded = new AtomicBoolean(false); + VmChannel channel = channelManager.channel(metadata.getName()) + .orElseGet(() -> { + toBeAdded.set(true); + return channelManager.createChannel(metadata.getName()); + }); // Get full definition and associate with channel as backup var vmModel = response.object; @@ -151,6 +157,9 @@ protected void handleChange(K8sClient client, + response.object.getMetadata()); return; } + if (toBeAdded.get()) { + channelManager.put(vmDef.name(), channel); + } // Create and fire changed event. Remove channel from channel // manager on completion. diff --git a/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/VmAccess.java b/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/VmAccess.java index 786fedf48..c82ccd4e7 100644 --- a/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/VmAccess.java +++ b/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/VmAccess.java @@ -32,10 +32,7 @@ import java.io.IOException; import java.net.Inet4Address; import java.net.Inet6Address; -import java.net.InetAddress; -import java.net.UnknownHostException; import java.time.Duration; -import java.util.Base64; import java.util.Collections; import java.util.EnumSet; import java.util.HashSet; @@ -549,26 +546,26 @@ private Set permissions(ResourceModel model, Session session, .map(ConsoleUser::getName).orElse(null); var roles = WebConsoleUtils.rolesFromSession(session) .stream().map(ConsoleRole::getName).toList(); - Set result = new HashSet<>(); if (model.mode() == ResourceModel.Mode.POOL) { if (pool == null) { pool = appPipeline.fire(new GetPools() .withName(model.name())).get().stream().findFirst() .orElse(null); } - if (pool != null) { - result.addAll(pool.permissionsFor(user, roles)); + if (pool == null) { + return Collections.emptySet(); } + return pool.permissionsFor(user, roles); } if (vmDef == null) { vmDef = appPipeline.fire(new GetVms().assignedFrom(model.name()) .assignedTo(user)).get().stream().map(VmData::definition) .findFirst().orElse(null); } - if (vmDef != null) { - result.addAll(vmDef.permissionsFor(user, roles)); + if (vmDef == null) { + return Collections.emptySet(); } - return result; + return vmDef.permissionsFor(user, roles); } private void updatePreview(ConsoleConnection channel, ResourceModel model, @@ -779,24 +776,8 @@ protected void doUpdateConletState(NotifyConletModel event, } break; case "openConsole": - var user = WebConsoleUtils.userFromSession(channel.session()) - .map(ConsoleUser::getName).orElse(""); - if (vmDef.conditionStatus("ConsoleConnected").orElse(false) - && vmDef.consoleUser().map(cu -> !cu.equals(user) - && !perms.contains(VmDefinition.Permission.TAKE_CONSOLE)) - .orElse(false)) { - channel.respond(new DisplayNotification( - resourceBundle.getString("consoleTakenNotification"), - Map.of("autoClose", 5_000, "type", "Warning"))); - return; - } - if (perms.contains(VmDefinition.Permission.ACCESS_CONSOLE) - || perms.contains(VmDefinition.Permission.TAKE_CONSOLE)) { - var pwQuery - = Event.onCompletion(new GetDisplayPassword(vmDef, user), - e -> openConsole(vmDef, channel, model, - e.password().orElse(null))); - fire(pwQuery, vmChannel); + if (perms.contains(VmDefinition.Permission.ACCESS_CONSOLE)) { + openConsole(channel, model, vmChannel, vmDef, perms); } break; default:// ignore @@ -804,6 +785,44 @@ protected void doUpdateConletState(NotifyConletModel event, } } + private void confirmReset(NotifyConletModel event, + ConsoleConnection channel, ResourceModel model, + ResourceBundle resourceBundle) throws TemplateNotFoundException, + MalformedTemplateNameException, ParseException, IOException { + Template tpl = freemarkerConfig() + .getTemplate("VmAccess-confirmReset.ftl.html"); + channel.respond(new OpenModalDialog(type(), model.getConletId(), + processTemplate(event, tpl, + fmModel(event, channel, model.getConletId(), model))) + .addOption("cancelable", true).addOption("closeLabel", "") + .addOption("title", + resourceBundle.getString("confirmResetTitle"))); + } + + private void openConsole(ConsoleConnection channel, ResourceModel model, + VmChannel vmChannel, VmDefinition vmDef, Set perms) { + var resourceBundle = resourceBundle(channel.locale()); + var user = WebConsoleUtils.userFromSession(channel.session()) + .map(ConsoleUser::getName).orElse(""); + if (!vmDef.consoleAccessible(user, perms)) { + channel.respond(new DisplayNotification( + resourceBundle.getString("consoleTakenNotification"), + Map.of("autoClose", 5_000, "type", "Warning"))); + return; + } + var pwQuery = Event.onCompletion(new GetDisplayPassword(vmDef, user), + e -> { + var data = vmDef.connectionFile(e.password().orElse(null), + preferredIpVersion, deleteConnectionFile); + if (data == null) { + return; + } + channel.respond(new NotifyConletView(type(), + model.getConletId(), "openConsole", data)); + }); + fire(pwQuery, vmChannel); + } + @SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition", "PMD.UseLocaleWithCaseConversions" }) private void selectResource(NotifyConletModel event, @@ -823,92 +842,6 @@ private void selectResource(NotifyConletModel event, } } - private void openConsole(VmDefinition vmDef, ConsoleConnection connection, - ResourceModel model, String password) { - if (vmDef == null) { - return; - } - var addr = displayIp(vmDef); - if (addr.isEmpty()) { - logger - .severe(() -> "Failed to find display IP for " + vmDef.name()); - return; - } - var port = vmDef. fromVm("display", "spice", "port") - .map(Number::longValue); - if (port.isEmpty()) { - logger - .severe(() -> "No port defined for display of " + vmDef.name()); - return; - } - StringBuffer data = new StringBuffer(100) - .append("[virt-viewer]\ntype=spice\nhost=") - .append(addr.get().getHostAddress()).append("\nport=") - .append(port.get().toString()) - .append('\n'); - if (password != null) { - data.append("password=").append(password).append('\n'); - } - vmDef. fromVm("display", "spice", "proxyUrl") - .ifPresent(u -> { - if (!Strings.isNullOrEmpty(u)) { - data.append("proxy=").append(u).append('\n'); - } - }); - if (deleteConnectionFile) { - data.append("delete-this-file=1\n"); - } - connection.respond(new NotifyConletView(type(), - model.getConletId(), "openConsole", "application/x-virt-viewer", - Base64.getEncoder().encodeToString(data.toString().getBytes()))); - } - - private Optional displayIp(VmDefinition vmDef) { - Optional server = vmDef.fromVm("display", "spice", "server"); - if (server.isPresent()) { - var srv = server.get(); - try { - var addr = InetAddress.getByName(srv); - logger.fine(() -> "Using IP address from CRD for " - + vmDef.getMetadata().getName() + ": " + addr); - return Optional.of(addr); - } catch (UnknownHostException e) { - logger.log(Level.SEVERE, e, () -> "Invalid server address " - + srv + ": " + e.getMessage()); - return Optional.empty(); - } - } - var addrs = Optional.> ofNullable(vmDef - .extra("nodeAddresses")).orElse(Collections.emptyList()).stream() - .map(a -> { - try { - return InetAddress.getByName(a); - } catch (UnknownHostException e) { - logger.warning(() -> "Invalid IP address: " + a); - return null; - } - }).filter(a -> a != null).toList(); - logger.fine(() -> "Known IP addresses for " - + vmDef.name() + ": " + addrs); - return addrs.stream() - .filter(a -> preferredIpVersion.isAssignableFrom(a.getClass())) - .findFirst().or(() -> addrs.stream().findFirst()); - } - - private void confirmReset(NotifyConletModel event, - ConsoleConnection channel, ResourceModel model, - ResourceBundle resourceBundle) throws TemplateNotFoundException, - MalformedTemplateNameException, ParseException, IOException { - Template tpl = freemarkerConfig() - .getTemplate("VmAccess-confirmReset.ftl.html"); - channel.respond(new OpenModalDialog(type(), model.getConletId(), - processTemplate(event, tpl, - fmModel(event, channel, model.getConletId(), model))) - .addOption("cancelable", true).addOption("closeLabel", "") - .addOption("title", - resourceBundle.getString("confirmResetTitle"))); - } - @Override protected boolean doSetLocale(SetLocale event, ConsoleConnection channel, String conletId) throws Exception { diff --git a/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/browser/VmAccess-functions.ts b/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/browser/VmAccess-functions.ts index de65216cb..9d2e13446 100644 --- a/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/browser/VmAccess-functions.ts +++ b/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/browser/VmAccess-functions.ts @@ -198,7 +198,7 @@ JGConsole.registerConletFunction("org.jdrupes.vmoperator.vmaccess.VmAccess", }); JGConsole.registerConletFunction("org.jdrupes.vmoperator.vmaccess.VmAccess", - "openConsole", function(_conletId: string, mimeType: string, data: string) { + "openConsole", function(_conletId: string, data: string) { let target = document.getElementById( "org.jdrupes.vmoperator.vmaccess.VmAccess.target"); if (!target) { @@ -208,7 +208,8 @@ JGConsole.registerConletFunction("org.jdrupes.vmoperator.vmaccess.VmAccess", target.setAttribute("style", "display: none;"); document.querySelector("body")!.append(target); } - const url = "data:" + mimeType + ";base64," + data; + const url = "data:application/x-virt-viewer;base64," + + window.btoa(data); window.open(url, target.id); }); diff --git a/org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/VmMgmt-confirmReset.ftl.html b/org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/VmMgmt-confirmReset.ftl.html new file mode 100644 index 000000000..d1747075f --- /dev/null +++ b/org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/VmMgmt-confirmReset.ftl.html @@ -0,0 +1,13 @@ +
+

${_("confirmResetMsg")}

+

+ + + + + + +

+
\ No newline at end of file diff --git a/org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/VmMgmt-view.ftl.html b/org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/VmMgmt-view.ftl.html index a57c53399..4dfc8d7a9 100644 --- a/org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/VmMgmt-view.ftl.html +++ b/org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/VmMgmt-view.ftl.html @@ -1,6 +1,7 @@
+ data-jgwc-on-unload="JGConsole.jgwc.unmountVueApps" + data-conlet-resource-base="${conletResource('')}">