Skip to content

Commit

Permalink
Add console access to VM management.
Browse files Browse the repository at this point in the history
  • Loading branch information
mnlipp committed Jan 29, 2025
1 parent 5cd4edc commit af41c78
Show file tree
Hide file tree
Showing 14 changed files with 561 additions and 124 deletions.
1 change: 1 addition & 0 deletions dev-example/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
admin:
- "*"
operator:
- org.jdrupes.vmoperator.vmmgmt.VmMgmt
- org.jdrupes.vmoperator.vmaccess.VmAccess
user:
- org.jdrupes.vmoperator.vmaccess.VmAccess
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -22,18 +22,24 @@
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;
import java.util.Optional;
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;

Expand All @@ -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;
Expand Down Expand Up @@ -427,14 +437,16 @@ 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
* @return the sets the
*/
public Set<Permission> permissionsFor(String user,
Collection<String> roles) {
return this.<List<Map<String, Object>>> fromSpec("permissions")
var result = this.<List<Map<String, Object>>> fromSpec("permissions")
.orElse(Collections.emptyList()).stream()
.filter(p -> DataPath.get(p, "user").map(u -> u.equals(user))
.orElse(false)
Expand All @@ -443,7 +455,29 @@ public Set<Permission> 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<Permission> permissions) {
return !conditionStatus("ConsoleConnected").orElse(true)
|| consoleUser().map(cu -> cu.equals(user)).orElse(true)
|| permissions.contains(VmDefinition.Permission.TAKE_CONSOLE);
}

/**
Expand All @@ -456,6 +490,78 @@ public Optional<Long> 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.<Number> 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.<String> 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<InetAddress> displayIp(Class<?> preferredIpVersion) {
Optional<String> 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.<List<String>> 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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -125,7 +126,12 @@ private void purge() throws ApiException {
protected void handleChange(K8sClient client,
Watch.Response<VmDefinitionModel> 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;
Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -779,31 +776,39 @@ 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
break;
}
}

private void openConsole(ConsoleConnection channel, ResourceModel model,
VmChannel vmChannel, VmDefinition vmDef, Set<Permission> 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,
Expand All @@ -823,78 +828,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.<Number> 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.<String> 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<InetAddress> displayIp(VmDefinition vmDef) {
Optional<String> 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.<List<String>> 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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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);
});

Expand Down
Loading

0 comments on commit af41c78

Please sign in to comment.