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