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}