001/* 002 * VM-Operator 003 * Copyright (C) 2024 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 io.kubernetes.client.custom.V1Patch; 022import io.kubernetes.client.openapi.ApiException; 023import io.kubernetes.client.openapi.models.V1Secret; 024import io.kubernetes.client.openapi.models.V1SecretList; 025import io.kubernetes.client.util.Watch.Response; 026import io.kubernetes.client.util.generic.options.ListOptions; 027import io.kubernetes.client.util.generic.options.PatchOptions; 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.Level; 039import static org.jdrupes.vmoperator.common.Constants.APP_NAME; 040import static org.jdrupes.vmoperator.common.Constants.VM_OP_NAME; 041import org.jdrupes.vmoperator.common.K8sClient; 042import org.jdrupes.vmoperator.common.K8sV1PodStub; 043import org.jdrupes.vmoperator.common.K8sV1SecretStub; 044import static org.jdrupes.vmoperator.manager.Constants.COMP_DISPLAY_SECRET; 045import static org.jdrupes.vmoperator.manager.Constants.DATA_DISPLAY_PASSWORD; 046import static org.jdrupes.vmoperator.manager.Constants.DATA_PASSWORD_EXPIRY; 047import org.jdrupes.vmoperator.manager.events.ChannelDictionary; 048import org.jdrupes.vmoperator.manager.events.GetDisplayPassword; 049import org.jdrupes.vmoperator.manager.events.VmChannel; 050import org.jdrupes.vmoperator.manager.events.VmDefChanged; 051import org.jgrapes.core.Channel; 052import org.jgrapes.core.CompletionLock; 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 * Watches for changes of display secrets. The component supports the 060 * following configuration properties: 061 * 062 * * `passwordValidity`: the validity of the random password in seconds. 063 * Used to calculate the password expiry time in the generated secret. 064 */ 065@SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", "PMD.TooManyStaticImports" }) 066public class DisplaySecretMonitor 067 extends AbstractMonitor<V1Secret, V1SecretList, VmChannel> { 068 069 private int passwordValidity = 10; 070 private final List<PendingGet> pendingGets 071 = Collections.synchronizedList(new LinkedList<>()); 072 private final ChannelDictionary<String, VmChannel, ?> channelDictionary; 073 074 /** 075 * Instantiates a new display secrets monitor. 076 * 077 * @param componentChannel the component channel 078 * @param channelDictionary the channel dictionary 079 */ 080 public DisplaySecretMonitor(Channel componentChannel, 081 ChannelDictionary<String, VmChannel, ?> channelDictionary) { 082 super(componentChannel, V1Secret.class, V1SecretList.class); 083 this.channelDictionary = channelDictionary; 084 context(K8sV1SecretStub.CONTEXT); 085 ListOptions options = new ListOptions(); 086 options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + "," 087 + "app.kubernetes.io/component=" + COMP_DISPLAY_SECRET); 088 options(options); 089 } 090 091 /** 092 * On configuration update. 093 * 094 * @param event the event 095 */ 096 @Handler 097 @Override 098 public void onConfigurationUpdate(ConfigurationUpdate event) { 099 super.onConfigurationUpdate(event); 100 event.structured(componentPath()).ifPresent(c -> { 101 try { 102 if (c.containsKey("passwordValidity")) { 103 passwordValidity = Integer 104 .parseInt((String) c.get("passwordValidity")); 105 } 106 } catch (ClassCastException e) { 107 logger.config("Malformed configuration: " + e.getMessage()); 108 } 109 }); 110 } 111 112 @Override 113 protected void prepareMonitoring() throws IOException, ApiException { 114 client(new K8sClient()); 115 } 116 117 @Override 118 protected void handleChange(K8sClient client, Response<V1Secret> change) { 119 String vmName = change.object.getMetadata().getLabels() 120 .get("app.kubernetes.io/instance"); 121 if (vmName == null) { 122 return; 123 } 124 var channel = channelDictionary.channel(vmName).orElse(null); 125 if (channel == null || channel.vmDefinition() == null) { 126 return; 127 } 128 129 try { 130 patchPod(client, change); 131 } catch (ApiException e) { 132 logger.log(Level.WARNING, e, 133 () -> "Cannot patch pod annotations: " + e.getMessage()); 134 } 135 } 136 137 private void patchPod(K8sClient client, Response<V1Secret> change) 138 throws ApiException { 139 // Force update for pod 140 ListOptions listOpts = new ListOptions(); 141 listOpts.setLabelSelector( 142 "app.kubernetes.io/managed-by=" + VM_OP_NAME + "," 143 + "app.kubernetes.io/name=" + APP_NAME + "," 144 + "app.kubernetes.io/instance=" + change.object.getMetadata() 145 .getLabels().get("app.kubernetes.io/instance")); 146 // Get pod, selected by label 147 var pods = K8sV1PodStub.list(client, namespace(), listOpts); 148 149 // If the VM is being created, the pod may not exist yet. 150 if (pods.isEmpty()) { 151 return; 152 } 153 var pod = pods.iterator().next(); 154 155 // Patch pod annotation 156 PatchOptions patchOpts = new PatchOptions(); 157 patchOpts.setFieldManager("kubernetes-java-kubectl-apply"); 158 pod.patch(V1Patch.PATCH_FORMAT_JSON_PATCH, 159 new V1Patch("[{\"op\": \"replace\", \"path\": " 160 + "\"/metadata/annotations/vmrunner.jdrupes.org~1dpVersion\", " 161 + "\"value\": \"" 162 + change.object.getMetadata().getResourceVersion() 163 + "\"}]"), 164 patchOpts); 165 } 166 167 /** 168 * On get display secrets. 169 * 170 * @param event the event 171 * @param channel the channel 172 * @throws ApiException the api exception 173 */ 174 @Handler 175 @SuppressWarnings("PMD.StringInstantiation") 176 public void onGetDisplaySecrets(GetDisplayPassword event, VmChannel channel) 177 throws ApiException { 178 ListOptions options = new ListOptions(); 179 options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + "," 180 + "app.kubernetes.io/component=" + COMP_DISPLAY_SECRET + "," 181 + "app.kubernetes.io/instance=" 182 + event.vmDefinition().metadata().getName()); 183 var stubs = K8sV1SecretStub.list(client(), 184 event.vmDefinition().metadata().getNamespace(), options); 185 if (stubs.isEmpty()) { 186 return; 187 } 188 var stub = stubs.iterator().next(); 189 190 // Check validity 191 var model = stub.model().get(); 192 @SuppressWarnings("PMD.StringInstantiation") 193 var expiry = Optional.ofNullable(model.getData() 194 .get(DATA_PASSWORD_EXPIRY)).map(b -> new String(b)).orElse(null); 195 if (model.getData().get(DATA_DISPLAY_PASSWORD) != null 196 && stillValid(expiry)) { 197 event.setResult( 198 new String(model.getData().get(DATA_DISPLAY_PASSWORD))); 199 return; 200 } 201 updatePassword(stub, event); 202 } 203 204 @SuppressWarnings("PMD.StringInstantiation") 205 private void updatePassword(K8sV1SecretStub stub, GetDisplayPassword event) 206 throws ApiException { 207 SecureRandom random = null; 208 try { 209 random = SecureRandom.getInstanceStrong(); 210 } catch (NoSuchAlgorithmException e) { // NOPMD 211 // "Every implementation of the Java platform is required 212 // to support at least one strong SecureRandom implementation." 213 } 214 byte[] bytes = new byte[16]; 215 random.nextBytes(bytes); 216 var password = Base64.encode(bytes); 217 var model = stub.model().get(); 218 model.setStringData(Map.of(DATA_DISPLAY_PASSWORD, password, 219 DATA_PASSWORD_EXPIRY, 220 Long.toString(Instant.now().getEpochSecond() + passwordValidity))); 221 event.setResult(password); 222 223 // Prepare wait for confirmation (by VM status change) 224 var pending = new PendingGet(event, 225 event.vmDefinition().displayPasswordSerial().orElse(0L) + 1, 226 new CompletionLock(event, 1500)); 227 pendingGets.add(pending); 228 Event.onCompletion(event, e -> { 229 pendingGets.remove(pending); 230 }); 231 232 // Update, will (eventually) trigger confirmation 233 stub.update(model).getObject(); 234 } 235 236 private boolean stillValid(String expiry) { 237 if (expiry == null || "never".equals(expiry)) { 238 return true; 239 } 240 @SuppressWarnings({ "PMD.CloseResource", "resource" }) 241 var scanner = new Scanner(expiry); 242 if (!scanner.hasNextLong()) { 243 return false; 244 } 245 long expTime = scanner.nextLong(); 246 return expTime > Instant.now().getEpochSecond() + passwordValidity; 247 } 248 249 /** 250 * On vm def changed. 251 * 252 * @param event the event 253 * @param channel the channel 254 */ 255 @Handler 256 @SuppressWarnings("PMD.AvoidSynchronizedStatement") 257 public void onVmDefChanged(VmDefChanged event, Channel channel) { 258 synchronized (pendingGets) { 259 String vmName = event.vmDefinition().metadata().getName(); 260 for (var pending : pendingGets) { 261 if (pending.event.vmDefinition().metadata().getName() 262 .equals(vmName) 263 && event.vmDefinition().displayPasswordSerial() 264 .map(s -> s >= pending.expectedSerial).orElse(false)) { 265 pending.lock.remove(); 266 // pending will be removed from pendingGest by 267 // waiting thread, see updatePassword 268 continue; 269 } 270 } 271 } 272 } 273 274 /** 275 * The Class PendingGet. 276 */ 277 @SuppressWarnings("PMD.DataClass") 278 private static class PendingGet { 279 public final GetDisplayPassword event; 280 public final long expectedSerial; 281 public final CompletionLock lock; 282 283 /** 284 * Instantiates a new pending get. 285 * 286 * @param event the event 287 * @param expectedSerial the expected serial 288 */ 289 public PendingGet(GetDisplayPassword event, long expectedSerial, 290 CompletionLock lock) { 291 super(); 292 this.event = event; 293 this.expectedSerial = expectedSerial; 294 this.lock = lock; 295 } 296 } 297}