Skip to content

Commit

Permalink
Authorized SSH keys for VM's based on cloud image
Browse files Browse the repository at this point in the history
  • Loading branch information
skobyda committed Oct 4, 2023
1 parent 32c1417 commit 9976dff
Show file tree
Hide file tree
Showing 4 changed files with 170 additions and 9 deletions.
100 changes: 92 additions & 8 deletions src/components/create-vm-dialog/createVmDialog.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { Divider } from "@patternfly/react-core/dist/esm/components/Divider";
import { Flex, FlexItem } from "@patternfly/react-core/dist/esm/layouts/Flex";
import { Form, FormGroup } from "@patternfly/react-core/dist/esm/components/Form";
import { FormSelect, FormSelectOption } from "@patternfly/react-core/dist/esm/components/FormSelect";
import { Grid, GridItem } from "@patternfly/react-core/dist/esm/layouts/Grid";
import { InputGroup } from "@patternfly/react-core/dist/esm/components/InputGroup";
import { Modal } from "@patternfly/react-core/dist/esm/components/Modal";
import { Select as PFSelect, SelectGroup, SelectOption } from "@patternfly/react-core/dist/esm/deprecated/components/Select";
Expand All @@ -33,7 +34,7 @@ import { Button } from "@patternfly/react-core/dist/esm/components/Button";
import { Tooltip } from "@patternfly/react-core/dist/esm/components/Tooltip";
import { TextArea } from "@patternfly/react-core/dist/esm/components/TextArea";
import { Spinner } from "@patternfly/react-core/dist/esm/components/Spinner";
import { ExternalLinkAltIcon } from '@patternfly/react-icons';
import { ExternalLinkAltIcon, TrashIcon } from '@patternfly/react-icons';

import { DialogsContext } from 'dialogs.jsx';
import cockpit from 'cockpit';
Expand Down Expand Up @@ -84,6 +85,7 @@ import { domainCreate } from '../../libvirtApi/domain.js';
import { storagePoolRefresh } from '../../libvirtApi/storagePool.js';
import { getAccessToken } from '../../libvirtApi/rhel-images.js';
import { PasswordFormFields, password_quality } from 'cockpit-components-password.jsx';
import { DynamicListForm } from 'DynamicListForm.jsx';

import './createVmDialog.scss';

Expand Down Expand Up @@ -248,6 +250,8 @@ function validateParams(vmParams) {
}
if (vmParams.userPassword && !vmParams.userLogin) {
validationFailed.userLogin = _("User login must not be empty when user password is set");
} else if (vmParams.sshKeys.length > 0 && !vmParams.userLogin) {
validationFailed.userLogin = _("User login must not be empty when SSH keys are set");
}

return validationFailed;
Expand Down Expand Up @@ -748,20 +752,98 @@ const UsersConfigurationRow = ({
);
};

// This method needs to be outside of component as re-render would create a new instance of debounce
const parseKey = debounce(500, (key, setKeyObject) => {
if (isEmpty(key))
return;

// Validate correctness of the key
return cockpit.spawn(["ssh-keygen", "-l", "-f", "-"], { error: "message" })
.input(key)
.then(() => {
const parts = key.split(" ");
if (parts.length > 2) {
setKeyObject({
type: parts[0],
data: parts[1],
comment: parts[2], // comment is optional in SSH-format
});
}
})
.catch(() => {
setKeyObject(undefined);
console.warn("Could not validate the public key");
});
});

const SshKeysRow = ({
id, item, onChange, idx, removeitem,
}) => {
const [keyObject, setKeyObject] = useState();

const onChangeHelper = (value) => {
// Some users might want to input multiple keys into the same field
// While handling that in the future might be a nice user experience, now we only parse one key out of input
value = value.split(/\r?\n/)[0];

onChange(idx, "value", value);
parseKey(value, setKeyObject);
};

return (
<Grid id={id} key={id}>
<GridItem span={11}>
{keyObject
? <FlexItem id="validated">
<strong>{keyObject.comment}</strong>
<span>{keyObject.comment ? " - " + keyObject.type : keyObject.type}</span>
<div>{keyObject.data}</div>
</FlexItem>
: <FormGroup label={_("Public key")}
fieldId='public-key'>
<TextArea value={item.value || ""}
aria-label={_("Public SSH key")}
onChange={(_, value) => onChangeHelper(value)}
rows="3" />
<FormHelper helperText={_("Keys are located in ~/.ssh/ and have a \".pub\" extension.")} />
</FormGroup>
}
</GridItem>
<GridItem span={1} className="pf-m-1-col-on-md remove-button-group">
<Button variant='plain'
className="btn-close"
id={id + "-btn-close"}
isSmall
aria-label={_("Remove item")}
icon={<TrashIcon />}
onClick={() => removeitem(idx)} />
</GridItem>
</Grid>
);
};

const CloudInitOptionsRow = ({
onValueChanged,
rootPassword,
userLogin, userPassword,
validationFailed,
}) => {
return (
<UsersConfigurationRow rootPassword={rootPassword}
rootPasswordLabelInfo={_("Leave the password blank if you do not wish to set a root password")}
showUserFields
userLogin={userLogin}
userPassword={userPassword}
validationFailed={validationFailed}
onValueChanged={onValueChanged} />
<>
<UsersConfigurationRow rootPassword={rootPassword}
rootPasswordLabelInfo={_("Leave the password blank if you do not wish to set a root password")}
showUserFields
userLogin={userLogin}
userPassword={userPassword}
validationFailed={validationFailed}
onValueChanged={onValueChanged} />
<DynamicListForm id="create-vm-dialog-ssh-key"
emptyStateString={_("No SSH keys specified")}
label={_("SSH keys")}
actionLabel={_("Add SSH keys")}
onChange={value => onValueChanged('sshKeys', value)}
itemcomponent={ <SshKeysRow />} />
</>
);
};

Expand Down Expand Up @@ -979,6 +1061,7 @@ class CreateVmModal extends React.Component {
userLogin: '',
accessToken: '',
offlineToken: '',
sshKeys: [],
};
this.onCreateClicked = this.onCreateClicked.bind(this);
this.onValueChanged = this.onValueChanged.bind(this);
Expand Down Expand Up @@ -1172,6 +1255,7 @@ class CreateVmModal extends React.Component {
userPassword: this.state.userPassword,
rootPassword: this.state.rootPassword,
userLogin: this.state.userLogin,
sshKeys: this.state.sshKeys.map(key => key.value),
startVm,
accessToken: this.state.accessToken,
loggedUser
Expand Down
4 changes: 3 additions & 1 deletion src/libvirtApi/domain.js
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,8 @@ export function domainCreate({
userPassword,
vmName,
accessToken,
loggedUser
loggedUser,
sshKeys,
}) {
// shows dummy vm until we get vm from virsh (cleans up inProgress)
setVmCreateInProgress(vmName, connectionName, { openConsoleTab: startVm });
Expand Down Expand Up @@ -297,6 +298,7 @@ export function domainCreate({
userLogin,
userPassword,
vmName,
sshKeys,
};

logDebug(`CREATE_VM(${vmName}): install_machine.py '${args}'`);
Expand Down
4 changes: 4 additions & 0 deletions src/scripts/install_machine.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,10 @@ def prepare_cloud_init(args):
if args['userLogin']:
user_data_file.write("users:\n")
user_data_file.write(f" - name: {args['userLogin']}\n")
if 'sshKeys' in args and len(args['sshKeys']) > 0:
user_data_file.write(" ssh_authorized_keys:\n")
for key in args['sshKeys']:
user_data_file.write(f" - {key}\n")

if args['rootPassword'] or args['userPassword']:
# enable SSH password login if any password is set
Expand Down
71 changes: 71 additions & 0 deletions test/check-machines-create
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,19 @@ class TestMachinesCreate(VirtualMachinesCase):
# https://bugzilla.redhat.com/show_bug.cgi?id=2109986
runner.checkPasswordsAreNotResetTest(TestMachinesCreate.VmDialog(self))

# Check user login has to be filled when SSH keys are provided
self.machine.execute("ssh-keygen -t rsa -N '' -f /tmp/rsakey")
rsakey = self.machine.execute("cat /tmp/rsakey.pub").strip()
self.addCleanup(self.machine.execute, "rm -f /tmp/rsakey*")
runner.checkDialogFormValidationTest(TestMachinesCreate.VmDialog(self, sourceType='cloud',
storage_size=10, storage_size_unit='MiB',
location=config.VALID_DISK_IMAGE_PATH,
root_password="foobar",
user_login="",
ssh_keys=[rsakey],
create_and_run=True),
{"create-vm-dialog-user-login": "User login must not be empty when SSH keys are set"})

def testCreateNameGeneration(self):
config = TestMachinesCreate.TestCreateConfig
runner = TestMachinesCreate.CreateVmRunner(self)
Expand Down Expand Up @@ -275,6 +288,49 @@ class TestMachinesCreate(VirtualMachinesCase):
os_name=config.FEDORA_28,
os_short_id=config.FEDORA_28_SHORTID,
create_and_run=True))
self.machine.execute("ssh-keygen -t rsa -N '' -f /tmp/rsakey")
rsakey = self.machine.execute("cat /tmp/rsakey.pub").strip()
self.addCleanup(self.machine.execute, "rm -f /tmp/rsakey*")
self.machine.execute("ssh-keygen -t dsa -N '' -C '' -f /tmp/dsakey") # public key with empty comment
dsakey = self.machine.execute("cat /tmp/dsakey.pub")
self.addCleanup(self.machine.execute, "rm -f /tmp/dsakey*")

# Try to create VM with one SSH key
runner.createCloudBaseImageTest(TestMachinesCreate.VmDialog(self, sourceType='cloud',
storage_size=10, storage_size_unit='MiB',
location=config.VALID_DISK_IMAGE_PATH,
os_name=config.FEDORA_28,
os_short_id=config.FEDORA_28_SHORTID,
user_password="catsaremybestfr13nds",
user_login="foo",
root_password="dogsaremybestfr13nds",
ssh_keys=[rsakey],
create_and_run=True))

# try to create VM with multiple SSH keys
runner.createCloudBaseImageTest(TestMachinesCreate.VmDialog(self, sourceType='cloud',
storage_size=10, storage_size_unit='MiB',
location=config.VALID_DISK_IMAGE_PATH,
os_name=config.FEDORA_28,
os_short_id=config.FEDORA_28_SHORTID,
user_password="catsaremybestfr13nds",
user_login="foo",
root_password="dogsaremybestfr13nds",
ssh_keys=[rsakey, dsakey],
create_and_run=True))

# try tp input multiple keys (separated by newline) into one text input, expect only first one to be used
runner.createCloudBaseImageTest(TestMachinesCreate.VmDialog(self, sourceType='cloud',
storage_size=10, storage_size_unit='MiB',
location=config.VALID_DISK_IMAGE_PATH,
os_name=config.FEDORA_28,
os_short_id=config.FEDORA_28_SHORTID,
user_password="catsaremybestfr13nds",
user_login="foo",
root_password="dogsaremybestfr13nds",
ssh_keys=[f"{rsakey} \n {dsakey}"],
expected_ssh_keys=[rsakey],
create_and_run=True))

def testCreateDownloadAnOS(self):
runner = TestMachinesCreate.CreateVmRunner(self)
Expand Down Expand Up @@ -802,6 +858,8 @@ vnc_password= "{vnc_passwd}"
root_password=None,
user_password=None,
user_login=None,
ssh_keys=None,
expected_ssh_keys=None,
storage_pool=NEW_VOLUME_QCOW2, storage_volume='',
create_and_run=False,
delete=True,
Expand Down Expand Up @@ -847,6 +905,8 @@ vnc_password= "{vnc_passwd}"
self.root_password = root_password
self.user_password = user_password
self.user_login = user_login
self.ssh_keys = ssh_keys
self.expected_ssh_keys = expected_ssh_keys
self.create_and_run = create_and_run or is_unattended
self.storage_pool = storage_pool
self.storage_volume = storage_volume
Expand Down Expand Up @@ -1072,6 +1132,11 @@ vnc_password= "{vnc_passwd}"

self.assertIn("\nssh_pwauth: true", user_data)

ssh_keys = self.expected_ssh_keys or self.ssh_keys
if ssh_keys is not None:
for key in ssh_keys:
self.assertIn(key, user_data)

# --unattended option is conflicting with --cloud-init option, resulting --cloud-init user_data being ignored
# https://bugzilla.redhat.com/show_bug.cgi?id=2096200#c14
self.assertNotIn("--unattended", virt_install_cmd_out)
Expand Down Expand Up @@ -1233,6 +1298,12 @@ vnc_password= "{vnc_passwd}"
b.set_input_text("#user-login", self.user_login)
if self.root_password:
b.set_input_text("#create-vm-dialog-root-password-pw1", self.root_password)
if self.ssh_keys is not None:
for idx, key in enumerate(self.ssh_keys):
b.click("button:contains(Add SSH keys)")
b.set_input_text(f"#create-vm-dialog-ssh-key-{idx} textarea", key, value_check=False, blur=False)
# Check that ssh key was validated
b.wait_visible(f"#create-vm-dialog-ssh-key-{idx} #validated")

if self.is_unattended:
b.wait_visible("#create-and-edit[aria-disabled=true]")
Expand Down

0 comments on commit 9976dff

Please sign in to comment.