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
@@ -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;
}
}
}