提交Git

This commit is contained in:
Peijun Zhao
2026-05-10 12:59:00 +08:00
commit b0aaea9fb3
2544 changed files with 545276 additions and 0 deletions
@@ -0,0 +1,457 @@
package com.aluer.console;
import com.aluer.audit.SecurityAuditService;
import com.aluer.config.ServerGuardConfig;
import com.aluer.security.CommandExecutionGuardService;
import com.jcraft.jsch.ChannelExec;
import com.jcraft.jsch.HostKey;
import com.jcraft.jsch.JSch;
import com.jcraft.jsch.JSchException;
import com.jcraft.jsch.Session;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
@Service
public class RemoteSshGatewayService {
private static final int CONNECT_TIMEOUT_MS = 12_000;
private static final int MAX_OUTPUT_CHARS = 16_000;
private final ServerGuardConfig config;
private final CommandExecutionGuardService commandExecutionGuardService;
private final SecurityAuditService securityAuditService;
private final AluerEngineHandshakeService aluerEngineHandshakeService;
private final Map<String, ActiveSession> sessions = new ConcurrentHashMap<>();
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(runnable -> {
Thread thread = new Thread(runnable, "aluer-ssh-gateway");
thread.setDaemon(true);
return thread;
});
@Autowired
public RemoteSshGatewayService(ServerGuardConfig config,
CommandExecutionGuardService commandExecutionGuardService,
SecurityAuditService securityAuditService,
AluerEngineHandshakeService aluerEngineHandshakeService) {
this.config = config;
this.commandExecutionGuardService = commandExecutionGuardService;
this.securityAuditService = securityAuditService;
this.aluerEngineHandshakeService = aluerEngineHandshakeService;
startCleanupLoop();
}
public RemoteSshGatewayService(ServerGuardConfig config,
CommandExecutionGuardService commandExecutionGuardService,
SecurityAuditService securityAuditService) {
this(config, commandExecutionGuardService, securityAuditService, null);
}
public AluerEngineHandshakeService.HandshakeResult requestHandshake(HandshakeRequest request) {
ensureEnabled();
if (aluerEngineHandshakeService == null) {
throw new IllegalStateException("主引擎握手服务不可用");
}
return aluerEngineHandshakeService.requestSshHandshake(
new AluerEngineHandshakeService.HandshakeIntent(
request.host(),
request.port(),
request.username(),
request.purpose()
)
);
}
public SessionView connect(ConnectRequest request) {
ensureEnabled();
cleanupExpiredSessions();
if (sessions.size() >= Math.max(1, config.getDashboard().getSshGateway().getMaxSessions())) {
throw new IllegalStateException("SSH 会话已达到上限");
}
String host = requireText(request.host(), "host");
String username = requireText(request.username(), "username");
int port = request.port() > 0 ? request.port() : 22;
boolean hasPassword = request.password() != null && !request.password().isBlank();
boolean hasInlineKey = request.privateKey() != null && !request.privateKey().isBlank();
boolean hasKeyPath = request.privateKeyPath() != null && !request.privateKeyPath().isBlank();
if (!hasPassword && !hasInlineKey && !hasKeyPath) {
throw new IllegalArgumentException("请提供 SSH 密码或私钥");
}
if (hasInlineKey && !config.getDashboard().getSshGateway().isAllowPrivateKeyPaste()) {
throw new IllegalArgumentException("当前配置不允许粘贴私钥");
}
String alias = request.alias() == null || request.alias().isBlank()
? username + "@" + host
: request.alias().trim();
validateHandshake(request, host, port, username);
try {
JSch jsch = new JSch();
if (hasInlineKey) {
byte[] passphrase = toBytes(request.passphrase());
jsch.addIdentity(alias, request.privateKey().getBytes(StandardCharsets.UTF_8), null, passphrase);
} else if (hasKeyPath) {
if (request.passphrase() != null && !request.passphrase().isBlank()) {
jsch.addIdentity(request.privateKeyPath(), request.passphrase());
} else {
jsch.addIdentity(request.privateKeyPath());
}
}
Session session = jsch.getSession(username, host, port);
if (hasPassword) {
session.setPassword(request.password());
}
session.setConfig("StrictHostKeyChecking",
config.getDashboard().getSshGateway().isStrictHostKeyChecking() ? "yes" : "no");
session.connect(CONNECT_TIMEOUT_MS);
String sessionId = UUID.randomUUID().toString();
HostKey hostKey = session.getHostKey();
ActiveSession activeSession = new ActiveSession(
sessionId,
alias,
host,
port,
username,
hostKey == null ? "" : hostKey.getFingerPrint(jsch),
session
);
sessions.put(sessionId, activeSession);
securityAuditService.logEvent("SSH_GATEWAY", alias, "CONNECT", host + ":" + port);
return activeSession.toView();
} catch (JSchException e) {
throw new IllegalStateException("SSH 连接失败: " + e.getMessage(), e);
}
}
public SshCommandResult execute(String sessionId, String command) {
ensureEnabled();
ActiveSession activeSession = requireSession(sessionId);
String normalizedCommand = requireText(command, "command");
activeSession.touch();
CommandExecutionGuardService.AntiIntrusionIncident guardIncident =
commandExecutionGuardService.analyzeCommand("ssh:" + activeSession.username, normalizedCommand, activeSession.host);
ChannelExec channel = null;
long started = System.currentTimeMillis();
try {
channel = (ChannelExec) activeSession.session.openChannel("exec");
channel.setCommand(normalizedCommand);
channel.setInputStream(null);
ByteArrayOutputStream stdout = new ByteArrayOutputStream();
ByteArrayOutputStream stderr = new ByteArrayOutputStream();
channel.setOutputStream(stdout);
channel.setErrStream(stderr);
channel.connect(CONNECT_TIMEOUT_MS);
long timeoutAt = started + Math.max(5, config.getDashboard().getSshGateway().getCommandTimeoutSeconds()) * 1000L;
while (!channel.isClosed() && System.currentTimeMillis() < timeoutAt) {
Thread.sleep(120);
}
boolean timedOut = !channel.isClosed();
if (timedOut) {
channel.disconnect();
}
int exitStatus = timedOut ? -1 : channel.getExitStatus();
long durationMs = System.currentTimeMillis() - started;
String stdoutText = trimOutput(stdout.toString(StandardCharsets.UTF_8));
String stderrText = trimOutput(stderr.toString(StandardCharsets.UTF_8));
securityAuditService.logEvent(
"SSH_GATEWAY",
activeSession.alias,
"EXECUTE",
normalizedCommand + " [" + exitStatus + "]"
);
return new SshCommandResult(
sessionId,
normalizedCommand,
stdoutText,
stderrText,
exitStatus,
durationMs,
timedOut,
guardIncident != null,
guardIncident == null ? "" : guardIncident.getType(),
guardIncident == null ? 0 : guardIncident.getSeverity(),
Instant.now().toEpochMilli()
);
} catch (Exception e) {
throw new IllegalStateException("SSH 命令执行失败: " + e.getMessage(), e);
} finally {
if (channel != null && channel.isConnected()) {
channel.disconnect();
}
}
}
public SessionView disconnect(String sessionId) {
ActiveSession activeSession = requireSession(sessionId);
activeSession.session.disconnect();
sessions.remove(sessionId);
securityAuditService.logEvent("SSH_GATEWAY", activeSession.alias, "DISCONNECT", activeSession.host);
return activeSession.toView();
}
public Map<String, Object> getGatewayStatus() {
Map<String, Object> result = new LinkedHashMap<>();
result.put("enabled", config.getDashboard().getSshGateway().isEnabled());
result.put("activeSessions", sessions.size());
result.put("sessionTimeoutMinutes", config.getDashboard().getSshGateway().getSessionTimeoutMinutes());
result.put("maxSessions", config.getDashboard().getSshGateway().getMaxSessions());
result.put("strictHostKeyChecking", config.getDashboard().getSshGateway().isStrictHostKeyChecking());
result.put("handshakeRequired", config.getDashboard().getSshGateway().isRequireEngineHandshake());
if (aluerEngineHandshakeService != null) {
result.put("engineHandshake", aluerEngineHandshakeService.getStatus());
}
result.put("sessions", listSessions());
return result;
}
public List<SessionView> listSessions() {
cleanupExpiredSessions();
return sessions.values().stream()
.sorted(Comparator.comparingLong(ActiveSession::getLastUsedAt).reversed())
.map(ActiveSession::toView)
.toList();
}
private void startCleanupLoop() {
scheduler.scheduleAtFixedRate(this::cleanupExpiredSessions, 60, 60, TimeUnit.SECONDS);
}
private void cleanupExpiredSessions() {
long cutoff = System.currentTimeMillis()
- Math.max(5, config.getDashboard().getSshGateway().getSessionTimeoutMinutes()) * 60_000L;
List<String> expired = new ArrayList<>();
for (ActiveSession activeSession : sessions.values()) {
if (activeSession.lastUsedAt < cutoff || !activeSession.session.isConnected()) {
expired.add(activeSession.sessionId);
}
}
for (String sessionId : expired) {
ActiveSession activeSession = sessions.remove(sessionId);
if (activeSession != null && activeSession.session.isConnected()) {
activeSession.session.disconnect();
}
}
}
private void ensureEnabled() {
if (!config.getDashboard().getSshGateway().isEnabled()) {
throw new IllegalStateException("SSH Gateway 已禁用");
}
}
private ActiveSession requireSession(String sessionId) {
String key = requireText(sessionId, "sessionId");
ActiveSession activeSession = sessions.get(key);
if (activeSession == null || !activeSession.session.isConnected()) {
sessions.remove(key);
throw new IllegalArgumentException("SSH 会话不存在或已断开");
}
return activeSession;
}
private String requireText(String value, String field) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(field + " 不能为空");
}
return value.trim();
}
private void validateHandshake(ConnectRequest request, String host, int port, String username) {
if (!config.getDashboard().getSshGateway().isRequireEngineHandshake()) {
return;
}
if (aluerEngineHandshakeService == null) {
throw new IllegalStateException("主引擎握手服务不可用");
}
aluerEngineHandshakeService.consumeSshGrant(request.handshakeToken(), host, port, username);
}
private byte[] toBytes(String value) {
return value == null || value.isBlank() ? null : value.getBytes(StandardCharsets.UTF_8);
}
private String trimOutput(String value) {
if (value == null) {
return "";
}
String normalized = value.trim();
if (normalized.length() <= MAX_OUTPUT_CHARS) {
return normalized;
}
return normalized.substring(0, MAX_OUTPUT_CHARS) + "\n...[output truncated by Aluer]";
}
public record ConnectRequest(
String alias,
String host,
int port,
String username,
String password,
String privateKey,
String privateKeyPath,
String passphrase,
String handshakeToken
) {
}
public record HandshakeRequest(
String host,
int port,
String username,
String purpose
) {
}
public static class SessionView {
private final String sessionId;
private final String alias;
private final String host;
private final int port;
private final String username;
private final String fingerprint;
private final long connectedAt;
private final long lastUsedAt;
public SessionView(String sessionId,
String alias,
String host,
int port,
String username,
String fingerprint,
long connectedAt,
long lastUsedAt) {
this.sessionId = sessionId;
this.alias = alias;
this.host = host;
this.port = port;
this.username = username;
this.fingerprint = fingerprint;
this.connectedAt = connectedAt;
this.lastUsedAt = lastUsedAt;
}
public String getSessionId() { return sessionId; }
public String getAlias() { return alias; }
public String getHost() { return host; }
public int getPort() { return port; }
public String getUsername() { return username; }
public String getFingerprint() { return fingerprint; }
public long getConnectedAt() { return connectedAt; }
public long getLastUsedAt() { return lastUsedAt; }
}
public static class SshCommandResult {
private final String sessionId;
private final String command;
private final String stdout;
private final String stderr;
private final int exitStatus;
private final long durationMs;
private final boolean timedOut;
private final boolean flagged;
private final String guardType;
private final int guardSeverity;
private final long timestamp;
public SshCommandResult(String sessionId,
String command,
String stdout,
String stderr,
int exitStatus,
long durationMs,
boolean timedOut,
boolean flagged,
String guardType,
int guardSeverity,
long timestamp) {
this.sessionId = sessionId;
this.command = command;
this.stdout = stdout;
this.stderr = stderr;
this.exitStatus = exitStatus;
this.durationMs = durationMs;
this.timedOut = timedOut;
this.flagged = flagged;
this.guardType = guardType;
this.guardSeverity = guardSeverity;
this.timestamp = timestamp;
}
public String getSessionId() { return sessionId; }
public String getCommand() { return command; }
public String getStdout() { return stdout; }
public String getStderr() { return stderr; }
public int getExitStatus() { return exitStatus; }
public long getDurationMs() { return durationMs; }
public boolean isTimedOut() { return timedOut; }
public boolean isFlagged() { return flagged; }
public String getGuardType() { return guardType; }
public int getGuardSeverity() { return guardSeverity; }
public long getTimestamp() { return timestamp; }
}
private static final class ActiveSession {
private final String sessionId;
private final String alias;
private final String host;
private final int port;
private final String username;
private final String fingerprint;
private final Session session;
private final long connectedAt;
private volatile long lastUsedAt;
private ActiveSession(String sessionId,
String alias,
String host,
int port,
String username,
String fingerprint,
Session session) {
this.sessionId = sessionId;
this.alias = alias;
this.host = host;
this.port = port;
this.username = username;
this.fingerprint = fingerprint;
this.session = session;
this.connectedAt = System.currentTimeMillis();
this.lastUsedAt = this.connectedAt;
}
private long getLastUsedAt() {
return lastUsedAt;
}
private void touch() {
this.lastUsedAt = System.currentTimeMillis();
}
private SessionView toView() {
return new SessionView(sessionId, alias, host, port, username, fingerprint, connectedAt, lastUsedAt);
}
}
}