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