Add password-bootstrap SSH setup for new connections
This commit is contained in:
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user