Initial commit: SSH Manager (backend + frontend)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
package com.sshmanager.service;
|
||||
|
||||
import com.sshmanager.dto.ConnectionCreateRequest;
|
||||
import com.sshmanager.dto.ConnectionDto;
|
||||
import com.sshmanager.entity.Connection;
|
||||
import com.sshmanager.repository.ConnectionRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class ConnectionService {
|
||||
|
||||
private final ConnectionRepository connectionRepository;
|
||||
private final EncryptionService encryptionService;
|
||||
|
||||
public ConnectionService(ConnectionRepository connectionRepository,
|
||||
EncryptionService encryptionService) {
|
||||
this.connectionRepository = connectionRepository;
|
||||
this.encryptionService = encryptionService;
|
||||
}
|
||||
|
||||
public List<ConnectionDto> listByUserId(Long userId) {
|
||||
return connectionRepository.findByUserIdOrderByUpdatedAtDesc(userId).stream()
|
||||
.map(ConnectionDto::fromEntity)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
public ConnectionDto getById(Long id, Long userId) {
|
||||
Connection conn = connectionRepository.findById(id).orElseThrow(
|
||||
() -> new RuntimeException("Connection not found: " + id));
|
||||
if (!conn.getUserId().equals(userId)) {
|
||||
throw new RuntimeException("Access denied");
|
||||
}
|
||||
return ConnectionDto.fromEntity(conn);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ConnectionDto create(ConnectionCreateRequest request, Long userId) {
|
||||
Connection conn = new Connection();
|
||||
conn.setUserId(userId);
|
||||
conn.setName(request.getName());
|
||||
conn.setHost(request.getHost());
|
||||
conn.setPort(request.getPort() != null ? request.getPort() : 22);
|
||||
conn.setUsername(request.getUsername());
|
||||
conn.setAuthType(request.getAuthType() != null ? request.getAuthType() : Connection.AuthType.PASSWORD);
|
||||
|
||||
if (conn.getAuthType() == Connection.AuthType.PASSWORD && request.getPassword() != null) {
|
||||
conn.setEncryptedPassword(encryptionService.encrypt(request.getPassword()));
|
||||
} else if (conn.getAuthType() == Connection.AuthType.PRIVATE_KEY && request.getPrivateKey() != null) {
|
||||
conn.setEncryptedPrivateKey(encryptionService.encrypt(request.getPrivateKey()));
|
||||
if (request.getPassphrase() != null && !request.getPassphrase().isEmpty()) {
|
||||
conn.setPassphrase(encryptionService.encrypt(request.getPassphrase()));
|
||||
}
|
||||
}
|
||||
|
||||
conn = connectionRepository.save(conn);
|
||||
return ConnectionDto.fromEntity(conn);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ConnectionDto update(Long id, ConnectionCreateRequest request, Long userId) {
|
||||
Connection conn = connectionRepository.findById(id).orElseThrow(
|
||||
() -> new RuntimeException("Connection not found: " + id));
|
||||
if (!conn.getUserId().equals(userId)) {
|
||||
throw new RuntimeException("Access denied");
|
||||
}
|
||||
|
||||
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.getAuthType() != null) conn.setAuthType(request.getAuthType());
|
||||
|
||||
if (request.getPassword() != null) {
|
||||
conn.setEncryptedPassword(encryptionService.encrypt(request.getPassword()));
|
||||
}
|
||||
if (request.getPrivateKey() != null) {
|
||||
conn.setEncryptedPrivateKey(encryptionService.encrypt(request.getPrivateKey()));
|
||||
}
|
||||
if (request.getPassphrase() != null) {
|
||||
conn.setPassphrase(request.getPassphrase().isEmpty() ? null :
|
||||
encryptionService.encrypt(request.getPassphrase()));
|
||||
}
|
||||
|
||||
conn.setUpdatedAt(Instant.now());
|
||||
conn = connectionRepository.save(conn);
|
||||
return ConnectionDto.fromEntity(conn);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void delete(Long id, Long userId) {
|
||||
Connection conn = connectionRepository.findById(id).orElseThrow(
|
||||
() -> new RuntimeException("Connection not found: " + id));
|
||||
if (!conn.getUserId().equals(userId)) {
|
||||
throw new RuntimeException("Access denied");
|
||||
}
|
||||
connectionRepository.delete(conn);
|
||||
}
|
||||
|
||||
public Connection getConnectionForSsh(Long id, Long userId) {
|
||||
Connection conn = connectionRepository.findById(id).orElseThrow(
|
||||
() -> new RuntimeException("Connection not found: " + id));
|
||||
if (!conn.getUserId().equals(userId)) {
|
||||
throw new RuntimeException("Access denied");
|
||||
}
|
||||
return conn;
|
||||
}
|
||||
|
||||
public String getDecryptedPassword(Connection conn) {
|
||||
return conn.getEncryptedPassword() != null ?
|
||||
encryptionService.decrypt(conn.getEncryptedPassword()) : null;
|
||||
}
|
||||
|
||||
public String getDecryptedPrivateKey(Connection conn) {
|
||||
return conn.getEncryptedPrivateKey() != null ?
|
||||
encryptionService.decrypt(conn.getEncryptedPrivateKey()) : null;
|
||||
}
|
||||
|
||||
public String getDecryptedPassphrase(Connection conn) {
|
||||
return conn.getPassphrase() != null ?
|
||||
encryptionService.decrypt(conn.getPassphrase()) : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.sshmanager.service;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.spec.GCMParameterSpec;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Base64;
|
||||
|
||||
@Service
|
||||
public class EncryptionService {
|
||||
|
||||
private static final int GCM_IV_LENGTH = 12;
|
||||
private static final int GCM_TAG_LENGTH = 128;
|
||||
private static final String ALGORITHM = "AES/GCM/NoPadding";
|
||||
|
||||
private final byte[] keyBytes;
|
||||
|
||||
public EncryptionService(@Value("${sshmanager.encryption-key}") String base64Key) {
|
||||
this.keyBytes = Base64.getDecoder().decode(base64Key);
|
||||
if (keyBytes.length != 32) {
|
||||
throw new IllegalArgumentException("Encryption key must be 32 bytes (256 bits)");
|
||||
}
|
||||
}
|
||||
|
||||
public String encrypt(String plainText) {
|
||||
if (plainText == null || plainText.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
byte[] iv = new byte[GCM_IV_LENGTH];
|
||||
new SecureRandom().nextBytes(iv);
|
||||
|
||||
SecretKeySpec keySpec = new SecretKeySpec(keyBytes, "AES");
|
||||
GCMParameterSpec gcmSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
|
||||
|
||||
Cipher cipher = Cipher.getInstance(ALGORITHM);
|
||||
cipher.init(Cipher.ENCRYPT_MODE, keySpec, gcmSpec);
|
||||
byte[] encrypted = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
byte[] combined = new byte[iv.length + encrypted.length];
|
||||
System.arraycopy(iv, 0, combined, 0, iv.length);
|
||||
System.arraycopy(encrypted, 0, combined, iv.length, encrypted.length);
|
||||
|
||||
return Base64.getEncoder().encodeToString(combined);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Encryption failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
public String decrypt(String encryptedText) {
|
||||
if (encryptedText == null || encryptedText.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
byte[] combined = Base64.getDecoder().decode(encryptedText);
|
||||
byte[] iv = new byte[GCM_IV_LENGTH];
|
||||
byte[] encrypted = new byte[combined.length - GCM_IV_LENGTH];
|
||||
System.arraycopy(combined, 0, iv, 0, iv.length);
|
||||
System.arraycopy(combined, iv.length, encrypted, 0, encrypted.length);
|
||||
|
||||
SecretKeySpec keySpec = new SecretKeySpec(keyBytes, "AES");
|
||||
GCMParameterSpec gcmSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
|
||||
|
||||
Cipher cipher = Cipher.getInstance(ALGORITHM);
|
||||
cipher.init(Cipher.DECRYPT_MODE, keySpec, gcmSpec);
|
||||
byte[] decrypted = cipher.doFinal(encrypted);
|
||||
|
||||
return new String(decrypted, StandardCharsets.UTF_8);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Decryption failed", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
188
backend/src/main/java/com/sshmanager/service/SftpService.java
Normal file
188
backend/src/main/java/com/sshmanager/service/SftpService.java
Normal file
@@ -0,0 +1,188 @@
|
||||
package com.sshmanager.service;
|
||||
|
||||
import com.jcraft.jsch.ChannelSftp;
|
||||
import com.jcraft.jsch.JSch;
|
||||
import com.jcraft.jsch.Session;
|
||||
import com.sshmanager.entity.Connection;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.io.PipedInputStream;
|
||||
import java.io.PipedOutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Vector;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
|
||||
@Service
|
||||
public class SftpService {
|
||||
|
||||
public SftpSession connect(Connection conn, String password, String privateKey, String passphrase)
|
||||
throws Exception {
|
||||
JSch jsch = new JSch();
|
||||
|
||||
if (conn.getAuthType() == Connection.AuthType.PRIVATE_KEY && privateKey != null && !privateKey.isEmpty()) {
|
||||
byte[] keyBytes = privateKey.getBytes(StandardCharsets.UTF_8);
|
||||
byte[] passphraseBytes = (passphrase != null && !passphrase.isEmpty())
|
||||
? passphrase.getBytes(StandardCharsets.UTF_8) : null;
|
||||
jsch.addIdentity("key", keyBytes, null, passphraseBytes);
|
||||
}
|
||||
|
||||
Session session = jsch.getSession(conn.getUsername(), conn.getHost(), conn.getPort());
|
||||
session.setConfig("StrictHostKeyChecking", "no");
|
||||
if (conn.getAuthType() == Connection.AuthType.PASSWORD && password != null) {
|
||||
session.setPassword(password);
|
||||
}
|
||||
session.connect(10000);
|
||||
|
||||
ChannelSftp channel = (ChannelSftp) session.openChannel("sftp");
|
||||
channel.connect(5000);
|
||||
|
||||
return new SftpSession(session, channel);
|
||||
}
|
||||
|
||||
public static class SftpSession {
|
||||
private final Session session;
|
||||
private final ChannelSftp channel;
|
||||
|
||||
public SftpSession(Session session, ChannelSftp channel) {
|
||||
this.session = session;
|
||||
this.channel = channel;
|
||||
}
|
||||
|
||||
public ChannelSftp getChannel() {
|
||||
return channel;
|
||||
}
|
||||
|
||||
public void disconnect() {
|
||||
if (channel != null && channel.isConnected()) {
|
||||
channel.disconnect();
|
||||
}
|
||||
if (session != null && session.isConnected()) {
|
||||
session.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isConnected() {
|
||||
return channel != null && channel.isConnected();
|
||||
}
|
||||
}
|
||||
|
||||
public static class FileInfo {
|
||||
public String name;
|
||||
public boolean directory;
|
||||
public long size;
|
||||
public long mtime;
|
||||
|
||||
public FileInfo(String name, boolean directory, long size, long mtime) {
|
||||
this.name = name;
|
||||
this.directory = directory;
|
||||
this.size = size;
|
||||
this.mtime = mtime;
|
||||
}
|
||||
}
|
||||
|
||||
public List<FileInfo> listFiles(SftpSession sftpSession, String path) throws Exception {
|
||||
Vector<?> entries = sftpSession.getChannel().ls(path);
|
||||
List<FileInfo> result = new ArrayList<>();
|
||||
for (Object obj : entries) {
|
||||
ChannelSftp.LsEntry entry = (ChannelSftp.LsEntry) obj;
|
||||
String name = entry.getFilename();
|
||||
if (".".equals(name) || "..".equals(name)) continue;
|
||||
result.add(new FileInfo(
|
||||
name,
|
||||
entry.getAttrs().isDir(),
|
||||
entry.getAttrs().getSize(),
|
||||
entry.getAttrs().getMTime() * 1000L
|
||||
));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public byte[] download(SftpSession sftpSession, String remotePath) throws Exception {
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
sftpSession.getChannel().get(remotePath, out);
|
||||
return out.toByteArray();
|
||||
}
|
||||
|
||||
public void upload(SftpSession sftpSession, String remotePath, byte[] data) throws Exception {
|
||||
sftpSession.getChannel().put(new ByteArrayInputStream(data), remotePath);
|
||||
}
|
||||
|
||||
public void delete(SftpSession sftpSession, String remotePath, boolean isDir) throws Exception {
|
||||
if (isDir) {
|
||||
sftpSession.getChannel().rmdir(remotePath);
|
||||
} else {
|
||||
sftpSession.getChannel().rm(remotePath);
|
||||
}
|
||||
}
|
||||
|
||||
public void mkdir(SftpSession sftpSession, String remotePath) throws Exception {
|
||||
sftpSession.getChannel().mkdir(remotePath);
|
||||
}
|
||||
|
||||
public void rename(SftpSession sftpSession, String oldPath, String newPath) throws Exception {
|
||||
sftpSession.getChannel().rename(oldPath, newPath);
|
||||
}
|
||||
|
||||
public String pwd(SftpSession sftpSession) throws Exception {
|
||||
return sftpSession.getChannel().pwd();
|
||||
}
|
||||
|
||||
public void cd(SftpSession sftpSession, String path) throws Exception {
|
||||
sftpSession.getChannel().cd(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transfer a single file from source session to target session (streaming, no full file in memory).
|
||||
* Fails if sourcePath is a directory.
|
||||
*/
|
||||
public void transferRemote(SftpSession source, String sourcePath, SftpSession target, String targetPath)
|
||||
throws Exception {
|
||||
if (source.getChannel().stat(sourcePath).isDir()) {
|
||||
throw new IllegalArgumentException("Source path is a directory; only single file transfer is supported");
|
||||
}
|
||||
final int pipeBufferSize = 65536;
|
||||
PipedOutputStream pos = new PipedOutputStream();
|
||||
PipedInputStream pis = new PipedInputStream(pos, pipeBufferSize);
|
||||
|
||||
ExecutorService executor = Executors.newSingleThreadExecutor();
|
||||
try {
|
||||
Future<?> putFuture = executor.submit(() -> {
|
||||
try {
|
||||
target.getChannel().put(pis, targetPath);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
});
|
||||
source.getChannel().get(sourcePath, pos);
|
||||
pos.close();
|
||||
putFuture.get(5, TimeUnit.MINUTES);
|
||||
} catch (ExecutionException e) {
|
||||
Throwable cause = e.getCause();
|
||||
if (cause instanceof RuntimeException && cause.getCause() instanceof Exception) {
|
||||
throw (Exception) cause.getCause();
|
||||
}
|
||||
if (cause instanceof Exception) {
|
||||
throw (Exception) cause;
|
||||
}
|
||||
throw new RuntimeException(cause);
|
||||
} catch (TimeoutException e) {
|
||||
throw new RuntimeException("Transfer timeout", e);
|
||||
} finally {
|
||||
executor.shutdownNow();
|
||||
try {
|
||||
pis.close();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
97
backend/src/main/java/com/sshmanager/service/SshService.java
Normal file
97
backend/src/main/java/com/sshmanager/service/SshService.java
Normal file
@@ -0,0 +1,97 @@
|
||||
package com.sshmanager.service;
|
||||
|
||||
import com.jcraft.jsch.ChannelShell;
|
||||
import com.jcraft.jsch.JSch;
|
||||
import com.jcraft.jsch.Session;
|
||||
import com.sshmanager.entity.Connection;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.io.PipedInputStream;
|
||||
import java.io.PipedOutputStream;
|
||||
|
||||
@Service
|
||||
public class SshService {
|
||||
|
||||
public SshSession createShellSession(Connection conn, String password, String privateKey, String passphrase)
|
||||
throws Exception {
|
||||
JSch jsch = new JSch();
|
||||
|
||||
if (conn.getAuthType() == Connection.AuthType.PRIVATE_KEY && privateKey != null && !privateKey.isEmpty()) {
|
||||
byte[] keyBytes = privateKey.getBytes(StandardCharsets.UTF_8);
|
||||
byte[] passphraseBytes = (passphrase != null && !passphrase.isEmpty())
|
||||
? passphrase.getBytes(StandardCharsets.UTF_8) : null;
|
||||
jsch.addIdentity("key", keyBytes, null, passphraseBytes);
|
||||
}
|
||||
|
||||
Session session = jsch.getSession(conn.getUsername(), conn.getHost(), conn.getPort());
|
||||
session.setConfig("StrictHostKeyChecking", "no");
|
||||
|
||||
if (conn.getAuthType() == Connection.AuthType.PASSWORD && password != null) {
|
||||
session.setPassword(password);
|
||||
}
|
||||
|
||||
session.connect(10000);
|
||||
|
||||
ChannelShell channel = (ChannelShell) session.openChannel("shell");
|
||||
channel.setPtyType("xterm");
|
||||
channel.connect(5000);
|
||||
|
||||
PipedInputStream pipedIn = new PipedInputStream();
|
||||
PipedOutputStream pipeToChannel = new PipedOutputStream(pipedIn);
|
||||
OutputStream channelIn = channel.getOutputStream();
|
||||
InputStream channelOut = channel.getInputStream();
|
||||
|
||||
new Thread(() -> {
|
||||
try {
|
||||
byte[] buf = new byte[1024];
|
||||
int n;
|
||||
while ((n = pipedIn.read(buf)) > 0) {
|
||||
channelIn.write(buf, 0, n);
|
||||
channelIn.flush();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// Channel closed
|
||||
}
|
||||
}).start();
|
||||
|
||||
return new SshSession(session, channel, channelOut, pipeToChannel);
|
||||
}
|
||||
|
||||
public static class SshSession {
|
||||
private final Session session;
|
||||
private final ChannelShell channel;
|
||||
private final InputStream outputStream;
|
||||
private final OutputStream inputStream;
|
||||
|
||||
public SshSession(Session session, ChannelShell channel, InputStream outputStream, OutputStream inputStream) {
|
||||
this.session = session;
|
||||
this.channel = channel;
|
||||
this.outputStream = outputStream;
|
||||
this.inputStream = inputStream;
|
||||
}
|
||||
|
||||
public InputStream getOutputStream() {
|
||||
return outputStream;
|
||||
}
|
||||
|
||||
public OutputStream getInputStream() {
|
||||
return inputStream;
|
||||
}
|
||||
|
||||
public void disconnect() {
|
||||
if (channel != null && channel.isConnected()) {
|
||||
channel.disconnect();
|
||||
}
|
||||
if (session != null && session.isConnected()) {
|
||||
session.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isConnected() {
|
||||
return channel != null && channel.isConnected();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user