Clarify responsibilities of display secret monitor and reconciler.

This commit is contained in:
Michael Lipp 2025-02-22 21:24:58 +01:00
parent e291352828
commit 81b128e4a3
5 changed files with 252 additions and 223 deletions

View file

@ -1,6 +1,6 @@
/* /*
* VM-Operator * VM-Operator
* Copyright (C) 2024 Michael N. Lipp * Copyright (C) 2025 Michael N. Lipp
* *
* This program is free software: you can redistribute it and/or modify * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as * it under the terms of the GNU Affero General Public License as
@ -18,8 +18,6 @@
package org.jdrupes.vmoperator.manager; package org.jdrupes.vmoperator.manager;
import com.google.gson.JsonObject;
import io.kubernetes.client.apimachinery.GroupVersionKind;
import io.kubernetes.client.custom.V1Patch; import io.kubernetes.client.custom.V1Patch;
import io.kubernetes.client.openapi.ApiException; import io.kubernetes.client.openapi.ApiException;
import io.kubernetes.client.openapi.models.V1Secret; import io.kubernetes.client.openapi.models.V1Secret;
@ -28,52 +26,26 @@ import io.kubernetes.client.util.Watch.Response;
import io.kubernetes.client.util.generic.options.ListOptions; import io.kubernetes.client.util.generic.options.ListOptions;
import io.kubernetes.client.util.generic.options.PatchOptions; import io.kubernetes.client.util.generic.options.PatchOptions;
import java.io.IOException; import java.io.IOException;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.time.Instant;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Scanner;
import java.util.logging.Level; import java.util.logging.Level;
import static org.jdrupes.vmoperator.common.Constants.APP_NAME; import static org.jdrupes.vmoperator.common.Constants.APP_NAME;
import static org.jdrupes.vmoperator.common.Constants.VM_OP_GROUP;
import static org.jdrupes.vmoperator.common.Constants.VM_OP_KIND_VM;
import static org.jdrupes.vmoperator.common.Constants.VM_OP_NAME; import static org.jdrupes.vmoperator.common.Constants.VM_OP_NAME;
import org.jdrupes.vmoperator.common.K8sClient; import org.jdrupes.vmoperator.common.K8sClient;
import org.jdrupes.vmoperator.common.K8sV1PodStub; import org.jdrupes.vmoperator.common.K8sV1PodStub;
import org.jdrupes.vmoperator.common.K8sV1SecretStub; import org.jdrupes.vmoperator.common.K8sV1SecretStub;
import org.jdrupes.vmoperator.common.VmDefinitionStub;
import static org.jdrupes.vmoperator.manager.Constants.COMP_DISPLAY_SECRET; import static org.jdrupes.vmoperator.manager.Constants.COMP_DISPLAY_SECRET;
import static org.jdrupes.vmoperator.manager.Constants.DATA_DISPLAY_PASSWORD;
import static org.jdrupes.vmoperator.manager.Constants.DATA_PASSWORD_EXPIRY;
import org.jdrupes.vmoperator.manager.events.ChannelDictionary; import org.jdrupes.vmoperator.manager.events.ChannelDictionary;
import org.jdrupes.vmoperator.manager.events.PrepareConsole;
import org.jdrupes.vmoperator.manager.events.VmChannel; import org.jdrupes.vmoperator.manager.events.VmChannel;
import org.jdrupes.vmoperator.manager.events.VmDefChanged;
import org.jgrapes.core.Channel; import org.jgrapes.core.Channel;
import org.jgrapes.core.CompletionLock;
import org.jgrapes.core.Event;
import org.jgrapes.core.annotation.Handler;
import org.jgrapes.util.events.ConfigurationUpdate;
import org.jose4j.base64url.Base64;
/** /**
* Watches for changes of display secrets. The component supports the * Watches for changes of display secrets. Updates an artifical attribute
* following configuration properties: * of the pod running the VM in response to force an update of the files
* * in the pod that reflect the information from the secret.
* * `passwordValidity`: the validity of the random password in seconds.
* Used to calculate the password expiry time in the generated secret.
*/ */
@SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", "PMD.TooManyStaticImports" }) @SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", "PMD.TooManyStaticImports" })
public class DisplaySecretMonitor public class DisplaySecretMonitor
extends AbstractMonitor<V1Secret, V1SecretList, VmChannel> { extends AbstractMonitor<V1Secret, V1SecretList, VmChannel> {
private int passwordValidity = 10;
private final List<PendingGet> pendingPrepares
= Collections.synchronizedList(new LinkedList<>());
private final ChannelDictionary<String, VmChannel, ?> channelDictionary; private final ChannelDictionary<String, VmChannel, ?> channelDictionary;
/** /**
@ -93,27 +65,6 @@ public class DisplaySecretMonitor
options(options); options(options);
} }
/**
* On configuration update.
*
* @param event the event
*/
@Handler
@Override
public void onConfigurationUpdate(ConfigurationUpdate event) {
super.onConfigurationUpdate(event);
event.structured(componentPath()).ifPresent(c -> {
try {
if (c.containsKey("passwordValidity")) {
passwordValidity = Integer
.parseInt((String) c.get("passwordValidity"));
}
} catch (ClassCastException e) {
logger.config("Malformed configuration: " + e.getMessage());
}
});
}
@Override @Override
protected void prepareMonitoring() throws IOException, ApiException { protected void prepareMonitoring() throws IOException, ApiException {
client(new K8sClient()); client(new K8sClient());
@ -168,157 +119,4 @@ public class DisplaySecretMonitor
+ "\"}]"), + "\"}]"),
patchOpts); patchOpts);
} }
/**
* On get display secrets.
*
* @param event the event
* @param channel the channel
* @throws ApiException the api exception
*/
@Handler
@SuppressWarnings("PMD.StringInstantiation")
public void onPrepareConsole(PrepareConsole event, VmChannel channel)
throws ApiException {
// Update console user in status
var vmStub = VmDefinitionStub.get(client(),
new GroupVersionKind(VM_OP_GROUP, "", VM_OP_KIND_VM),
event.vmDefinition().namespace(), event.vmDefinition().name());
var optVmDef = vmStub.updateStatus(from -> {
JsonObject status = from.statusJson();
status.addProperty("consoleUser", event.user());
return status;
});
if (optVmDef.isEmpty()) {
return;
}
var vmDef = optVmDef.get();
// Check if access is possible
if (event.loginUser()
? !vmDef.conditionStatus("Booted").orElse(false)
: !vmDef.conditionStatus("Running").orElse(false)) {
return;
}
// Look for secret
ListOptions options = new ListOptions();
options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + ","
+ "app.kubernetes.io/component=" + COMP_DISPLAY_SECRET + ","
+ "app.kubernetes.io/instance=" + vmDef.name());
var stubs = K8sV1SecretStub.list(client(), vmDef.namespace(), options);
if (stubs.isEmpty()) {
// No secret means no password for this VM wanted
event.setResult(null);
return;
}
var stub = stubs.iterator().next();
// Check validity
var secret = stub.model().get();
@SuppressWarnings("PMD.StringInstantiation")
var expiry = Optional.ofNullable(secret.getData()
.get(DATA_PASSWORD_EXPIRY)).map(b -> new String(b)).orElse(null);
if (secret.getData().get(DATA_DISPLAY_PASSWORD) != null
&& stillValid(expiry)) {
// Fixed secret, don't touch
event.setResult(
new String(secret.getData().get(DATA_DISPLAY_PASSWORD)));
return;
}
updatePassword(stub, event);
}
@SuppressWarnings("PMD.StringInstantiation")
private void updatePassword(K8sV1SecretStub stub, PrepareConsole event)
throws ApiException {
SecureRandom random = null;
try {
random = SecureRandom.getInstanceStrong();
} catch (NoSuchAlgorithmException e) { // NOPMD
// "Every implementation of the Java platform is required
// to support at least one strong SecureRandom implementation."
}
byte[] bytes = new byte[16];
random.nextBytes(bytes);
var password = Base64.encode(bytes);
var model = stub.model().get();
model.setStringData(Map.of(DATA_DISPLAY_PASSWORD, password,
DATA_PASSWORD_EXPIRY,
Long.toString(Instant.now().getEpochSecond() + passwordValidity)));
event.setResult(password);
// Prepare wait for confirmation (by VM status change)
var pending = new PendingGet(event,
event.vmDefinition().displayPasswordSerial().orElse(0L) + 1,
new CompletionLock(event, 1500));
pendingPrepares.add(pending);
Event.onCompletion(event, e -> {
pendingPrepares.remove(pending);
});
// Update, will (eventually) trigger confirmation
stub.update(model).getObject();
}
private boolean stillValid(String expiry) {
if (expiry == null || "never".equals(expiry)) {
return true;
}
@SuppressWarnings({ "PMD.CloseResource", "resource" })
var scanner = new Scanner(expiry);
if (!scanner.hasNextLong()) {
return false;
}
long expTime = scanner.nextLong();
return expTime > Instant.now().getEpochSecond() + passwordValidity;
}
/**
* On vm def changed.
*
* @param event the event
* @param channel the channel
*/
@Handler
@SuppressWarnings("PMD.AvoidSynchronizedStatement")
public void onVmDefChanged(VmDefChanged event, Channel channel) {
synchronized (pendingPrepares) {
String vmName = event.vmDefinition().name();
for (var pending : pendingPrepares) {
if (pending.event.vmDefinition().name().equals(vmName)
&& event.vmDefinition().displayPasswordSerial()
.map(s -> s >= pending.expectedSerial).orElse(false)) {
pending.lock.remove();
// pending will be removed from pendingGest by
// waiting thread, see updatePassword
continue;
}
}
}
}
/**
* The Class PendingGet.
*/
@SuppressWarnings("PMD.DataClass")
private static class PendingGet {
public final PrepareConsole event;
public final long expectedSerial;
public final CompletionLock lock;
/**
* Instantiates a new pending get.
*
* @param event the event
* @param expectedSerial the expected serial
*/
public PendingGet(PrepareConsole event, long expectedSerial,
CompletionLock lock) {
super();
this.event = event;
this.expectedSerial = expectedSerial;
this.lock = lock;
}
}
} }

View file

@ -1,6 +1,6 @@
/* /*
* VM-Operator * VM-Operator
* Copyright (C) 2023 Michael N. Lipp * Copyright (C) 2025 Michael N. Lipp
* *
* This program is free software: you can redistribute it and/or modify * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as * it under the terms of the GNU Affero General Public License as
@ -18,7 +18,9 @@
package org.jdrupes.vmoperator.manager; package org.jdrupes.vmoperator.manager;
import com.google.gson.JsonObject;
import freemarker.template.TemplateException; import freemarker.template.TemplateException;
import io.kubernetes.client.apimachinery.GroupVersionKind;
import io.kubernetes.client.openapi.ApiException; import io.kubernetes.client.openapi.ApiException;
import io.kubernetes.client.openapi.models.V1ObjectMeta; import io.kubernetes.client.openapi.models.V1ObjectMeta;
import io.kubernetes.client.openapi.models.V1Secret; import io.kubernetes.client.openapi.models.V1Secret;
@ -26,25 +28,83 @@ import io.kubernetes.client.util.generic.options.ListOptions;
import java.io.IOException; import java.io.IOException;
import java.security.NoSuchAlgorithmException; import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom; import java.security.SecureRandom;
import java.time.Instant;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional;
import java.util.Scanner;
import java.util.logging.Logger; import java.util.logging.Logger;
import static org.jdrupes.vmoperator.common.Constants.APP_NAME;
import static org.jdrupes.vmoperator.common.Constants.VM_OP_GROUP;
import static org.jdrupes.vmoperator.common.Constants.VM_OP_KIND_VM;
import org.jdrupes.vmoperator.common.K8sV1SecretStub; import org.jdrupes.vmoperator.common.K8sV1SecretStub;
import static org.jdrupes.vmoperator.manager.Constants.APP_NAME; import org.jdrupes.vmoperator.common.VmDefinitionStub;
import static org.jdrupes.vmoperator.manager.Constants.COMP_DISPLAY_SECRET; import static org.jdrupes.vmoperator.manager.Constants.COMP_DISPLAY_SECRET;
import static org.jdrupes.vmoperator.manager.Constants.DATA_DISPLAY_PASSWORD; import static org.jdrupes.vmoperator.manager.Constants.DATA_DISPLAY_PASSWORD;
import static org.jdrupes.vmoperator.manager.Constants.DATA_PASSWORD_EXPIRY; import static org.jdrupes.vmoperator.manager.Constants.DATA_PASSWORD_EXPIRY;
import org.jdrupes.vmoperator.manager.events.PrepareConsole;
import org.jdrupes.vmoperator.manager.events.VmChannel; import org.jdrupes.vmoperator.manager.events.VmChannel;
import org.jdrupes.vmoperator.manager.events.VmDefChanged; import org.jdrupes.vmoperator.manager.events.VmDefChanged;
import org.jdrupes.vmoperator.util.DataPath; import org.jdrupes.vmoperator.util.DataPath;
import org.jgrapes.core.Channel;
import org.jgrapes.core.CompletionLock;
import org.jgrapes.core.Component;
import org.jgrapes.core.Event;
import org.jgrapes.core.annotation.Handler;
import org.jgrapes.util.events.ConfigurationUpdate;
import org.jose4j.base64url.Base64; import org.jose4j.base64url.Base64;
/** /**
* Delegee for reconciling the display secret * The properties of the display secret do not only depend on the
* VM definition, but also on events that occur during runtime.
* The reconciler for the display secret is therefore a separate
* component.
*
* The reconciler supports the following configuration properties:
*
* * `passwordValidity`: the validity of the random password in seconds.
* Used to calculate the password expiry time in the generated secret.
*/ */
@SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", "PMD.TooManyStaticImports" }) @SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", "PMD.TooManyStaticImports" })
/* default */ class DisplaySecretReconciler { public class DisplaySecretReconciler extends Component {
protected final Logger logger = Logger.getLogger(getClass().getName()); protected final Logger logger = Logger.getLogger(getClass().getName());
private int passwordValidity = 10;
private final List<PendingGet> pendingPrepares
= Collections.synchronizedList(new LinkedList<>());
/**
* On configuration update.
*
* @param event the event
*/
@Handler
public void onConfigurationUpdate(ConfigurationUpdate event) {
event.structured(componentPath())
// for backward compatibility
.or(() -> {
var oldConfig = event
.structured("/Manager/Controller/DisplaySecretMonitor");
if (oldConfig.isPresent()) {
logger.warning(() -> "Using configuration with old "
+ "path '/Manager/Controller/DisplaySecretMonitor' "
+ "for `passwordValidity`, please update "
+ "the configuration.");
}
return oldConfig;
}).ifPresent(c -> {
try {
if (c.containsKey("passwordValidity")) {
passwordValidity = Integer
.parseInt((String) c.get("passwordValidity"));
}
} catch (ClassCastException e) {
logger.config("Malformed configuration: " + e.getMessage());
}
});
}
/** /**
* Reconcile. If the configuration prevents generating a secret * Reconcile. If the configuration prevents generating a secret
@ -104,4 +164,160 @@ import org.jose4j.base64url.Base64;
K8sV1SecretStub.create(channel.client(), secret); K8sV1SecretStub.create(channel.client(), secret);
} }
/**
* Prepares access to the console for the user from the event.
* Generates a new password and sends it to the runner.
* Requests the VM (via the runner) to login the user if specified
* in the event.
*
* @param event the event
* @param channel the channel
* @throws ApiException the api exception
*/
@Handler
@SuppressWarnings("PMD.StringInstantiation")
public void onPrepareConsole(PrepareConsole event, VmChannel channel)
throws ApiException {
// Update console user in status
var vmStub = VmDefinitionStub.get(channel.client(),
new GroupVersionKind(VM_OP_GROUP, "", VM_OP_KIND_VM),
event.vmDefinition().namespace(), event.vmDefinition().name());
var optVmDef = vmStub.updateStatus(from -> {
JsonObject status = from.statusJson();
status.addProperty("consoleUser", event.user());
return status;
});
if (optVmDef.isEmpty()) {
return;
}
var vmDef = optVmDef.get();
// Check if access is possible
if (event.loginUser()
? !vmDef.conditionStatus("Booted").orElse(false)
: !vmDef.conditionStatus("Running").orElse(false)) {
return;
}
// Look for secret
ListOptions options = new ListOptions();
options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + ","
+ "app.kubernetes.io/component=" + COMP_DISPLAY_SECRET + ","
+ "app.kubernetes.io/instance=" + vmDef.name());
var stubs = K8sV1SecretStub.list(channel.client(), vmDef.namespace(),
options);
if (stubs.isEmpty()) {
// No secret means no password for this VM wanted
event.setResult(null);
return;
}
var stub = stubs.iterator().next();
// Check validity
var secret = stub.model().get();
@SuppressWarnings("PMD.StringInstantiation")
var expiry = Optional.ofNullable(secret.getData()
.get(DATA_PASSWORD_EXPIRY)).map(b -> new String(b)).orElse(null);
if (secret.getData().get(DATA_DISPLAY_PASSWORD) != null
&& stillValid(expiry)) {
// Fixed secret, don't touch
event.setResult(
new String(secret.getData().get(DATA_DISPLAY_PASSWORD)));
return;
}
updatePassword(stub, event);
}
@SuppressWarnings("PMD.StringInstantiation")
private void updatePassword(K8sV1SecretStub stub, PrepareConsole event)
throws ApiException {
SecureRandom random = null;
try {
random = SecureRandom.getInstanceStrong();
} catch (NoSuchAlgorithmException e) { // NOPMD
// "Every implementation of the Java platform is required
// to support at least one strong SecureRandom implementation."
}
byte[] bytes = new byte[16];
random.nextBytes(bytes);
var password = Base64.encode(bytes);
var model = stub.model().get();
model.setStringData(Map.of(DATA_DISPLAY_PASSWORD, password,
DATA_PASSWORD_EXPIRY,
Long.toString(Instant.now().getEpochSecond() + passwordValidity)));
event.setResult(password);
// Prepare wait for confirmation (by VM status change)
var pending = new PendingGet(event,
event.vmDefinition().displayPasswordSerial().orElse(0L) + 1,
new CompletionLock(event, 1500));
pendingPrepares.add(pending);
Event.onCompletion(event, e -> {
pendingPrepares.remove(pending);
});
// Update, will (eventually) trigger confirmation
stub.update(model).getObject();
}
private boolean stillValid(String expiry) {
if (expiry == null || "never".equals(expiry)) {
return true;
}
@SuppressWarnings({ "PMD.CloseResource", "resource" })
var scanner = new Scanner(expiry);
if (!scanner.hasNextLong()) {
return false;
}
long expTime = scanner.nextLong();
return expTime > Instant.now().getEpochSecond() + passwordValidity;
}
/**
* On vm def changed.
*
* @param event the event
* @param channel the channel
*/
@Handler
@SuppressWarnings("PMD.AvoidSynchronizedStatement")
public void onVmDefChanged(VmDefChanged event, Channel channel) {
synchronized (pendingPrepares) {
String vmName = event.vmDefinition().name();
for (var pending : pendingPrepares) {
if (pending.event.vmDefinition().name().equals(vmName)
&& event.vmDefinition().displayPasswordSerial()
.map(s -> s >= pending.expectedSerial).orElse(false)) {
pending.lock.remove();
// pending will be removed from pendingGest by
// waiting thread, see updatePassword
continue;
}
}
}
}
/**
* The Class PendingGet.
*/
@SuppressWarnings("PMD.DataClass")
private static class PendingGet {
public final PrepareConsole event;
public final long expectedSerial;
public final CompletionLock lock;
/**
* Instantiates a new pending get.
*
* @param event the event
* @param expectedSerial the expected serial
*/
public PendingGet(PrepareConsole event, long expectedSerial,
CompletionLock lock) {
super();
this.event = event;
this.expectedSerial = expectedSerial;
this.lock = lock;
}
}
} }

View file

@ -138,6 +138,8 @@ import org.jgrapes.util.events.ConfigurationUpdate;
* properties to be used by the runners managed by the controller. * properties to be used by the runners managed by the controller.
* This property is a string that holds the content of * This property is a string that holds the content of
* a logging.properties file. * a logging.properties file.
*
* @see org.jdrupes.vmoperator.manager.DisplaySecretReconciler
*/ */
@SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", @SuppressWarnings({ "PMD.DataflowAnomalyAnalysis",
"PMD.AvoidDuplicateLiterals" }) "PMD.AvoidDuplicateLiterals" })
@ -163,6 +165,7 @@ public class Reconciler extends Component {
* *
* @param componentChannel the component channel * @param componentChannel the component channel
*/ */
@SuppressWarnings("PMD.ConstructorCallsOverridableMethod")
public Reconciler(Channel componentChannel) { public Reconciler(Channel componentChannel) {
super(componentChannel); super(componentChannel);
@ -177,7 +180,7 @@ public class Reconciler extends Component {
fmConfig.setClassForTemplateLoading(Reconciler.class, ""); fmConfig.setClassForTemplateLoading(Reconciler.class, "");
cmReconciler = new ConfigMapReconciler(fmConfig); cmReconciler = new ConfigMapReconciler(fmConfig);
dsReconciler = new DisplaySecretReconciler(); dsReconciler = attach(new DisplaySecretReconciler());
stsReconciler = new StatefulSetReconciler(fmConfig); stsReconciler = new StatefulSetReconciler(fmConfig);
pvcReconciler = new PvcReconciler(fmConfig); pvcReconciler = new PvcReconciler(fmConfig);
podReconciler = new PodReconciler(fmConfig); podReconciler = new PodReconciler(fmConfig);

View file

@ -7,16 +7,24 @@ layout: vm-operator
## To version 4.0.0 ## To version 4.0.0
The VmViewer conlet has been renamed to VmAccess. This affects the * The VmViewer conlet has been renamed to VmAccess. This affects the
[configuration](https://jdrupes.org/vm-operator/user-gui.html). Configuration information using the old path [configuration](https://jdrupes.org/vm-operator/user-gui.html). Configuration
"/Manager/GuiHttpServer/ConsoleWeblet/WebConsole/ComponentCollector/VmViewer" information using the old path
is still accepted for backward compatibility, but should be updated. `/Manager/GuiHttpServer/ConsoleWeblet/WebConsole/ComponentCollector/VmViewer`
is still accepted for backward compatibility until the next major version,
but should be updated.
The change of name also causes conlets added to the overview page by The change of name also causes conlets added to the overview page by
users to "disappear" from the GUI. They have to be re-added. users to "disappear" from the GUI. They have to be re-added.
The latter behavior also applies to the VmConlet conlet which has been The latter behavior also applies to the VmConlet conlet which has been
renamed to VmMgmt. renamed to VmMgmt.
* The configuration property `passwordValidity` has been moved from component
`/Manager/Controller/DisplaySecretMonitor` to
`/Manager/Controller/Reconciler/DisplaySecretReconciler`. The old path is
still accepted for backward compatibility until the next major version,
but should be updated.
## To version 3.4.0 ## To version 3.4.0

View file

@ -127,16 +127,20 @@ of 16 (strong) random bytes (128 random bits). It is valid for
10 seconds only. This may be challenging on a slower computer 10 seconds only. This may be challenging on a slower computer
or if users may not enable automatic open for connection files or if users may not enable automatic open for connection files
in the browser. The validity can therefore be adjusted in the in the browser. The validity can therefore be adjusted in the
configuration. configuration.[^oldPath]
```yaml ```yaml
"/Manager": "/Manager":
"/Controller": "/Controller":
"/DisplaySecretMonitor": "/Reconciler":
# Validity of generated password in seconds "/DisplaySecretReconciler":
passwordValidity: 10 # Validity of generated password in seconds
passwordValidity: 10
``` ```
[^oldPath]: Before version 4.0, the path for `passwordValidity` was
`/Manager/Controller/DisplaySecretMonitor`.
Taking into account that the controller generates a display Taking into account that the controller generates a display
secret automatically by default, this approach to securing secret automatically by default, this approach to securing
console access should be sufficient in all cases. (Any feedback console access should be sufficient in all cases. (Any feedback