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}