Viewer ACL (#26)
Some checks failed
Java CI with Gradle / build (push) Has been cancelled

Provide ACLs (together with general improvements) for the viewer conlet.
This commit is contained in:
Michael N. Lipp 2024-06-01 11:12:15 +02:00 committed by GitHub
parent a6525a2289
commit 659463b3b4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
42 changed files with 1664 additions and 679 deletions

View file

@ -3,5 +3,6 @@ conletName = VM-Konsole
okayLabel = Anwenden und Schließen
Select\ VM = VM auswählen
Start\ VM = VM Starten
Stop\ VM = VM Anhalten
Start\ VM = VM starten
Stop\ VM = VM anhalten
Open\ console = Konsole anzeigen

View file

@ -1,6 +1,6 @@
/*
* VM-Operator
* Copyright (C) 2023 Michael N. Lipp
* Copyright (C) 2023,2024 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,6 +22,7 @@ import com.fasterxml.jackson.annotation.JsonGetter;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.google.gson.JsonObject;
import com.google.gson.JsonPrimitive;
import freemarker.core.ParseException;
import freemarker.template.MalformedTemplateNameException;
@ -33,18 +34,23 @@ 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.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.ResourceBundle;
import java.util.Set;
import java.util.logging.Level;
import org.bouncycastle.util.Objects;
import org.jdrupes.json.JsonBeanDecoder;
import org.jdrupes.json.JsonDecodeException;
import org.jdrupes.vmoperator.common.K8sDynamicModel;
import org.jdrupes.vmoperator.common.K8sObserver;
import org.jdrupes.vmoperator.common.VmDefinitionModel;
import org.jdrupes.vmoperator.common.VmDefinitionModel.Permission;
import org.jdrupes.vmoperator.manager.events.ChannelCache;
import org.jdrupes.vmoperator.manager.events.GetDisplayPassword;
import org.jdrupes.vmoperator.manager.events.ModifyVm;
@ -52,6 +58,7 @@ import org.jdrupes.vmoperator.manager.events.VmChannel;
import org.jdrupes.vmoperator.manager.events.VmDefChanged;
import org.jdrupes.vmoperator.util.GsonPtr;
import org.jgrapes.core.Channel;
import org.jgrapes.core.Components;
import org.jgrapes.core.Event;
import org.jgrapes.core.Manager;
import org.jgrapes.core.annotation.Handler;
@ -62,11 +69,15 @@ import org.jgrapes.util.events.KeyValueStoreUpdate;
import org.jgrapes.webconsole.base.Conlet.RenderMode;
import org.jgrapes.webconsole.base.ConletBaseModel;
import org.jgrapes.webconsole.base.ConsoleConnection;
import org.jgrapes.webconsole.base.ConsoleRole;
import org.jgrapes.webconsole.base.ConsoleUser;
import org.jgrapes.webconsole.base.WebConsoleUtils;
import org.jgrapes.webconsole.base.events.AddConletRequest;
import org.jgrapes.webconsole.base.events.AddConletType;
import org.jgrapes.webconsole.base.events.AddPageResources.ScriptResource;
import org.jgrapes.webconsole.base.events.ConletDeleted;
import org.jgrapes.webconsole.base.events.ConsoleConfigured;
import org.jgrapes.webconsole.base.events.ConsolePrepared;
import org.jgrapes.webconsole.base.events.ConsoleReady;
import org.jgrapes.webconsole.base.events.DeleteConlet;
import org.jgrapes.webconsole.base.events.NotifyConletModel;
@ -75,22 +86,32 @@ import org.jgrapes.webconsole.base.events.OpenModalDialog;
import org.jgrapes.webconsole.base.events.RenderConlet;
import org.jgrapes.webconsole.base.events.RenderConletRequestBase;
import org.jgrapes.webconsole.base.events.SetLocale;
import org.jgrapes.webconsole.base.events.UpdateConletType;
import org.jgrapes.webconsole.base.freemarker.FreeMarkerConlet;
/**
* The Class VmConlet.
*/
@SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", "PMD.ExcessiveImports",
"PMD.CouplingBetweenObjects" })
"PMD.CouplingBetweenObjects", "PMD.GodClass" })
public class VmViewer extends FreeMarkerConlet<VmViewer.ViewerModel> {
private static final String VM_NAME_PROPERTY = "vmName";
private static final String RENDERED
= VmViewer.class.getName() + ".rendered";
private static final String PENDING
= VmViewer.class.getName() + ".pending";
private static final Set<RenderMode> MODES = RenderMode.asSet(
RenderMode.Preview, RenderMode.Edit);
private static final Set<RenderMode> MODES_FOR_GENERATED = RenderMode.asSet(
RenderMode.Preview, RenderMode.StickyPreview);
private final ChannelCache<String, VmChannel,
K8sDynamicModel> channelManager = new ChannelCache<>();
VmDefinitionModel> channelManager = new ChannelCache<>();
private static ObjectMapper objectMapper
= new ObjectMapper().registerModule(new JavaTimeModule());
private Class<?> preferredIpVersion = Inet4Address.class;
private final Set<String> syncUsers = new HashSet<>();
private final Set<String> syncRoles = new HashSet<>();
/**
* The periodically generated update event.
@ -114,24 +135,47 @@ public class VmViewer extends FreeMarkerConlet<VmViewer.ViewerModel> {
*
* @param event the event
*/
@SuppressWarnings("unchecked")
@Handler
public void onConfigurationUpdate(ConfigurationUpdate event) {
event.structured(componentPath()).ifPresent(c -> {
@SuppressWarnings("unchecked")
var dispRes = (Map<String, Object>) c
.getOrDefault("displayResource", Collections.emptyMap());
switch ((String) dispRes.getOrDefault("preferredIpVersion", "")) {
case "ipv6":
preferredIpVersion = Inet6Address.class;
break;
case "ipv4":
default:
preferredIpVersion = Inet4Address.class;
break;
try {
var dispRes = (Map<String, Object>) c
.getOrDefault("displayResource", Collections.emptyMap());
switch ((String) dispRes.getOrDefault("preferredIpVersion",
"")) {
case "ipv6":
preferredIpVersion = Inet6Address.class;
break;
case "ipv4":
default:
preferredIpVersion = Inet4Address.class;
break;
}
// Sync
for (var entry : (List<Map<String, String>>) c.getOrDefault(
"syncPreviewsFor", Collections.emptyList())) {
if (entry.containsKey("user")) {
syncUsers.add(entry.get("user"));
} else if (entry.containsKey("role")) {
syncRoles.add(entry.get("role"));
}
}
} catch (ClassCastException e) {
logger.config("Malformed configuration: " + e.getMessage());
}
});
}
private boolean syncPreviews(Session session) {
return WebConsoleUtils.userFromSession(session)
.filter(u -> syncUsers.contains(u.getName())).isPresent()
|| WebConsoleUtils.rolesFromSession(session).stream()
.filter(cr -> syncRoles.contains(cr.getName())).findAny()
.isPresent();
}
/**
* On {@link ConsoleReady}, fire the {@link AddConletType}.
*
@ -155,6 +199,61 @@ public class VmViewer extends FreeMarkerConlet<VmViewer.ViewerModel> {
.addScript(new ScriptResource().setScriptType("module")
.setScriptUri(event.renderSupport().conletResource(
type(), "VmViewer-functions.js"))));
channel.session().put(RENDERED, new HashSet<>());
}
/**
* On console configured.
*
* @param event the event
* @param connection the console connection
* @throws InterruptedException the interrupted exception
*/
@Handler
@SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
public void onConsoleConfigured(ConsoleConfigured event,
ConsoleConnection connection) throws InterruptedException,
IOException {
@SuppressWarnings("unchecked")
final var rendered = (Set<String>) connection.session().get(RENDERED);
connection.session().remove(RENDERED);
if (!syncPreviews(connection.session())) {
return;
}
boolean foundMissing = false;
for (var vmName : accessibleVms(connection)) {
if (rendered.contains(vmName)) {
continue;
}
if (!foundMissing) {
// Suspending to allow rendering of conlets to be noticed
var failSafe = Components.schedule(t -> event.resumeHandling(),
Duration.ofSeconds(1));
event.suspendHandling(failSafe::cancel);
connection.setAssociated(PENDING, event);
foundMissing = true;
}
fire(new AddConletRequest(event.event().event().renderSupport(),
VmViewer.class.getName(),
RenderMode.asSet(RenderMode.Preview))
.addProperty(VM_NAME_PROPERTY, vmName),
connection);
}
}
/**
* On console prepared.
*
* @param event the event
* @param connection the connection
*/
@Handler
public void onConsolePrepared(ConsolePrepared event,
ConsoleConnection connection) {
if (syncPreviews(connection.session())) {
connection.respond(new UpdateConletType(type()));
}
}
private String storagePath(Session session, String conletId) {
@ -163,6 +262,20 @@ public class VmViewer extends FreeMarkerConlet<VmViewer.ViewerModel> {
+ "/" + VmViewer.class.getName() + "/" + conletId;
}
@Override
protected Optional<ViewerModel> createNewState(AddConletRequest event,
ConsoleConnection connection, String conletId) throws Exception {
var model = new ViewerModel(conletId);
model.vmName = (String) event.properties().get(VM_NAME_PROPERTY);
if (model.vmName != null) {
model.setGenerated(true);
}
String jsonState = objectMapper.writeValueAsString(model);
connection.respond(new KeyValueStoreUpdate().update(
storagePath(connection.session(), model.getConletId()), jsonState));
return Optional.of(model);
}
@Override
protected Optional<ViewerModel> createStateRepresentation(Event<?> event,
ConsoleConnection connection, String conletId) throws Exception {
@ -197,32 +310,57 @@ public class VmViewer extends FreeMarkerConlet<VmViewer.ViewerModel> {
}
@Override
@SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
@SuppressWarnings({ "PMD.AvoidInstantiatingObjectsInLoops", "unchecked" })
protected Set<RenderMode> doRenderConlet(RenderConletRequestBase<?> event,
ConsoleConnection channel, String conletId, ViewerModel conletState)
ConsoleConnection channel, String conletId, ViewerModel model)
throws Exception {
ResourceBundle resourceBundle = resourceBundle(channel.locale());
Set<RenderMode> renderedAs = new HashSet<>();
if (event.renderAs().contains(RenderMode.Preview)) {
channel.associated(PENDING, Event.class)
.ifPresent(e -> {
e.resumeHandling();
channel.setAssociated(PENDING, null);
});
// Remove conlet if definition has been removed
if (model.vmName() != null
&& !channelManager.associated(model.vmName()).isPresent()) {
channel.respond(
new DeleteConlet(conletId, Collections.emptySet()));
return Collections.emptySet();
}
// Don't render if user has not at least one permission
if (model.vmName() != null
&& channelManager.associated(model.vmName())
.map(d -> permissions(d, channel.session()).isEmpty())
.orElse(true)) {
return Collections.emptySet();
}
// Render
Template tpl
= freemarkerConfig().getTemplate("VmViewer-preview.ftl.html");
channel.respond(new RenderConlet(type(), conletId,
processTemplate(event, tpl,
fmModel(event, channel, conletId, conletState)))
fmModel(event, channel, conletId, model)))
.setRenderAs(
RenderMode.Preview.addModifiers(event.renderAs()))
.setSupportedModes(MODES));
.setSupportedModes(
model.isGenerated() ? MODES_FOR_GENERATED : MODES));
renderedAs.add(RenderMode.Preview);
if (!Strings.isNullOrEmpty(conletState.vmName())) {
updateConfig(channel, conletState);
if (!Strings.isNullOrEmpty(model.vmName())) {
Optional.ofNullable(channel.session().get(RENDERED))
.ifPresent(s -> ((Set<String>) s).add(model.vmName()));
updateConfig(channel, model);
}
}
if (event.renderAs().contains(RenderMode.Edit)) {
Template tpl = freemarkerConfig()
.getTemplate("VmViewer-edit.ftl.html");
var fmModel = fmModel(event, channel, conletId, conletState);
fmModel.put("vmNames",
channelManager.keys().stream().sorted().toList());
var fmModel = fmModel(event, channel, conletId, model);
fmModel.put("vmNames", accessibleVms(channel));
channel.respond(new OpenModalDialog(type(), conletId,
processTemplate(event, tpl, fmModel))
.addOption("cancelable", true)
@ -232,6 +370,21 @@ public class VmViewer extends FreeMarkerConlet<VmViewer.ViewerModel> {
return renderedAs;
}
private List<String> accessibleVms(ConsoleConnection channel) {
return channelManager.associated().stream()
.filter(d -> !permissions(d, channel.session()).isEmpty())
.map(d -> d.getMetadata().getName()).sorted().toList();
}
private Set<Permission> permissions(VmDefinitionModel vmDef,
Session session) {
var user = WebConsoleUtils.userFromSession(session)
.map(ConsoleUser::getName).orElse(null);
var roles = WebConsoleUtils.rolesFromSession(session)
.stream().map(ConsoleRole::getName).toList();
return vmDef.permissionsFor(user, roles);
}
private void updateConfig(ConsoleConnection channel, ViewerModel model) {
channel.respond(new NotifyConletView(type(),
model.getConletId(), "updateConfig", model.vmName()));
@ -246,6 +399,9 @@ public class VmViewer extends FreeMarkerConlet<VmViewer.ViewerModel> {
try {
var def = JsonBeanDecoder.create(vmDef.data().toString())
.readObject();
def.setField("userPermissions",
permissions(vmDef, channel.session()).stream()
.map(Permission::toString).toList());
channel.respond(new NotifyConletView(type(),
model.getConletId(), "updateVmDefinition", def));
} catch (JsonDecodeException e) {
@ -279,8 +435,10 @@ public class VmViewer extends FreeMarkerConlet<VmViewer.ViewerModel> {
"PMD.ConfusingArgumentToVarargsMethod" })
public void onVmDefChanged(VmDefChanged event, VmChannel channel)
throws JsonDecodeException, IOException {
var vmDef = new K8sDynamicModel(channel.client().getJSON()
var vmDef = new VmDefinitionModel(channel.client().getJSON()
.getGson(), event.vmDefinition().data());
GsonPtr.to(vmDef.data()).to("metadata").get(JsonObject.class)
.remove("managedFields");
var vmName = vmDef.getMetadata().getName();
if (event.type() == K8sObserver.ResponseType.DELETED) {
channelManager.remove(vmName);
@ -291,7 +449,8 @@ public class VmViewer extends FreeMarkerConlet<VmViewer.ViewerModel> {
var connection = entry.getKey();
for (var conletId : entry.getValue()) {
var model = stateFromSession(connection.session(), conletId);
if (model.isEmpty() || !model.get().vmName().equals(vmName)) {
if (model.isEmpty()
|| !Objects.areEqual(model.get().vmName(), vmName)) {
continue;
}
if (event.type() == K8sObserver.ResponseType.DELETED) {
@ -311,11 +470,15 @@ public class VmViewer extends FreeMarkerConlet<VmViewer.ViewerModel> {
ConsoleConnection channel, ViewerModel model)
throws Exception {
event.stop();
var vmName = event.params().asString(0);
var vmChannel = channelManager.channel(vmName).orElse(null);
if (vmChannel == null) {
var both = Optional.ofNullable(event.params().asString(0))
.flatMap(vm -> channelManager.both(vm));
if (both.isEmpty()) {
return;
}
var vmChannel = both.get().channel;
var vmDef = both.get().associated;
var vmName = vmDef.metadata().getName();
var perms = permissions(vmDef, channel.session());
switch (event.method()) {
case "selectedVm":
model.setVmName(event.params().asString(0));
@ -325,15 +488,22 @@ public class VmViewer extends FreeMarkerConlet<VmViewer.ViewerModel> {
updateConfig(channel, model);
break;
case "start":
fire(new ModifyVm(vmName, "state", "Running", vmChannel));
if (perms.contains(Permission.START)) {
fire(new ModifyVm(vmName, "state", "Running", vmChannel));
}
break;
case "stop":
fire(new ModifyVm(vmName, "state", "Stopped", vmChannel));
if (perms.contains(Permission.STOP)) {
fire(new ModifyVm(vmName, "state", "Stopped", vmChannel));
}
break;
case "openConsole":
channelManager.channel(vmName).ifPresent(
vc -> fire(Event.onCompletion(new GetDisplayPassword(vmName),
ds -> openConsole(vmName, channel, model, ds)), vc));
if (perms.contains(Permission.ACCESS_CONSOLE)) {
var pwQuery = Event.onCompletion(new GetDisplayPassword(vmDef),
e -> e.password().ifPresent(
pw -> openConsole(vmName, channel, model, pw)));
fire(pwQuery, vmChannel);
}
break;
default:// ignore
break;
@ -341,7 +511,7 @@ public class VmViewer extends FreeMarkerConlet<VmViewer.ViewerModel> {
}
private void openConsole(String vmName, ConsoleConnection connection,
ViewerModel model, GetDisplayPassword pwQuery) {
ViewerModel model, String password) {
var vmDef = channelManager.associated(vmName).orElse(null);
if (vmDef == null) {
return;
@ -362,10 +532,8 @@ public class VmViewer extends FreeMarkerConlet<VmViewer.ViewerModel> {
StringBuffer data = new StringBuffer(100)
.append("[virt-viewer]\ntype=spice\nhost=")
.append(addr.get().getHostAddress()).append("\nport=")
.append(Integer.toString(port.get().getAsInt())).append('\n');
pwQuery.password().ifPresent(p -> {
data.append("password=").append(p).append('\n');
});
.append(Integer.toString(port.get().getAsInt()))
.append("\npassword=").append(password).append('\n');
proxyUrl.map(JsonPrimitive::getAsString).ifPresent(u -> {
if (!Strings.isNullOrEmpty(u)) {
data.append("proxy=").append(u).append('\n');
@ -418,9 +586,11 @@ public class VmViewer extends FreeMarkerConlet<VmViewer.ViewerModel> {
/**
* The Class VmsModel.
*/
@SuppressWarnings("PMD.DataClass")
public static class ViewerModel extends ConletBaseModel {
private String vmName;
private boolean generated;
/**
* Instantiates a new vms model.
@ -450,5 +620,23 @@ public class VmViewer extends FreeMarkerConlet<VmViewer.ViewerModel> {
this.vmName = vmName;
}
/**
* Checks if is generated.
*
* @return the generated
*/
public boolean isGenerated() {
return generated;
}
/**
* Sets the generated.
*
* @param generated the generated to set
*/
public void setGenerated(boolean generated) {
this.generated = generated;
}
}
}

View file

@ -84,17 +84,22 @@ window.orgJDrupesVmOperatorVmViewer.initPreview = (previewDom: HTMLElement,
<tbody>
<tr>
<td><img role=button
:aria-disabled="!vmDef.running || !vmDef.userPermissions
|| !vmDef.userPermissions.includes('accessConsole')"
v-on:click="vmAction(vmDef.name, 'openConsole')"
:src="resourceBase + (vmDef.running
? 'computer.svg' : 'computer-off.svg')"></td>
? 'computer.svg' : 'computer-off.svg')"
:title="localize('Open console')"></td>
<td v-if="vmDef.spec"
class="jdrupes-vmoperator-vmviewer-preview-action-list">
<span role="button" v-if="vmDef.spec.vm.state != 'Running'"
tabindex="0" class="fa fa-play" :title="localize('Start VM')"
<span role="button" v-if="vmDef.spec.vm.state != 'Running'"
:aria-disabled="!vmDef.userPermissions.includes('start')"
tabindex="0" class="fa fa-play" :title="localize('Start VM')"
v-on:click="vmAction(vmDef.name, 'start')"></span>
<span role="button" v-else class="fa fa-play"
aria-disabled="true" :title="localize('Start VM')"></span>
<span role="button" v-if="vmDef.spec.vm.state != 'Stopped'"
:aria-disabled="!vmDef.userPermissions.includes('stop')"
tabindex="0" class="fa fa-stop" :title="localize('Stop VM')"
v-on:click="vmAction(vmDef.name, 'stop')"></span>
<span role="button" v-else class="fa fa-stop"

View file

@ -19,24 +19,27 @@
/*
* Conlet specific styles.
*/
.jdrupes-vmoperator-vmviewer-preview img {
height: 3em;
padding: 0.25rem;
&:hover {
box-shadow: var(--darkening);
.jdrupes-vmoperator-vmviewer-preview {
[role=button] {
padding: 0.25rem;
&:not([aria-disabled]):hover, &[aria-disabled='false']:hover {
box-shadow: var(--darkening);
}
}
img {
height: 3em;
padding: 0.25rem;
&[aria-disabled=''], &[aria-disabled='true'] {
opacity: 0.4;
}
}
}
.jdrupes-vmoperator-vmviewer-preview-action-list {
white-space: nowrap;
[role=button] {
padding: 0.25rem;
&:not([aria-disabled]):hover {
box-shadow: var(--darkening);
}
}
}