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}