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