001/* 002 * VM-Operator 003 * Copyright (C) 2025 Michael N. Lipp 004 * 005 * This program is free software: you can redistribute it and/or modify 006 * it under the terms of the GNU Affero General Public License as 007 * published by the Free Software Foundation, either version 3 of the 008 * License, or (at your option) any later version. 009 * 010 * This program is distributed in the hope that it will be useful, 011 * but WITHOUT ANY WARRANTY; without even the implied warranty of 012 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 013 * GNU Affero General Public License for more details. 014 * 015 * You should have received a copy of the GNU Affero General Public License 016 * along with this program. If not, see <https://www.gnu.org/licenses/>. 017 */ 018 019package org.jdrupes.vmoperator.manager; 020 021import com.google.gson.JsonObject; 022import freemarker.template.TemplateException; 023import io.kubernetes.client.apimachinery.GroupVersionKind; 024import io.kubernetes.client.openapi.ApiException; 025import io.kubernetes.client.openapi.models.V1ObjectMeta; 026import io.kubernetes.client.openapi.models.V1Secret; 027import io.kubernetes.client.util.generic.options.ListOptions; 028import java.io.IOException; 029import java.nio.charset.StandardCharsets; 030import java.security.NoSuchAlgorithmException; 031import java.security.SecureRandom; 032import java.time.Instant; 033import java.util.Collections; 034import java.util.LinkedList; 035import java.util.List; 036import java.util.Map; 037import java.util.Optional; 038import java.util.Scanner; 039import java.util.logging.Logger; 040import static org.jdrupes.vmoperator.common.Constants.APP_NAME; 041import org.jdrupes.vmoperator.common.Constants.Crd; 042import org.jdrupes.vmoperator.common.Constants.DisplaySecret; 043import org.jdrupes.vmoperator.common.Constants.Status; 044import org.jdrupes.vmoperator.common.K8sV1SecretStub; 045import org.jdrupes.vmoperator.common.VmDefinition; 046import org.jdrupes.vmoperator.common.VmDefinitionStub; 047import org.jdrupes.vmoperator.manager.events.GetDisplaySecret; 048import org.jdrupes.vmoperator.manager.events.VmChannel; 049import org.jdrupes.vmoperator.manager.events.VmResourceChanged; 050import org.jdrupes.vmoperator.util.DataPath; 051import org.jgrapes.core.Channel; 052import org.jgrapes.core.CompletionLock; 053import org.jgrapes.core.Component; 054import org.jgrapes.core.Event; 055import org.jgrapes.core.annotation.Handler; 056import org.jgrapes.util.events.ConfigurationUpdate; 057import org.jose4j.base64url.Base64; 058 059/** 060 * The properties of the display secret do not only depend on the 061 * VM definition, but also on events that occur during runtime. 062 * The reconciler for the display secret is therefore a separate 063 * component. 064 * 065 * The reconciler supports the following configuration properties: 066 * 067 * * `passwordValidity`: the validity of the random password in seconds. 068 * Used to calculate the password expiry time in the generated secret. 069 */ 070public class DisplaySecretReconciler extends Component { 071 072 protected final Logger logger = Logger.getLogger(getClass().getName()); 073 private int passwordValidity = 10; 074 private final List<PendingRequest> pendingPrepares 075 = Collections.synchronizedList(new LinkedList<>()); 076 077 /** 078 * Instantiates a new display secret reconciler. 079 * 080 * @param componentChannel the component channel 081 */ 082 public DisplaySecretReconciler(Channel componentChannel) { 083 super(componentChannel); 084 } 085 086 /** 087 * On configuration update. 088 * 089 * @param event the event 090 */ 091 @Handler 092 public void onConfigurationUpdate(ConfigurationUpdate event) { 093 event.structured(componentPath()) 094 // for backward compatibility 095 .or(() -> { 096 var oldConfig = event 097 .structured("/Manager/Controller/DisplaySecretMonitor"); 098 if (oldConfig.isPresent()) { 099 logger.warning(() -> "Using configuration with old " 100 + "path '/Manager/Controller/DisplaySecretMonitor' " 101 + "for `passwordValidity`, please update " 102 + "the configuration."); 103 } 104 return oldConfig; 105 }).ifPresent(c -> { 106 try { 107 Optional.ofNullable(c.get("passwordValidity")) 108 .map(p -> p instanceof Integer ? (Integer) p 109 : Integer.valueOf((String) p)) 110 .ifPresent(p -> { 111 passwordValidity = p; 112 }); 113 } catch (NumberFormatException e) { 114 logger.warning( 115 () -> "Malformed configuration: " + e.getMessage()); 116 } 117 }); 118 } 119 120 /** 121 * Reconcile. If the configuration prevents generating a secret 122 * or the secret already exists, do nothing. Else generate a new 123 * secret with a random password and immediate expiration, thus 124 * preventing access to the display. 125 * 126 * @param vmDef the VM definition 127 * @param model the model 128 * @param channel the channel 129 * @param specChanged the spec changed 130 * @throws IOException Signals that an I/O exception has occurred. 131 * @throws TemplateException the template exception 132 * @throws ApiException the api exception 133 */ 134 public void reconcile(VmDefinition vmDef, Map<String, Object> model, 135 VmChannel channel, boolean specChanged) 136 throws IOException, TemplateException, ApiException { 137 // Nothing to do unless spec changed 138 if (!specChanged) { 139 return; 140 } 141 142 // Secret needed at all? 143 var display = vmDef.fromVm("display").get(); 144 if (!DataPath.<Boolean> get(display, "spice", "generateSecret") 145 .orElse(true)) { 146 return; 147 } 148 149 // Check if exists 150 ListOptions options = new ListOptions(); 151 options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + "," 152 + "app.kubernetes.io/component=" + DisplaySecret.NAME + "," 153 + "app.kubernetes.io/instance=" + vmDef.name()); 154 var stubs = K8sV1SecretStub.list(channel.client(), vmDef.namespace(), 155 options); 156 if (!stubs.isEmpty()) { 157 return; 158 } 159 160 // Create secret 161 var secretName = vmDef.name() + "-" + DisplaySecret.NAME; 162 logger.fine(() -> "Create/update secret " + secretName); 163 var secret = new V1Secret(); 164 secret.setMetadata(new V1ObjectMeta().namespace(vmDef.namespace()) 165 .name(secretName) 166 .putLabelsItem("app.kubernetes.io/name", APP_NAME) 167 .putLabelsItem("app.kubernetes.io/component", DisplaySecret.NAME) 168 .putLabelsItem("app.kubernetes.io/instance", vmDef.name())); 169 secret.setType("Opaque"); 170 SecureRandom random = null; 171 try { 172 random = SecureRandom.getInstanceStrong(); 173 } catch (NoSuchAlgorithmException e) { // NOPMD 174 // "Every implementation of the Java platform is required 175 // to support at least one strong SecureRandom implementation." 176 } 177 byte[] bytes = new byte[16]; 178 random.nextBytes(bytes); 179 var password = Base64.encode(bytes); 180 secret.setStringData(Map.of(DisplaySecret.PASSWORD, password, 181 DisplaySecret.EXPIRY, "now")); 182 K8sV1SecretStub.create(channel.client(), secret); 183 } 184 185 /** 186 * Prepares access to the console for the user from the event. 187 * Generates a new password and sends it to the runner. 188 * Requests the VM (via the runner) to login the user if specified 189 * in the event. 190 * 191 * @param event the event 192 * @param channel the channel 193 * @throws ApiException the api exception 194 */ 195 @Handler 196 public void onGetDisplaySecret(GetDisplaySecret event, VmChannel channel) 197 throws ApiException { 198 // Get VM definition and check if running 199 var vmStub = VmDefinitionStub.get(channel.client(), 200 new GroupVersionKind(Crd.GROUP, "", Crd.KIND_VM), 201 event.vmDefinition().namespace(), event.vmDefinition().name()); 202 var vmDef = vmStub.model().orElse(null); 203 if (vmDef == null || !vmDef.conditionStatus("Running").orElse(false)) { 204 return; 205 } 206 207 // Update console user in status 208 vmDef = vmStub.updateStatus(from -> { 209 JsonObject status = from.statusJson(); 210 status.addProperty(Status.CONSOLE_USER, event.user()); 211 return status; 212 }).get(); 213 214 // Get secret and update password in secret 215 var stub = getSecretStub(event, channel, vmDef); 216 if (stub == null) { 217 return; 218 } 219 var secret = stub.model().get(); 220 if (!updatePassword(secret, event)) { 221 return; 222 } 223 224 // Register wait for confirmation (by VM status change, 225 // after secret update) 226 var pending = new PendingRequest(event, 227 event.vmDefinition().displayPasswordSerial().orElse(0L) + 1, 228 new CompletionLock(event, 1500)); 229 pendingPrepares.add(pending); 230 Event.onCompletion(event, e -> { 231 pendingPrepares.remove(pending); 232 }); 233 234 // Update, will (eventually) trigger confirmation 235 stub.update(secret).getObject(); 236 } 237 238 private K8sV1SecretStub getSecretStub(GetDisplaySecret event, 239 VmChannel channel, VmDefinition vmDef) throws ApiException { 240 // Look for secret 241 ListOptions options = new ListOptions(); 242 options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + "," 243 + "app.kubernetes.io/component=" + DisplaySecret.NAME + "," 244 + "app.kubernetes.io/instance=" + vmDef.name()); 245 var stubs = K8sV1SecretStub.list(channel.client(), vmDef.namespace(), 246 options); 247 if (stubs.isEmpty()) { 248 // No secret means no password for this VM wanted 249 event.setResult(null); 250 return null; 251 } 252 return stubs.iterator().next(); 253 } 254 255 private boolean updatePassword(V1Secret secret, GetDisplaySecret event) { 256 var expiry = Optional.ofNullable(secret.getData() 257 .get(DisplaySecret.EXPIRY)) 258 .map(b -> new String(b, StandardCharsets.UTF_8)).orElse(null); 259 if (secret.getData().get(DisplaySecret.PASSWORD) != null 260 && stillValid(expiry)) { 261 // Fixed secret, don't touch 262 event.setResult(new String(secret.getData() 263 .get(DisplaySecret.PASSWORD), StandardCharsets.UTF_8)); 264 return false; 265 } 266 267 // Generate password and set expiry 268 SecureRandom random = null; 269 try { 270 random = SecureRandom.getInstanceStrong(); 271 } catch (NoSuchAlgorithmException e) { // NOPMD 272 // "Every implementation of the Java platform is required 273 // to support at least one strong SecureRandom implementation." 274 } 275 byte[] bytes = new byte[16]; 276 random.nextBytes(bytes); 277 var password = Base64.encode(bytes); 278 secret.setStringData(Map.of(DisplaySecret.PASSWORD, password, 279 DisplaySecret.EXPIRY, 280 Long.toString(Instant.now().getEpochSecond() + passwordValidity))); 281 event.setResult(password); 282 return true; 283 } 284 285 private boolean stillValid(String expiry) { 286 if (expiry == null || "never".equals(expiry)) { 287 return true; 288 } 289 @SuppressWarnings({ "PMD.CloseResource", "resource" }) 290 var scanner = new Scanner(expiry); 291 if (!scanner.hasNextLong()) { 292 return false; 293 } 294 long expTime = scanner.nextLong(); 295 return expTime > Instant.now().getEpochSecond() + passwordValidity; 296 } 297 298 /** 299 * On vm def changed. 300 * 301 * @param event the event 302 * @param channel the channel 303 */ 304 @Handler 305 @SuppressWarnings("PMD.AvoidSynchronizedStatement") 306 public void onVmResourceChanged(VmResourceChanged event, Channel channel) { 307 synchronized (pendingPrepares) { 308 String vmName = event.vmDefinition().name(); 309 for (var pending : pendingPrepares) { 310 if (pending.event.vmDefinition().name().equals(vmName) 311 && event.vmDefinition().displayPasswordSerial() 312 .map(s -> s >= pending.expectedSerial).orElse(false)) { 313 pending.lock.remove(); 314 // pending will be removed from pendingGest by 315 // waiting thread, see updatePassword 316 continue; 317 } 318 } 319 } 320 } 321 322 private record PendingRequest(GetDisplaySecret event, long expectedSerial, 323 CompletionLock lock) { 324 } 325}