Skip to content

Commit 8952cd5

Browse files
kvm: add hosts using cloudstack ssh private key (ccc21 hackathon) (#5684)
This PR provides the option to add kvm hosts with empty or wrong password. To support this, the cloudstack ssh public key needs to be added in the ~/.ssh/authorized_keys on host. Feature work: 1. get cloudstack public key from management server (/var/lib/cloudstack/management/.ssh/id_rsa.pub) ![image](https://user-images.githubusercontent.com/57355700/141449653-85f644b5-c32e-44ca-9c6b-77570262c046.png) 2. add the key to ~/.ssh/authorized_keys on kvm hosts ![image](https://user-images.githubusercontent.com/57355700/141449722-e906eea5-74fd-4f81-a4d3-41563beeb79c.png) 3. add kvm host with empty password <img src="https://user-images.githubusercontent.com/57355700/141449865-6ffee1f0-b0d7-4ea4-b11a-32df42e2fe91.png" height="50%" width="50%"> Tested as: 1. add host, with correct password, works as expected. 2. put host to maitenance, stop cloudstack-agent, cancel maintenance, remove the host. all work. 3. add host, with empty password (ssh public key is added to ~/.ssh/authorized_keys), works as expected. 4. put host to maitenance, stop cloudstack-agent, cancel maintenance. all work as expected.
1 parent 9798fa0 commit 8952cd5

File tree

6 files changed

+107
-28
lines changed

6 files changed

+107
-28
lines changed

server/src/main/java/com/cloud/hypervisor/kvm/discoverer/LibvirtServerDiscoverer.java

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -259,9 +259,17 @@ private void setupAgentSecurity(final Connection sshConnection, final String age
259259
sshConnection = new Connection(agentIp, 22);
260260

261261
sshConnection.connect(null, 60000, 60000);
262-
if (!sshConnection.authenticateWithPassword(username, password)) {
263-
s_logger.debug("Failed to authenticate");
264-
throw new DiscoveredWithErrorException("Authentication error");
262+
263+
final String privateKey = _configDao.getValue("ssh.privatekey");
264+
if (!SSHCmdHelper.acquireAuthorizedConnectionWithPublicKey(sshConnection, username, privateKey)) {
265+
s_logger.error("Failed to authenticate with ssh key");
266+
if (org.apache.commons.lang3.StringUtils.isEmpty(password)) {
267+
throw new DiscoveredWithErrorException("Authentication error with ssh private key");
268+
}
269+
if (!sshConnection.authenticateWithPassword(username, password)) {
270+
s_logger.error("Failed to authenticate with password");
271+
throw new DiscoveredWithErrorException("Authentication error with host password");
272+
}
265273
}
266274

267275
if (!SSHCmdHelper.sshExecuteCmd(sshConnection, "ls /dev/kvm")) {

server/src/main/java/com/cloud/resource/ResourceManagerImpl.java

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@
164164
import com.cloud.storage.dao.VMTemplateDao;
165165
import com.cloud.user.Account;
166166
import com.cloud.user.AccountManager;
167-
import com.cloud.utils.Pair;
167+
import com.cloud.utils.Ternary;
168168
import com.cloud.utils.StringUtils;
169169
import com.cloud.utils.UriUtils;
170170
import com.cloud.utils.component.Manager;
@@ -200,7 +200,6 @@
200200
import com.cloud.vm.VmDetailConstants;
201201
import com.cloud.vm.dao.UserVmDetailsDao;
202202
import com.cloud.vm.dao.VMInstanceDao;
203-
import com.google.common.base.Strings;
204203
import com.google.gson.Gson;
205204

206205
@Component
@@ -696,9 +695,16 @@ private List<HostVO> discoverHostsFull(final Long dcId, final Long podId, Long c
696695
throw new InvalidParameterValueException("Can't specify cluster without specifying the pod");
697696
}
698697
List<String> skipList = Arrays.asList(HypervisorType.VMware.name().toLowerCase(Locale.ROOT), Type.SecondaryStorage.name().toLowerCase(Locale.ROOT));
699-
if (!skipList.contains(hypervisorType.toLowerCase(Locale.ROOT)) &&
700-
(Strings.isNullOrEmpty(username) || Strings.isNullOrEmpty(password))) {
701-
throw new InvalidParameterValueException("Username and Password need to be provided.");
698+
if (!skipList.contains(hypervisorType.toLowerCase(Locale.ROOT))) {
699+
if (HypervisorType.KVM.toString().equalsIgnoreCase(hypervisorType)) {
700+
if (org.apache.commons.lang3.StringUtils.isBlank(username)) {
701+
throw new InvalidParameterValueException("Username need to be provided.");
702+
}
703+
} else {
704+
if (org.apache.commons.lang3.StringUtils.isBlank(username) || org.apache.commons.lang3.StringUtils.isBlank(password)) {
705+
throw new InvalidParameterValueException("Username and Password need to be provided.");
706+
}
707+
}
702708
}
703709

704710
if (clusterId != null) {
@@ -2732,8 +2738,8 @@ protected void handleAgentIfNotConnected(HostVO host, boolean vmsMigrating) {
27322738
}
27332739
final boolean sshToAgent = Boolean.parseBoolean(_configDao.getValue(KvmSshToAgentEnabled.key()));
27342740
if (sshToAgent) {
2735-
Pair<String, String> credentials = getHostCredentials(host);
2736-
connectAndRestartAgentOnHost(host, credentials.first(), credentials.second());
2741+
Ternary<String, String, String> credentials = getHostCredentials(host);
2742+
connectAndRestartAgentOnHost(host, credentials.first(), credentials.second(), credentials.third());
27372743
} else {
27382744
throw new CloudRuntimeException("SSH access is disabled, cannot cancel maintenance mode as " +
27392745
"host agent is not connected");
@@ -2744,22 +2750,23 @@ protected void handleAgentIfNotConnected(HostVO host, boolean vmsMigrating) {
27442750
* Get host credentials
27452751
* @throws CloudRuntimeException if username or password are not found
27462752
*/
2747-
protected Pair<String, String> getHostCredentials(HostVO host) {
2753+
protected Ternary<String, String, String> getHostCredentials(HostVO host) {
27482754
_hostDao.loadDetails(host);
27492755
final String password = host.getDetail("password");
27502756
final String username = host.getDetail("username");
2751-
if (password == null || username == null) {
2752-
throw new CloudRuntimeException("SSH to agent is enabled, but username/password credentials are not found");
2757+
final String privateKey = _configDao.getValue("ssh.privatekey");
2758+
if ((password == null && privateKey == null) || username == null) {
2759+
throw new CloudRuntimeException("SSH to agent is enabled, but username and password or private key are not found");
27532760
}
2754-
return new Pair<>(username, password);
2761+
return new Ternary<>(username, password, privateKey);
27552762
}
27562763

27572764
/**
27582765
* True if agent is restarted via SSH. Assumes kvm.ssh.to.agent = true and host status is not Up
27592766
*/
2760-
protected void connectAndRestartAgentOnHost(HostVO host, String username, String password) {
2767+
protected void connectAndRestartAgentOnHost(HostVO host, String username, String password, String privateKey) {
27612768
final com.trilead.ssh2.Connection connection = SSHCmdHelper.acquireAuthorizedConnection(
2762-
host.getPrivateIpAddress(), 22, username, password);
2769+
host.getPrivateIpAddress(), 22, username, password, privateKey);
27632770
if (connection == null) {
27642771
throw new CloudRuntimeException(String.format("SSH to agent is enabled, but failed to connect to %s via IP address [%s].", host, host.getPrivateIpAddress()));
27652772
}

server/src/test/java/com/cloud/resource/ResourceManagerImplTest.java

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@
6767
import com.cloud.host.dao.HostDao;
6868
import com.cloud.hypervisor.Hypervisor;
6969
import com.cloud.storage.StorageManager;
70-
import com.cloud.utils.Pair;
70+
import com.cloud.utils.Ternary;
7171
import com.cloud.utils.exception.CloudRuntimeException;
7272
import com.cloud.utils.fsm.NoTransitionException;
7373
import com.cloud.utils.ssh.SSHCmdHelper;
@@ -125,6 +125,7 @@ public class ResourceManagerImplTest {
125125
private static long hostId = 1L;
126126
private static final String hostUsername = "user";
127127
private static final String hostPassword = "password";
128+
private static final String hostPrivateKey = "privatekey";
128129
private static final String hostPrivateIp = "192.168.1.10";
129130

130131
private static long vm1Id = 1L;
@@ -148,6 +149,7 @@ public void setup() throws Exception {
148149
when(hostDao.findById(hostId)).thenReturn(host);
149150
when(host.getDetail("username")).thenReturn(hostUsername);
150151
when(host.getDetail("password")).thenReturn(hostPassword);
152+
when(configurationDao.getValue("ssh.privatekey")).thenReturn(hostPrivateKey);
151153
when(host.getStatus()).thenReturn(Status.Up);
152154
when(host.getPrivateIpAddress()).thenReturn(hostPrivateIp);
153155
when(vm1.getId()).thenReturn(vm1Id);
@@ -171,7 +173,7 @@ public void setup() throws Exception {
171173

172174
PowerMockito.mockStatic(SSHCmdHelper.class);
173175
BDDMockito.given(SSHCmdHelper.acquireAuthorizedConnection(eq(hostPrivateIp), eq(22),
174-
eq(hostUsername), eq(hostPassword))).willReturn(sshConnection);
176+
eq(hostUsername), eq(hostPassword), eq(hostPrivateKey))).willReturn(sshConnection);
175177
BDDMockito.given(SSHCmdHelper.sshExecuteCmdOneShot(eq(sshConnection),
176178
eq("service cloudstack-agent restart"))).
177179
willReturn(new SSHCmdHelper.SSHCmdResult(0,"",""));
@@ -292,50 +294,52 @@ public void testConfigureVncAccessForKVMHostFailedMigrations() {
292294
@Test(expected = CloudRuntimeException.class)
293295
public void testGetHostCredentialsMissingParameter() {
294296
when(host.getDetail("password")).thenReturn(null);
297+
when(configurationDao.getValue("ssh.privatekey")).thenReturn(null);
295298
resourceManager.getHostCredentials(host);
296299
}
297300

298301
@Test
299302
public void testGetHostCredentials() {
300-
Pair<String, String> credentials = resourceManager.getHostCredentials(host);
303+
Ternary<String, String, String> credentials = resourceManager.getHostCredentials(host);
301304
Assert.assertNotNull(credentials);
302305
Assert.assertEquals(hostUsername, credentials.first());
303306
Assert.assertEquals(hostPassword, credentials.second());
307+
Assert.assertEquals(hostPrivateKey, credentials.third());
304308
}
305309

306310
@Test(expected = CloudRuntimeException.class)
307311
public void testConnectAndRestartAgentOnHostCannotConnect() {
308312
BDDMockito.given(SSHCmdHelper.acquireAuthorizedConnection(eq(hostPrivateIp), eq(22),
309-
eq(hostUsername), eq(hostPassword))).willReturn(null);
310-
resourceManager.connectAndRestartAgentOnHost(host, hostUsername, hostPassword);
313+
eq(hostUsername), eq(hostPassword), eq(hostPrivateKey))).willReturn(null);
314+
resourceManager.connectAndRestartAgentOnHost(host, hostUsername, hostPassword, hostPrivateKey);
311315
}
312316

313317
@Test(expected = CloudRuntimeException.class)
314318
public void testConnectAndRestartAgentOnHostCannotRestart() throws Exception {
315319
BDDMockito.given(SSHCmdHelper.sshExecuteCmdOneShot(eq(sshConnection),
316320
eq("service cloudstack-agent restart"))).willThrow(new SshException("exception"));
317-
resourceManager.connectAndRestartAgentOnHost(host, hostUsername, hostPassword);
321+
resourceManager.connectAndRestartAgentOnHost(host, hostUsername, hostPassword, hostPrivateKey);
318322
}
319323

320324
@Test
321325
public void testConnectAndRestartAgentOnHost() {
322-
resourceManager.connectAndRestartAgentOnHost(host, hostUsername, hostPassword);
326+
resourceManager.connectAndRestartAgentOnHost(host, hostUsername, hostPassword, hostPrivateKey);
323327
}
324328

325329
@Test
326330
public void testHandleAgentSSHEnabledNotConnectedAgent() {
327331
when(host.getStatus()).thenReturn(Status.Disconnected);
328332
resourceManager.handleAgentIfNotConnected(host, false);
329333
verify(resourceManager).getHostCredentials(eq(host));
330-
verify(resourceManager).connectAndRestartAgentOnHost(eq(host), eq(hostUsername), eq(hostPassword));
334+
verify(resourceManager).connectAndRestartAgentOnHost(eq(host), eq(hostUsername), eq(hostPassword), eq(hostPrivateKey));
331335
}
332336

333337
@Test
334338
public void testHandleAgentSSHEnabledConnectedAgent() {
335339
when(host.getStatus()).thenReturn(Status.Up);
336340
resourceManager.handleAgentIfNotConnected(host, false);
337341
verify(resourceManager, never()).getHostCredentials(eq(host));
338-
verify(resourceManager, never()).connectAndRestartAgentOnHost(eq(host), eq(hostUsername), eq(hostPassword));
342+
verify(resourceManager, never()).connectAndRestartAgentOnHost(eq(host), eq(hostUsername), eq(hostPassword), eq(hostPrivateKey));
339343
}
340344

341345
@Test(expected = CloudRuntimeException.class)
@@ -351,14 +355,14 @@ public void testHandleAgentSSHDisabledConnectedAgent() {
351355
when(configurationDao.getValue(ResourceManager.KvmSshToAgentEnabled.key())).thenReturn("false");
352356
resourceManager.handleAgentIfNotConnected(host, false);
353357
verify(resourceManager, never()).getHostCredentials(eq(host));
354-
verify(resourceManager, never()).connectAndRestartAgentOnHost(eq(host), eq(hostUsername), eq(hostPassword));
358+
verify(resourceManager, never()).connectAndRestartAgentOnHost(eq(host), eq(hostUsername), eq(hostPassword), eq(hostPrivateKey));
355359
}
356360

357361
@Test
358362
public void testHandleAgentVMsMigrating() {
359363
resourceManager.handleAgentIfNotConnected(host, true);
360364
verify(resourceManager, never()).getHostCredentials(eq(host));
361-
verify(resourceManager, never()).connectAndRestartAgentOnHost(eq(host), eq(hostUsername), eq(hostPassword));
365+
verify(resourceManager, never()).connectAndRestartAgentOnHost(eq(host), eq(hostUsername), eq(hostPassword), eq(hostPrivateKey));
362366
}
363367

364368
private void setupNoPendingMigrationRetries() {

ui/public/locales/en.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -468,6 +468,8 @@
468468
"label.associatednetworkid": "Associated Network ID",
469469
"label.associatednetworkname": "Network Name",
470470
"label.asyncbackup": "Async Backup",
471+
"label.authentication.method": "Authentication Method",
472+
"label.authentication.sshkey": "System SSH Key",
471473
"label.author.email": "Author e-mail",
472474
"label.author.name": "Author name",
473475
"label.auto.assign.diskoffering.disk.size": "Automatically assign offering matching the disk size",
@@ -2588,6 +2590,7 @@
25882590
"message.add.firewall.rule.processing": "Adding new Firewall rule...",
25892591
"message.add.guest.network": "Please confirm that you would like to add a guest network",
25902592
"message.add.host": "Please specify the following parameters to add a new host",
2593+
"message.add.host.sshkey": "WARNING: In order to add a host with SSH key, you must ensure your hypervisor host has been configured correctly.",
25912594
"message.add.ip.range": "Add an IP range to public network in zone",
25922595
"message.add.ip.range.direct.network": "Add an IP range to direct network <b><span id=\"directnetwork_name\"></span></b> in zone <b><span id=\"zone_name\"></span></b>",
25932596
"message.add.ip.range.to.pod": "<p>Add an IP range to pod: <b><span id=\"pod_name_label\"></span></b></p>",

ui/src/views/infra/HostAdd.vue

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,30 @@
9494
<a-input :placeholder="placeholder.username" v-model="username"></a-input>
9595
</div>
9696

97-
<div class="form__item required-field" v-if="selectedClusterHyperVisorType !== 'VMware'">
97+
<div class="form__item" v-if="selectedClusterHyperVisorType !== 'VMware'">
98+
<div class="form__label"><span class="required">* </span>{{ $t('label.authentication.method') }}</div>
99+
<a-radio-group
100+
v-decorator="['authmethod', {
101+
initialValue: authMethod
102+
}]"
103+
buttonStyle="solid"
104+
:defaultValue="authMethod"
105+
@change="selected => { handleAuthMethodChange(selected.target.value) }">
106+
<a-radio-button value="password">
107+
{{ $t('label.password') }}
108+
</a-radio-button>
109+
<a-radio-button value="sshkey" v-if="selectedClusterHyperVisorType === 'KVM'">
110+
{{ $t('label.authentication.sshkey') }}
111+
</a-radio-button>
112+
</a-radio-group>
113+
<span v-if="authMethod === 'sshkey'">
114+
<a-alert type="warning">
115+
<span style="display:block;width:300px;word-wrap:break-word;" slot="message" v-html="$t('message.add.host.sshkey')" />
116+
</a-alert>
117+
</span>
118+
</div>
119+
120+
<div class="form__item required-field" v-if="selectedClusterHyperVisorType !== 'VMware' && authMethod === 'password'">
98121
<div class="form__label"><span class="required">* </span>{{ $t('label.password') }}</div>
99122
<span class="required required-label">{{ $t('label.required') }}</span>
100123
<a-input :placeholder="placeholder.password" type="password" v-model="password"></a-input>
@@ -190,6 +213,7 @@ export default {
190213
agentusername: null,
191214
agentpassword: null,
192215
agentport: null,
216+
authMethod: 'password',
193217
selectedCluster: null,
194218
selectedClusterHyperVisorType: null,
195219
showDedicated: false,
@@ -280,6 +304,9 @@ export default {
280304
this.dedicatedAccount = null
281305
this.showDedicated = !this.showDedicated
282306
},
307+
handleAuthMethodChange (val) {
308+
this.authMethod = val
309+
},
283310
handleSubmitForm () {
284311
if (this.loading) return
285312
const requiredFields = document.querySelectorAll('.required-field')
@@ -306,6 +333,10 @@ export default {
306333
this.url = this.hostname
307334
}
308335
336+
if (this.authMethod !== 'password') {
337+
this.password = ''
338+
}
339+
309340
const args = {
310341
zoneid: this.zoneId,
311342
podid: this.podId,

utils/src/main/java/com/cloud/utils/ssh/SSHCmdHelper.java

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import java.io.InputStream;
2424

2525
import org.apache.cloudstack.utils.security.KeyStoreUtils;
26+
import org.apache.commons.lang3.StringUtils;
2627
import org.apache.log4j.Logger;
2728

2829
import com.google.common.base.Strings;
@@ -77,8 +78,33 @@ public static com.trilead.ssh2.Connection acquireAuthorizedConnection(String ip,
7778
}
7879

7980
public static com.trilead.ssh2.Connection acquireAuthorizedConnection(String ip, int port, String username, String password) {
81+
return acquireAuthorizedConnection(ip, 22, username, password, null);
82+
}
83+
84+
public static boolean acquireAuthorizedConnectionWithPublicKey(final com.trilead.ssh2.Connection sshConnection, final String username, final String privateKey) {
85+
if (StringUtils.isNotBlank(privateKey)) {
86+
try {
87+
if (!sshConnection.authenticateWithPublicKey(username, privateKey.toCharArray(), null)) {
88+
s_logger.warn("Failed to authenticate with ssh key");
89+
return false;
90+
}
91+
return true;
92+
} catch (IOException e) {
93+
s_logger.warn("An exception occurred when authenticate with ssh key");
94+
return false;
95+
}
96+
}
97+
return false;
98+
}
99+
100+
public static com.trilead.ssh2.Connection acquireAuthorizedConnection(String ip, int port, String username, String password, String privateKey) {
80101
com.trilead.ssh2.Connection sshConnection = new com.trilead.ssh2.Connection(ip, port);
81102
try {
103+
sshConnection.connect(null, DEFAULT_CONNECT_TIMEOUT, DEFAULT_KEX_TIMEOUT);
104+
if (acquireAuthorizedConnectionWithPublicKey(sshConnection, username, privateKey)) {
105+
return sshConnection;
106+
};
107+
sshConnection = new com.trilead.ssh2.Connection(ip, port);
82108
sshConnection.connect(null, DEFAULT_CONNECT_TIMEOUT, DEFAULT_KEX_TIMEOUT);
83109
if (!sshConnection.authenticateWithPassword(username, password)) {
84110
String[] methods = sshConnection.getRemainingAuthMethods(username);

0 commit comments

Comments
 (0)