Add password-bootstrap SSH setup for new connections

This commit is contained in:
liumangmang
2026-04-21 16:32:46 +08:00
parent 05b835eb02
commit 42836aa4c3
9 changed files with 862 additions and 215 deletions
@@ -5,6 +5,11 @@ import lombok.Data;
@Data
public class ConnectionCreateRequest {
public enum SetupMode {
NONE,
PASSWORD_BOOTSTRAP
}
private String name;
private String host;
private Integer port = 22;
@@ -13,4 +18,6 @@ public class ConnectionCreateRequest {
private String password;
private String privateKey;
private String passphrase;
private SetupMode setupMode = SetupMode.NONE;
private String bootstrapPassword;
}
@@ -3,6 +3,9 @@ package com.sshmanager.service;
import com.sshmanager.dto.ConnectionCreateRequest;
import com.sshmanager.dto.ConnectionDto;
import com.sshmanager.entity.Connection;
import com.sshmanager.exception.AccessDeniedException;
import com.sshmanager.exception.InvalidOperationException;
import com.sshmanager.exception.NotFoundException;
import com.sshmanager.repository.ConnectionRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -17,13 +20,16 @@ public class ConnectionService {
private final ConnectionRepository connectionRepository;
private final EncryptionService encryptionService;
private final SshService sshService;
private final SshBootstrapService sshBootstrapService;
public ConnectionService(ConnectionRepository connectionRepository,
EncryptionService encryptionService,
SshService sshService) {
SshService sshService,
SshBootstrapService sshBootstrapService) {
this.connectionRepository = connectionRepository;
this.encryptionService = encryptionService;
this.sshService = sshService;
this.sshBootstrapService = sshBootstrapService;
}
public List<ConnectionDto> listByUserId(Long userId) {
@@ -34,31 +40,33 @@ public class ConnectionService {
public ConnectionDto getById(Long id, Long userId) {
Connection conn = connectionRepository.findById(id).orElseThrow(
() -> new RuntimeException("Connection not found: " + id));
() -> new NotFoundException("Connection not found: " + id));
if (!conn.getUserId().equals(userId)) {
throw new RuntimeException("Access denied");
throw new AccessDeniedException("Access denied");
}
return ConnectionDto.fromEntity(conn);
}
@Transactional
public ConnectionDto create(ConnectionCreateRequest request, Long userId) {
validateCreateRequest(request);
Connection conn = new Connection();
conn.setUserId(userId);
conn.setName(request.getName());
conn.setHost(request.getHost());
conn.setName(trimToNull(request.getName()));
conn.setHost(trimToNull(request.getHost()));
conn.setPort(request.getPort() != null ? request.getPort() : 22);
conn.setUsername(request.getUsername());
conn.setAuthType(request.getAuthType() != null ? request.getAuthType() : Connection.AuthType.PASSWORD);
conn.setUsername(trimToNull(request.getUsername()));
if (conn.getAuthType() == Connection.AuthType.PASSWORD) {
conn.setEncryptedPassword(encryptionService.encrypt(request.getPassword()));
conn.setEncryptedPrivateKey(null);
if (getSetupMode(request) == ConnectionCreateRequest.SetupMode.PASSWORD_BOOTSTRAP) {
SshBootstrapService.BootstrapResult bootstrapResult = sshBootstrapService.bootstrapWithPassword(request, userId);
conn.setAuthType(Connection.AuthType.PRIVATE_KEY);
conn.setEncryptedPassword(null);
conn.setEncryptedPrivateKey(encryptionService.encrypt(bootstrapResult.getPrivateKey()));
conn.setPassphrase(null);
} else {
conn.setEncryptedPassword(null);
conn.setEncryptedPrivateKey(encryptionService.encrypt(request.getPrivateKey()));
conn.setPassphrase(encryptionService.encrypt(request.getPassphrase()));
conn.setAuthType(resolveAuthType(request));
applyCredentialUpdate(conn, request);
}
conn = connectionRepository.save(conn);
@@ -68,32 +76,26 @@ public class ConnectionService {
@Transactional
public ConnectionDto update(Long id, ConnectionCreateRequest request, Long userId) {
Connection conn = connectionRepository.findById(id).orElseThrow(
() -> new RuntimeException("Connection not found: " + id));
() -> new NotFoundException("Connection not found: " + id));
if (!conn.getUserId().equals(userId)) {
throw new RuntimeException("Access denied");
throw new AccessDeniedException("Access denied");
}
if (getSetupMode(request) == ConnectionCreateRequest.SetupMode.PASSWORD_BOOTSTRAP) {
throw new InvalidOperationException("编辑连接时不支持一键免密配置");
}
if (request.getName() != null) conn.setName(request.getName());
if (request.getHost() != null) conn.setHost(request.getHost());
if (request.getPort() != null) conn.setPort(request.getPort());
if (request.getUsername() != null) conn.setUsername(request.getUsername());
if (request.getName() != null) conn.setName(trimToNull(request.getName()));
if (request.getHost() != null) conn.setHost(trimToNull(request.getHost()));
if (request.getPort() != null) {
validatePort(request.getPort());
conn.setPort(request.getPort());
}
if (request.getUsername() != null) conn.setUsername(trimToNull(request.getUsername()));
if (request.getAuthType() != null) conn.setAuthType(request.getAuthType());
if (conn.getAuthType() == Connection.AuthType.PASSWORD) {
if (request.getPassword() != null) {
conn.setEncryptedPassword(encryptionService.encrypt(request.getPassword()));
}
conn.setEncryptedPrivateKey(null);
conn.setPassphrase(null);
} else {
if (request.getPrivateKey() != null) {
conn.setEncryptedPrivateKey(encryptionService.encrypt(request.getPrivateKey()));
}
if (request.getPassphrase() != null) {
conn.setPassphrase(encryptionService.encrypt(request.getPassphrase()));
}
conn.setEncryptedPassword(null);
}
validatePersistedFields(conn);
applyCredentialUpdate(conn, request);
validateStoredCredentials(conn);
conn.setUpdatedAt(Instant.now());
conn = connectionRepository.save(conn);
@@ -107,16 +109,16 @@ public class ConnectionService {
return;
}
if (!conn.getUserId().equals(userId)) {
throw new RuntimeException("Access denied");
throw new AccessDeniedException("Access denied");
}
connectionRepository.delete(conn);
}
public Connection getConnectionForSsh(Long id, Long userId) {
Connection conn = connectionRepository.findById(id).orElseThrow(
() -> new RuntimeException("Connection not found: " + id));
() -> new NotFoundException("Connection not found: " + id));
if (!conn.getUserId().equals(userId)) {
throw new RuntimeException("Access denied");
throw new AccessDeniedException("Access denied");
}
return conn;
}
@@ -149,4 +151,110 @@ public class ConnectionService {
}
}
}
private void validateCreateRequest(ConnectionCreateRequest request) {
if (request == null) {
throw new InvalidOperationException("连接信息不能为空");
}
validatePersistedFields(request);
if (getSetupMode(request) == ConnectionCreateRequest.SetupMode.PASSWORD_BOOTSTRAP) {
requireText(request.getBootstrapPassword(), "启用一键免密配置时必须填写初始登录密码");
return;
}
Connection.AuthType authType = resolveAuthType(request);
if (authType == Connection.AuthType.PASSWORD) {
if (!hasText(request.getPassword())) {
throw new InvalidOperationException("请填写密码");
}
return;
}
if (!hasText(request.getPrivateKey())) {
throw new InvalidOperationException("请填写私钥");
}
}
private void validatePersistedFields(ConnectionCreateRequest request) {
requireText(request.getName(), "请填写名称");
requireText(request.getHost(), "请填写主机");
requireText(request.getUsername(), "请填写用户名");
validatePort(request.getPort() != null ? request.getPort() : 22);
}
private void validatePersistedFields(Connection conn) {
requireText(conn.getName(), "请填写名称");
requireText(conn.getHost(), "请填写主机");
requireText(conn.getUsername(), "请填写用户名");
validatePort(conn.getPort() != null ? conn.getPort() : 22);
}
private void applyCredentialUpdate(Connection conn, ConnectionCreateRequest request) {
if (conn.getAuthType() == Connection.AuthType.PASSWORD) {
String password = trimToNull(request.getPassword());
if (password != null) {
conn.setEncryptedPassword(encryptionService.encrypt(password));
}
conn.setEncryptedPrivateKey(null);
conn.setPassphrase(null);
return;
}
String privateKey = trimToNull(request.getPrivateKey());
if (privateKey != null) {
conn.setEncryptedPrivateKey(encryptionService.encrypt(privateKey));
}
if (request.getPassphrase() != null) {
conn.setPassphrase(encryptionService.encrypt(trimToNull(request.getPassphrase())));
}
conn.setEncryptedPassword(null);
}
private void validateStoredCredentials(Connection conn) {
if (conn.getAuthType() == Connection.AuthType.PASSWORD) {
if (!hasText(conn.getEncryptedPassword())) {
throw new InvalidOperationException("请填写密码");
}
return;
}
if (!hasText(conn.getEncryptedPrivateKey())) {
throw new InvalidOperationException("请填写私钥");
}
}
private Connection.AuthType resolveAuthType(ConnectionCreateRequest request) {
return request.getAuthType() != null ? request.getAuthType() : Connection.AuthType.PASSWORD;
}
private ConnectionCreateRequest.SetupMode getSetupMode(ConnectionCreateRequest request) {
if (request == null || request.getSetupMode() == null) {
return ConnectionCreateRequest.SetupMode.NONE;
}
return request.getSetupMode();
}
private void requireText(String value, String message) {
if (!hasText(value)) {
throw new InvalidOperationException(message);
}
}
private void validatePort(Integer port) {
if (port == null || port < 1 || port > 65535) {
throw new InvalidOperationException("端口号必须在1-65535之间");
}
}
private boolean hasText(String value) {
return value != null && !value.trim().isEmpty();
}
private String trimToNull(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
}
@@ -0,0 +1,221 @@
package com.sshmanager.service;
import com.jcraft.jsch.JSch;
import com.jcraft.jsch.KeyPair;
import com.sshmanager.dto.ConnectionCreateRequest;
import com.sshmanager.entity.Connection;
import com.sshmanager.exception.InvalidOperationException;
import org.springframework.stereotype.Service;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
@Service
public class SshBootstrapService {
private final SshService sshService;
public SshBootstrapService(SshService sshService) {
this.sshService = sshService;
}
public BootstrapResult bootstrapWithPassword(ConnectionCreateRequest request, Long userId) {
String bootstrapPassword = trimToNull(request.getBootstrapPassword());
if (bootstrapPassword == null) {
throw new InvalidOperationException("启用一键免密配置时必须填写初始登录密码");
}
GeneratedKeyPair keyPair = generateKeyPair(buildKeyComment(userId, request.getName()));
Connection passwordConnection = buildConnection(request, Connection.AuthType.PASSWORD);
Connection privateKeyConnection = buildConnection(request, Connection.AuthType.PRIVATE_KEY);
authorizePublicKey(passwordConnection, bootstrapPassword, keyPair.getPublicKey());
verifyPrivateKeyLogin(privateKeyConnection, keyPair.getPrivateKey());
return new BootstrapResult(keyPair.getPrivateKey());
}
private GeneratedKeyPair generateKeyPair(String comment) {
KeyPair keyPair = null;
try {
JSch jsch = new JSch();
keyPair = KeyPair.genKeyPair(jsch, KeyPair.RSA, 2048);
ByteArrayOutputStream privateKeyOutput = new ByteArrayOutputStream();
ByteArrayOutputStream publicKeyOutput = new ByteArrayOutputStream();
keyPair.writePrivateKey(privateKeyOutput);
keyPair.writePublicKey(publicKeyOutput, comment);
return new GeneratedKeyPair(
privateKeyOutput.toString(StandardCharsets.UTF_8.name()),
publicKeyOutput.toString(StandardCharsets.UTF_8.name()).trim()
);
} catch (Exception e) {
throw new InvalidOperationException("免密初始化失败:无法生成 SSH 密钥");
} finally {
if (keyPair != null) {
keyPair.dispose();
}
}
}
private void authorizePublicKey(Connection connection, String bootstrapPassword, String publicKey) {
String command = buildAuthorizeCommand(publicKey);
try {
SshService.CommandResult result = sshService.executeCommandWithResult(
connection,
bootstrapPassword,
null,
null,
command
);
if (result.getExitStatus() != 0) {
throw new InvalidOperationException(buildRemoteFailureMessage(
"免密初始化失败:无法写入远端 authorized_keys",
result
));
}
} catch (InvalidOperationException e) {
throw e;
} catch (Exception e) {
throw new InvalidOperationException("免密初始化失败:密码登录或公钥下发失败" + formatCauseMessage(e));
}
}
private void verifyPrivateKeyLogin(Connection connection, String privateKey) {
try {
SshService.CommandResult result = sshService.executeCommandWithResult(
connection,
null,
privateKey,
null,
"printf 'ssh-manager bootstrap ok'"
);
if (result.getExitStatus() != 0) {
throw new InvalidOperationException(buildRemoteFailureMessage(
"免密初始化失败:公钥已下发,但私钥验证失败",
result
));
}
} catch (InvalidOperationException e) {
throw e;
} catch (Exception e) {
throw new InvalidOperationException("免密初始化失败:公钥已下发,但私钥验证失败" + formatCauseMessage(e));
}
}
private String buildAuthorizeCommand(String publicKey) {
String escapedPublicKey = shellQuote(publicKey);
String innerCommand =
"umask 077 && " +
"mkdir -p ~/.ssh && chmod 700 ~/.ssh && " +
"touch ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys && " +
"{ grep -Fqx " + escapedPublicKey + " ~/.ssh/authorized_keys || " +
"printf '%s\\n' " + escapedPublicKey + " >> ~/.ssh/authorized_keys; }";
return "sh -lc " + shellQuote(innerCommand);
}
private String buildRemoteFailureMessage(String prefix, SshService.CommandResult result) {
String stderr = trimToNull(result.getStderr());
String stdout = trimToNull(result.getStdout());
String detail = stderr != null ? stderr : stdout;
if (detail == null) {
return prefix;
}
return prefix + "" + detail;
}
private String buildKeyComment(Long userId, String connectionName) {
String sanitizedName = sanitizeForComment(connectionName);
long timestamp = Instant.now().getEpochSecond();
if (sanitizedName == null) {
return "ssh-manager-" + userId + "-" + timestamp;
}
return "ssh-manager-" + userId + "-" + timestamp + "-" + sanitizedName;
}
private String sanitizeForComment(String value) {
String trimmed = trimToNull(value);
if (trimmed == null) {
return null;
}
StringBuilder builder = new StringBuilder();
for (int i = 0; i < trimmed.length(); i++) {
char ch = trimmed.charAt(i);
if ((ch >= 'a' && ch <= 'z')
|| (ch >= 'A' && ch <= 'Z')
|| (ch >= '0' && ch <= '9')
|| ch == '.'
|| ch == '_'
|| ch == '-') {
builder.append(ch);
} else if (builder.length() == 0 || builder.charAt(builder.length() - 1) != '-') {
builder.append('-');
}
}
String sanitized = builder.toString().replaceAll("^-+|-+$", "");
return sanitized.isEmpty() ? null : sanitized;
}
private Connection buildConnection(ConnectionCreateRequest request, Connection.AuthType authType) {
Connection connection = new Connection();
connection.setHost(trimToNull(request.getHost()));
connection.setPort(request.getPort() != null ? request.getPort() : 22);
connection.setUsername(trimToNull(request.getUsername()));
connection.setAuthType(authType);
return connection;
}
private String shellQuote(String value) {
return "'" + value.replace("'", "'\"'\"'") + "'";
}
private String formatCauseMessage(Exception e) {
String message = trimToNull(e.getMessage());
if (message == null) {
return "";
}
return "" + message;
}
private String trimToNull(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
public static class BootstrapResult {
private final String privateKey;
public BootstrapResult(String privateKey) {
this.privateKey = privateKey;
}
public String getPrivateKey() {
return privateKey;
}
}
private static class GeneratedKeyPair {
private final String privateKey;
private final String publicKey;
private GeneratedKeyPair(String privateKey, String publicKey) {
this.privateKey = privateKey;
this.publicKey = publicKey;
}
public String getPrivateKey() {
return privateKey;
}
public String getPublicKey() {
return publicKey;
}
}
}
@@ -7,6 +7,7 @@ import com.jcraft.jsch.Session;
import com.sshmanager.entity.Connection;
import org.springframework.stereotype.Service;
import java.io.ByteArrayOutputStream;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
@@ -73,6 +74,15 @@ public class SshService {
// 执行单次命令并返回输出
public String executeCommand(Connection conn, String password, String privateKey, String passphrase, String command) throws Exception {
CommandResult result = executeCommandWithResult(conn, password, privateKey, passphrase, command);
return result.getStdout();
}
public CommandResult executeCommandWithResult(Connection conn,
String password,
String privateKey,
String passphrase,
String command) throws Exception {
JSch jsch = new JSch();
if (conn.getAuthType() == Connection.AuthType.PRIVATE_KEY && privateKey != null && !privateKey.isEmpty()) {
@@ -95,21 +105,34 @@ public class SshService {
ChannelExec channel = (ChannelExec) session.openChannel("exec");
channel.setCommand(command);
channel.setErrStream(System.err);
InputStream in = channel.getInputStream();
ByteArrayOutputStream stderr = new ByteArrayOutputStream();
channel.setErrStream(stderr, true);
channel.connect(3000);
BufferedReader reader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8));
StringBuilder result = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
result.append(line).append("\n");
String stdout = readStream(in);
while (!channel.isClosed()) {
Thread.sleep(50L);
}
String stderrText = stderr.toString(StandardCharsets.UTF_8.name()).trim();
int exitStatus = channel.getExitStatus();
channel.disconnect();
session.disconnect();
return new CommandResult(stdout, stderrText, exitStatus);
}
private String readStream(InputStream inputStream) throws Exception {
BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8));
StringBuilder result = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
if (result.length() > 0) {
result.append('\n');
}
result.append(line);
}
return result.toString().trim();
}
@@ -160,4 +183,28 @@ public class SshService {
return channel != null && channel.isConnected();
}
}
public static class CommandResult {
private final String stdout;
private final String stderr;
private final int exitStatus;
public CommandResult(String stdout, String stderr, int exitStatus) {
this.stdout = stdout;
this.stderr = stderr;
this.exitStatus = exitStatus;
}
public String getStdout() {
return stdout;
}
public String getStderr() {
return stderr;
}
public int getExitStatus() {
return exitStatus;
}
}
}
@@ -3,6 +3,7 @@ package com.sshmanager.service;
import com.sshmanager.dto.ConnectionCreateRequest;
import com.sshmanager.dto.ConnectionDto;
import com.sshmanager.entity.Connection;
import com.sshmanager.exception.InvalidOperationException;
import com.sshmanager.repository.ConnectionRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -17,9 +18,13 @@ import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.lenient;
@ExtendWith(MockitoExtension.class)
class ConnectionServiceTest {
@@ -30,12 +35,18 @@ class ConnectionServiceTest {
@Mock
private EncryptionService encryptionService;
@Mock
private SshService sshService;
@Mock
private SshBootstrapService sshBootstrapService;
@InjectMocks
private ConnectionService connectionService;
@BeforeEach
void setUp() {
when(connectionRepository.save(any(Connection.class))).thenAnswer(invocation -> invocation.getArgument(0));
lenient().when(connectionRepository.save(any(Connection.class))).thenAnswer(invocation -> invocation.getArgument(0));
}
@Test
@@ -91,11 +102,82 @@ class ConnectionServiceTest {
assertNull(saved.getEncryptedPassword());
}
@Test
void createPasswordBootstrapConnectionSavesGeneratedPrivateKey() {
ConnectionCreateRequest request = new ConnectionCreateRequest();
request.setName("prod");
request.setHost("127.0.0.1");
request.setPort(22);
request.setUsername("root");
request.setSetupMode(ConnectionCreateRequest.SetupMode.PASSWORD_BOOTSTRAP);
request.setBootstrapPassword("bootstrap-secret");
when(sshBootstrapService.bootstrapWithPassword(request, 1L))
.thenReturn(new SshBootstrapService.BootstrapResult("generated-private-key"));
when(encryptionService.encrypt("generated-private-key")).thenReturn("enc-generated-private-key");
ConnectionDto result = connectionService.create(request, 1L);
assertNotNull(result);
ArgumentCaptor<Connection> captor = ArgumentCaptor.forClass(Connection.class);
verify(connectionRepository).save(captor.capture());
Connection saved = captor.getValue();
assertEquals(Connection.AuthType.PRIVATE_KEY, saved.getAuthType());
assertNull(saved.getEncryptedPassword());
assertEquals("enc-generated-private-key", saved.getEncryptedPrivateKey());
assertNull(saved.getPassphrase());
verify(sshBootstrapService).bootstrapWithPassword(request, 1L);
verify(encryptionService, never()).encrypt("bootstrap-secret");
}
@Test
void createPasswordBootstrapConnectionRequiresBootstrapPassword() {
ConnectionCreateRequest request = new ConnectionCreateRequest();
request.setName("prod");
request.setHost("127.0.0.1");
request.setUsername("root");
request.setSetupMode(ConnectionCreateRequest.SetupMode.PASSWORD_BOOTSTRAP);
InvalidOperationException exception = assertThrows(
InvalidOperationException.class,
() -> connectionService.create(request, 1L)
);
assertEquals("启用一键免密配置时必须填写初始登录密码", exception.getMessage());
verifyNoInteractions(connectionRepository, sshBootstrapService, encryptionService);
}
@Test
void createPasswordBootstrapConnectionDoesNotSaveWhenBootstrapFails() {
ConnectionCreateRequest request = new ConnectionCreateRequest();
request.setName("prod");
request.setHost("127.0.0.1");
request.setUsername("root");
request.setSetupMode(ConnectionCreateRequest.SetupMode.PASSWORD_BOOTSTRAP);
request.setBootstrapPassword("bootstrap-secret");
when(sshBootstrapService.bootstrapWithPassword(request, 1L))
.thenThrow(new InvalidOperationException("免密初始化失败"));
InvalidOperationException exception = assertThrows(
InvalidOperationException.class,
() -> connectionService.create(request, 1L)
);
assertEquals("免密初始化失败", exception.getMessage());
verify(connectionRepository, never()).save(any(Connection.class));
}
@Test
void updateSwitchToPrivateKeyClearsPasswordCredential() {
Connection existing = new Connection();
existing.setId(10L);
existing.setUserId(1L);
existing.setName("prod");
existing.setHost("127.0.0.1");
existing.setPort(22);
existing.setUsername("root");
existing.setAuthType(Connection.AuthType.PASSWORD);
existing.setEncryptedPassword("old-password");
@@ -126,6 +208,10 @@ class ConnectionServiceTest {
Connection existing = new Connection();
existing.setId(20L);
existing.setUserId(1L);
existing.setName("prod");
existing.setHost("127.0.0.1");
existing.setPort(22);
existing.setUsername("root");
existing.setAuthType(Connection.AuthType.PRIVATE_KEY);
existing.setEncryptedPrivateKey("old-key");
existing.setPassphrase("old-passphrase");
@@ -149,4 +235,30 @@ class ConnectionServiceTest {
assertNull(saved.getEncryptedPrivateKey());
assertNull(saved.getPassphrase());
}
@Test
void updateRejectsPasswordBootstrapMode() {
Connection existing = new Connection();
existing.setId(20L);
existing.setUserId(1L);
existing.setName("prod");
existing.setHost("127.0.0.1");
existing.setPort(22);
existing.setUsername("root");
existing.setAuthType(Connection.AuthType.PASSWORD);
existing.setEncryptedPassword("old-password");
ConnectionCreateRequest request = new ConnectionCreateRequest();
request.setSetupMode(ConnectionCreateRequest.SetupMode.PASSWORD_BOOTSTRAP);
when(connectionRepository.findById(20L)).thenReturn(Optional.of(existing));
InvalidOperationException exception = assertThrows(
InvalidOperationException.class,
() -> connectionService.update(20L, request, 1L)
);
assertEquals("编辑连接时不支持一键免密配置", exception.getMessage());
verify(connectionRepository, never()).save(any(Connection.class));
}
}
@@ -0,0 +1,104 @@
package com.sshmanager.service;
import com.sshmanager.dto.ConnectionCreateRequest;
import com.sshmanager.entity.Connection;
import com.sshmanager.exception.InvalidOperationException;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class SshBootstrapServiceTest {
@Mock
private SshService sshService;
@InjectMocks
private SshBootstrapService sshBootstrapService;
@Test
void bootstrapWithPasswordGeneratesKeyAndVerifiesPrivateKeyLogin() throws Exception {
ConnectionCreateRequest request = new ConnectionCreateRequest();
request.setName("prod");
request.setHost("127.0.0.1");
request.setPort(22);
request.setUsername("root");
request.setBootstrapPassword("bootstrap-secret");
when(sshService.executeCommandWithResult(any(Connection.class), anyString(), isNull(), isNull(), anyString()))
.thenReturn(new SshService.CommandResult("", "", 0));
when(sshService.executeCommandWithResult(any(Connection.class), isNull(), anyString(), isNull(), anyString()))
.thenReturn(new SshService.CommandResult("ssh-manager bootstrap ok", "", 0));
SshBootstrapService.BootstrapResult result = sshBootstrapService.bootstrapWithPassword(request, 1L);
assertNotNull(result);
assertTrue(result.getPrivateKey().contains("PRIVATE KEY"));
ArgumentCaptor<Connection> connectionCaptor = ArgumentCaptor.forClass(Connection.class);
ArgumentCaptor<String> passwordCaptor = ArgumentCaptor.forClass(String.class);
ArgumentCaptor<String> privateKeyCaptor = ArgumentCaptor.forClass(String.class);
ArgumentCaptor<String> commandCaptor = ArgumentCaptor.forClass(String.class);
verify(sshService, times(2)).executeCommandWithResult(
connectionCaptor.capture(),
passwordCaptor.capture(),
privateKeyCaptor.capture(),
isNull(),
commandCaptor.capture()
);
List<Connection> connections = connectionCaptor.getAllValues();
List<String> passwords = passwordCaptor.getAllValues();
List<String> privateKeys = privateKeyCaptor.getAllValues();
List<String> commands = commandCaptor.getAllValues();
assertEquals(Connection.AuthType.PASSWORD, connections.get(0).getAuthType());
assertEquals("bootstrap-secret", passwords.get(0));
assertNull(privateKeys.get(0));
assertTrue(commands.get(0).contains("authorized_keys"));
assertEquals(Connection.AuthType.PRIVATE_KEY, connections.get(1).getAuthType());
assertNull(passwords.get(1));
assertNotNull(privateKeys.get(1));
assertTrue(privateKeys.get(1).contains("PRIVATE KEY"));
assertTrue(commands.get(1).contains("ssh-manager bootstrap ok"));
}
@Test
void bootstrapWithPasswordFailsWhenRemoteAuthorizationCommandFails() throws Exception {
ConnectionCreateRequest request = new ConnectionCreateRequest();
request.setName("prod");
request.setHost("127.0.0.1");
request.setPort(22);
request.setUsername("root");
request.setBootstrapPassword("bootstrap-secret");
when(sshService.executeCommandWithResult(any(Connection.class), anyString(), isNull(), isNull(), anyString()))
.thenReturn(new SshService.CommandResult("", "permission denied", 1));
InvalidOperationException exception = assertThrows(
InvalidOperationException.class,
() -> sshBootstrapService.bootstrapWithPassword(request, 1L)
);
assertTrue(exception.getMessage().contains("authorized_keys"));
assertTrue(exception.getMessage().contains("permission denied"));
}
}
+3
View File
@@ -1,6 +1,7 @@
import client from './client'
export type AuthType = 'PASSWORD' | 'PRIVATE_KEY'
export type ConnectionSetupMode = 'NONE' | 'PASSWORD_BOOTSTRAP'
export interface Connection {
id: number
@@ -22,6 +23,8 @@ export interface ConnectionCreateRequest {
password?: string
privateKey?: string
passphrase?: string
setupMode?: ConnectionSetupMode
bootstrapPassword?: string
}
export function listConnections() {
+57 -12
View File
@@ -23,8 +23,11 @@ const privateKey = ref('')
const privateKeyFileName = ref('')
const privateKeyInputRef = ref<HTMLInputElement | null>(null)
const passphrase = ref('')
const passwordBootstrapEnabled = ref(false)
const bootstrapPassword = ref('')
const isEdit = computed(() => !!props.connection)
const isPasswordBootstrapMode = computed(() => !isEdit.value && passwordBootstrapEnabled.value)
const hostError = computed(() => {
const h = host.value.trim()
@@ -59,6 +62,8 @@ watch(
privateKey.value = ''
privateKeyFileName.value = ''
passphrase.value = ''
passwordBootstrapEnabled.value = false
bootstrapPassword.value = ''
} else {
name.value = ''
host.value = ''
@@ -69,6 +74,8 @@ watch(
privateKey.value = ''
privateKeyFileName.value = ''
passphrase.value = ''
passwordBootstrapEnabled.value = false
bootstrapPassword.value = ''
}
},
{ immediate: true }
@@ -151,11 +158,15 @@ async function handleSubmit() {
error.value = '请填写用户名'
return
}
if (authType.value === 'PASSWORD' && !isEdit.value && !password.value) {
if (isPasswordBootstrapMode.value && !bootstrapPassword.value) {
error.value = '请填写初始登录密码'
return
}
if (!isPasswordBootstrapMode.value && authType.value === 'PASSWORD' && !isEdit.value && !password.value) {
error.value = '请填写密码'
return
}
if (authType.value === 'PRIVATE_KEY' && !isEdit.value && !privateKey.value.trim()) {
if (!isPasswordBootstrapMode.value && authType.value === 'PRIVATE_KEY' && !isEdit.value && !privateKey.value.trim()) {
error.value = '请填写私钥'
return
}
@@ -167,14 +178,19 @@ async function handleSubmit() {
host: host.value.trim(),
port: port.value,
username: username.value.trim(),
authType: authType.value,
}
if (authType.value === 'PASSWORD' && password.value) {
data.password = password.value
}
if (authType.value === 'PRIVATE_KEY') {
if (privateKey.value.trim()) data.privateKey = privateKey.value.trim()
if (passphrase.value) data.passphrase = passphrase.value
if (isPasswordBootstrapMode.value) {
data.setupMode = 'PASSWORD_BOOTSTRAP'
data.bootstrapPassword = bootstrapPassword.value
} else {
data.authType = authType.value
if (authType.value === 'PASSWORD' && password.value) {
data.password = password.value
}
if (authType.value === 'PRIVATE_KEY') {
if (privateKey.value.trim()) data.privateKey = privateKey.value.trim()
if (passphrase.value) data.passphrase = passphrase.value
}
}
try {
if (props.onSave) {
@@ -262,7 +278,19 @@ async function handleSubmit() {
placeholder="root"
/>
</div>
<div>
<div v-if="!isEdit" class="rounded-lg border border-cyan-900/70 bg-cyan-950/40 p-4 space-y-3">
<label class="flex items-start gap-3 cursor-pointer">
<input v-model="passwordBootstrapEnabled" type="checkbox" class="mt-1 rounded border-slate-500 bg-slate-700 text-cyan-500" />
<div>
<p class="text-sm font-medium text-slate-100">一键免密配置</p>
<p class="mt-1 text-xs leading-5 text-slate-400">
使用一次初始密码为远端追加公钥创建成功后会保存为系统内的私钥连接
不会修改你本机的 <code>~/.ssh/config</code>
</p>
</div>
</label>
</div>
<div v-if="!isPasswordBootstrapMode">
<label class="block text-sm font-medium text-slate-300 mb-2">认证方式</label>
<div class="flex gap-4">
<label class="flex items-center gap-2 cursor-pointer">
@@ -275,7 +303,24 @@ async function handleSubmit() {
</label>
</div>
</div>
<div v-if="authType === 'PASSWORD'">
<div v-if="isPasswordBootstrapMode" class="space-y-2 rounded-lg border border-emerald-900/70 bg-emerald-950/30 p-4">
<div>
<p class="text-sm font-medium text-slate-100">创建后将自动切换为私钥认证</p>
<p class="mt-1 text-xs leading-5 text-slate-400">
系统会生成一把新的 SSH 密钥先用密码登录并写入远端 <code>authorized_keys</code>验证成功后再保存连接
</p>
</div>
<div>
<label for="bootstrapPassword" class="block text-sm font-medium text-slate-300 mb-1">初始登录密码</label>
<input
id="bootstrapPassword"
v-model="bootstrapPassword"
type="password"
class="w-full px-4 py-2.5 rounded-lg bg-slate-700 border border-slate-600 text-slate-100 focus:outline-none focus:ring-2 focus:ring-cyan-500"
/>
</div>
</div>
<div v-if="!isPasswordBootstrapMode && authType === 'PASSWORD'">
<label for="password" class="block text-sm font-medium text-slate-300 mb-1">
密码 {{ isEdit ? '(留空则不修改)' : '' }}
</label>
@@ -287,7 +332,7 @@ async function handleSubmit() {
:placeholder="isEdit ? '••••••••' : ''"
/>
</div>
<div v-if="authType === 'PRIVATE_KEY'" class="space-y-2">
<div v-if="!isPasswordBootstrapMode && authType === 'PRIVATE_KEY'" class="space-y-2">
<label for="privateKey" class="block text-sm font-medium text-slate-300 mb-1">
私钥 {{ isEdit ? '(留空则不修改)' : '' }}
</label>
+1 -1
View File
@@ -252,7 +252,7 @@ async function handleSessionSubmit(data: ConnectionCreateRequest) {
await connectionsStore.createConnection(data)
// Tree node insertion is handled by useConnectionSync -> syncNewConnections.
// Avoid manual insertion here to prevent duplicate nodes.
toast.success('连接已创建')
toast.success(data.setupMode === 'PASSWORD_BOOTSTRAP' ? '连接已创建并完成免密配置' : '连接已创建')
showFirstRunGuide.value = false
}
closeSessionModal()