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.PrepareConsole; 047import org.jdrupes.vmoperator.manager.events.VmChannel; 048import org.jdrupes.vmoperator.manager.events.VmDefChanged; 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<PendingPrepare> 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 event the event 124 * @param model the model 125 * @param channel the channel 126 * @throws IOException Signals that an I/O exception has occurred. 127 * @throws TemplateException the template exception 128 * @throws ApiException the api exception 129 */ 130 public void reconcile(VmDefChanged event, 131 Map<String, Object> model, VmChannel channel) 132 throws IOException, TemplateException, ApiException { 133 // Secret needed at all? 134 var display = event.vmDefinition().fromVm("display").get(); 135 if (!DataPath.<Boolean> get(display, "spice", "generateSecret") 136 .orElse(true)) { 137 return; 138 } 139 140 // Check if exists 141 var vmDef = event.vmDefinition(); 142 ListOptions options = new ListOptions(); 143 options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + "," 144 + "app.kubernetes.io/component=" + DisplaySecret.NAME + "," 145 + "app.kubernetes.io/instance=" + vmDef.name()); 146 var stubs = K8sV1SecretStub.list(channel.client(), vmDef.namespace(), 147 options); 148 if (!stubs.isEmpty()) { 149 return; 150 } 151 152 // Create secret 153 var secret = new V1Secret(); 154 secret.setMetadata(new V1ObjectMeta().namespace(vmDef.namespace()) 155 .name(vmDef.name() + "-" + DisplaySecret.NAME) 156 .putLabelsItem("app.kubernetes.io/name", APP_NAME) 157 .putLabelsItem("app.kubernetes.io/component", DisplaySecret.NAME) 158 .putLabelsItem("app.kubernetes.io/instance", vmDef.name())); 159 secret.setType("Opaque"); 160 SecureRandom random = null; 161 try { 162 random = SecureRandom.getInstanceStrong(); 163 } catch (NoSuchAlgorithmException e) { // NOPMD 164 // "Every implementation of the Java platform is required 165 // to support at least one strong SecureRandom implementation." 166 } 167 byte[] bytes = new byte[16]; 168 random.nextBytes(bytes); 169 var password = Base64.encode(bytes); 170 secret.setStringData(Map.of(DisplaySecret.PASSWORD, password, 171 DisplaySecret.EXPIRY, "now")); 172 K8sV1SecretStub.create(channel.client(), secret); 173 } 174 175 /** 176 * Prepares access to the console for the user from the event. 177 * Generates a new password and sends it to the runner. 178 * Requests the VM (via the runner) to login the user if specified 179 * in the event. 180 * 181 * @param event the event 182 * @param channel the channel 183 * @throws ApiException the api exception 184 */ 185 @Handler 186 @SuppressWarnings("PMD.StringInstantiation") 187 public void onPrepareConsole(PrepareConsole event, VmChannel channel) 188 throws ApiException { 189 // Update console user in status 190 var vmDef = updateConsoleUser(event, channel); 191 if (vmDef == null) { 192 return; 193 } 194 195 // Check if access is possible 196 if (event.loginUser() 197 ? !vmDef.<String> fromStatus(Status.LOGGED_IN_USER) 198 .map(u -> u.equals(event.user())).orElse(false) 199 : !vmDef.conditionStatus("Running").orElse(false)) { 200 return; 201 } 202 203 // Get secret and update password in secret 204 var stub = getSecretStub(event, channel, vmDef); 205 if (stub == null) { 206 return; 207 } 208 var secret = stub.model().get(); 209 if (!updatePassword(secret, event)) { 210 return; 211 } 212 213 // Register wait for confirmation (by VM status change, 214 // after secret update) 215 var pending = new PendingPrepare(event, 216 event.vmDefinition().displayPasswordSerial().orElse(0L) + 1, 217 new CompletionLock(event, 1500)); 218 pendingPrepares.add(pending); 219 Event.onCompletion(event, e -> { 220 pendingPrepares.remove(pending); 221 }); 222 223 // Update, will (eventually) trigger confirmation 224 stub.update(secret).getObject(); 225 } 226 227 private VmDefinition updateConsoleUser(PrepareConsole event, 228 VmChannel channel) throws ApiException { 229 var vmStub = VmDefinitionStub.get(channel.client(), 230 new GroupVersionKind(Crd.GROUP, "", Crd.KIND_VM), 231 event.vmDefinition().namespace(), event.vmDefinition().name()); 232 return vmStub.updateStatus(from -> { 233 JsonObject status = from.statusJson(); 234 status.addProperty(Status.CONSOLE_USER, event.user()); 235 return status; 236 }).orElse(null); 237 } 238 239 private K8sV1SecretStub getSecretStub(PrepareConsole event, 240 VmChannel channel, VmDefinition vmDef) throws ApiException { 241 // Look for secret 242 ListOptions options = new ListOptions(); 243 options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + "," 244 + "app.kubernetes.io/component=" + DisplaySecret.NAME + "," 245 + "app.kubernetes.io/instance=" + vmDef.name()); 246 var stubs = K8sV1SecretStub.list(channel.client(), vmDef.namespace(), 247 options); 248 if (stubs.isEmpty()) { 249 // No secret means no password for this VM wanted 250 event.setResult(null); 251 return null; 252 } 253 return stubs.iterator().next(); 254 } 255 256 private boolean updatePassword(V1Secret secret, PrepareConsole event) { 257 var expiry = Optional.ofNullable(secret.getData() 258 .get(DisplaySecret.EXPIRY)).map(b -> new String(b)).orElse(null); 259 if (secret.getData().get(DisplaySecret.PASSWORD) != null 260 && stillValid(expiry)) { 261 // Fixed secret, don't touch 262 event.setResult( 263 new String(secret.getData().get(DisplaySecret.PASSWORD))); 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 onVmDefChanged(VmDefChanged 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 /** 323 * The Class PendingGet. 324 */ 325 @SuppressWarnings("PMD.DataClass") 326 private static class PendingPrepare { 327 public final PrepareConsole event; 328 public final long expectedSerial; 329 public final CompletionLock lock; 330 331 /** 332 * Instantiates a new pending get. 333 * 334 * @param event the event 335 * @param expectedSerial the expected serial 336 */ 337 public PendingPrepare(PrepareConsole event, long expectedSerial, 338 CompletionLock lock) { 339 super(); 340 this.event = event; 341 this.expectedSerial = expectedSerial; 342 this.lock = lock; 343 } 344 } 345}