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 */ 069@SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", "PMD.TooManyStaticImports" }) 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 if (c.containsKey("passwordValidity")) { 108 passwordValidity = Integer 109 .parseInt((String) c.get("passwordValidity")); 110 } 111 } catch (ClassCastException e) { 112 logger.config("Malformed configuration: " + e.getMessage()); 113 } 114 }); 115 } 116 117 /** 118 * Reconcile. If the configuration prevents generating a secret 119 * or the secret already exists, do nothing. Else generate a new 120 * secret with a random password and immediate expiration, thus 121 * preventing access to the display. 122 * 123 * @param vmDef the VM definition 124 * @param model the model 125 * @param channel the channel 126 * @param specChanged the spec changed 127 * @throws IOException Signals that an I/O exception has occurred. 128 * @throws TemplateException the template exception 129 * @throws ApiException the api exception 130 */ 131 public void reconcile(VmDefinition vmDef, Map<String, Object> model, 132 VmChannel channel, boolean specChanged) 133 throws IOException, TemplateException, ApiException { 134 // Nothing to do unless spec changed 135 if (!specChanged) { 136 return; 137 } 138 139 // Secret needed at all? 140 var display = vmDef.fromVm("display").get(); 141 if (!DataPath.<Boolean> get(display, "spice", "generateSecret") 142 .orElse(true)) { 143 return; 144 } 145 146 // Check if exists 147 ListOptions options = new ListOptions(); 148 options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + "," 149 + "app.kubernetes.io/component=" + DisplaySecret.NAME + "," 150 + "app.kubernetes.io/instance=" + vmDef.name()); 151 var stubs = K8sV1SecretStub.list(channel.client(), vmDef.namespace(), 152 options); 153 if (!stubs.isEmpty()) { 154 return; 155 } 156 157 // Create secret 158 var secretName = vmDef.name() + "-" + DisplaySecret.NAME; 159 logger.fine(() -> "Create/update secret " + secretName); 160 var secret = new V1Secret(); 161 secret.setMetadata(new V1ObjectMeta().namespace(vmDef.namespace()) 162 .name(secretName) 163 .putLabelsItem("app.kubernetes.io/name", APP_NAME) 164 .putLabelsItem("app.kubernetes.io/component", DisplaySecret.NAME) 165 .putLabelsItem("app.kubernetes.io/instance", vmDef.name())); 166 secret.setType("Opaque"); 167 SecureRandom random = null; 168 try { 169 random = SecureRandom.getInstanceStrong(); 170 } catch (NoSuchAlgorithmException e) { // NOPMD 171 // "Every implementation of the Java platform is required 172 // to support at least one strong SecureRandom implementation." 173 } 174 byte[] bytes = new byte[16]; 175 random.nextBytes(bytes); 176 var password = Base64.encode(bytes); 177 secret.setStringData(Map.of(DisplaySecret.PASSWORD, password, 178 DisplaySecret.EXPIRY, "now")); 179 K8sV1SecretStub.create(channel.client(), secret); 180 } 181 182 /** 183 * Prepares access to the console for the user from the event. 184 * Generates a new password and sends it to the runner. 185 * Requests the VM (via the runner) to login the user if specified 186 * in the event. 187 * 188 * @param event the event 189 * @param channel the channel 190 * @throws ApiException the api exception 191 */ 192 @Handler 193 @SuppressWarnings("PMD.StringInstantiation") 194 public void onGetDisplaySecret(GetDisplaySecret event, VmChannel channel) 195 throws ApiException { 196 // Get VM definition and check if running 197 var vmStub = VmDefinitionStub.get(channel.client(), 198 new GroupVersionKind(Crd.GROUP, "", Crd.KIND_VM), 199 event.vmDefinition().namespace(), event.vmDefinition().name()); 200 var vmDef = vmStub.model().orElse(null); 201 if (vmDef == null || !vmDef.conditionStatus("Running").orElse(false)) { 202 return; 203 } 204 205 // Update console user in status 206 vmDef = vmStub.updateStatus(from -> { 207 JsonObject status = from.statusJson(); 208 status.addProperty(Status.CONSOLE_USER, event.user()); 209 return status; 210 }).get(); 211 212 // Get secret and update password in secret 213 var stub = getSecretStub(event, channel, vmDef); 214 if (stub == null) { 215 return; 216 } 217 var secret = stub.model().get(); 218 if (!updatePassword(secret, event)) { 219 return; 220 } 221 222 // Register wait for confirmation (by VM status change, 223 // after secret update) 224 var pending = new PendingRequest(event, 225 event.vmDefinition().displayPasswordSerial().orElse(0L) + 1, 226 new CompletionLock(event, 1500)); 227 pendingPrepares.add(pending); 228 Event.onCompletion(event, e -> { 229 pendingPrepares.remove(pending); 230 }); 231 232 // Update, will (eventually) trigger confirmation 233 stub.update(secret).getObject(); 234 } 235 236 private K8sV1SecretStub getSecretStub(GetDisplaySecret event, 237 VmChannel channel, VmDefinition vmDef) throws ApiException { 238 // Look for secret 239 ListOptions options = new ListOptions(); 240 options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + "," 241 + "app.kubernetes.io/component=" + DisplaySecret.NAME + "," 242 + "app.kubernetes.io/instance=" + vmDef.name()); 243 var stubs = K8sV1SecretStub.list(channel.client(), vmDef.namespace(), 244 options); 245 if (stubs.isEmpty()) { 246 // No secret means no password for this VM wanted 247 event.setResult(null); 248 return null; 249 } 250 return stubs.iterator().next(); 251 } 252 253 private boolean updatePassword(V1Secret secret, GetDisplaySecret event) { 254 var expiry = Optional.ofNullable(secret.getData() 255 .get(DisplaySecret.EXPIRY)).map(b -> new String(b)).orElse(null); 256 if (secret.getData().get(DisplaySecret.PASSWORD) != null 257 && stillValid(expiry)) { 258 // Fixed secret, don't touch 259 event.setResult( 260 new String(secret.getData().get(DisplaySecret.PASSWORD))); 261 return false; 262 } 263 264 // Generate password and set expiry 265 SecureRandom random = null; 266 try { 267 random = SecureRandom.getInstanceStrong(); 268 } catch (NoSuchAlgorithmException e) { // NOPMD 269 // "Every implementation of the Java platform is required 270 // to support at least one strong SecureRandom implementation." 271 } 272 byte[] bytes = new byte[16]; 273 random.nextBytes(bytes); 274 var password = Base64.encode(bytes); 275 secret.setStringData(Map.of(DisplaySecret.PASSWORD, password, 276 DisplaySecret.EXPIRY, 277 Long.toString(Instant.now().getEpochSecond() + passwordValidity))); 278 event.setResult(password); 279 return true; 280 } 281 282 private boolean stillValid(String expiry) { 283 if (expiry == null || "never".equals(expiry)) { 284 return true; 285 } 286 @SuppressWarnings({ "PMD.CloseResource", "resource" }) 287 var scanner = new Scanner(expiry); 288 if (!scanner.hasNextLong()) { 289 return false; 290 } 291 long expTime = scanner.nextLong(); 292 return expTime > Instant.now().getEpochSecond() + passwordValidity; 293 } 294 295 /** 296 * On vm def changed. 297 * 298 * @param event the event 299 * @param channel the channel 300 */ 301 @Handler 302 @SuppressWarnings("PMD.AvoidSynchronizedStatement") 303 public void onVmResourceChanged(VmResourceChanged event, Channel channel) { 304 synchronized (pendingPrepares) { 305 String vmName = event.vmDefinition().name(); 306 for (var pending : pendingPrepares) { 307 if (pending.event.vmDefinition().name().equals(vmName) 308 && event.vmDefinition().displayPasswordSerial() 309 .map(s -> s >= pending.expectedSerial).orElse(false)) { 310 pending.lock.remove(); 311 // pending will be removed from pendingGest by 312 // waiting thread, see updatePassword 313 continue; 314 } 315 } 316 } 317 } 318 319 /** 320 * The Class PendingGet. 321 */ 322 @SuppressWarnings("PMD.DataClass") 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}