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