222 lines
7.9 KiB
Java
222 lines
7.9 KiB
Java
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;
|
||
}
|
||
}
|
||
}
|