update
This commit is contained in:
403
src/main/java/research/loghunter/service/SftpService.java
Normal file
403
src/main/java/research/loghunter/service/SftpService.java
Normal file
@@ -0,0 +1,403 @@
|
||||
package research.loghunter.service;
|
||||
|
||||
import com.jcraft.jsch.*;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import research.loghunter.entity.Server;
|
||||
import research.loghunter.entity.ServerLogPath;
|
||||
import research.loghunter.repository.ServerRepository;
|
||||
import research.loghunter.util.CryptoUtil;
|
||||
|
||||
import java.io.*;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.*;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class SftpService {
|
||||
|
||||
private final ServerRepository serverRepository;
|
||||
private final CryptoUtil cryptoUtil;
|
||||
|
||||
private static final int CONNECT_TIMEOUT = 10000; // 10초
|
||||
private static final int CHANNEL_TIMEOUT = 30000; // 30초
|
||||
|
||||
/**
|
||||
* SFTP 연결 테스트
|
||||
*/
|
||||
public ConnectionTestResult testConnection(Long serverId) {
|
||||
Server server = serverRepository.findById(serverId)
|
||||
.orElseThrow(() -> new RuntimeException("Server not found: " + serverId));
|
||||
|
||||
Session session = null;
|
||||
ChannelSftp channel = null;
|
||||
|
||||
try {
|
||||
session = createSession(server);
|
||||
|
||||
log.info("Connecting to {}:{} as user '{}' with auth type '{}'",
|
||||
server.getHost(), server.getPort(), server.getUsername(), server.getAuthType());
|
||||
|
||||
session.connect(CONNECT_TIMEOUT);
|
||||
|
||||
channel = (ChannelSftp) session.openChannel("sftp");
|
||||
channel.connect(CHANNEL_TIMEOUT);
|
||||
|
||||
// 홈 디렉토리 확인
|
||||
String home = channel.pwd();
|
||||
|
||||
String detail = String.format("연결 성공!\n- 호스트: %s:%d\n- 사용자: %s\n- 인증방식: %s\n- 홈 디렉토리: %s",
|
||||
server.getHost(), server.getPort(), server.getUsername(),
|
||||
"PASSWORD".equals(server.getAuthType()) ? "비밀번호" : "키 파일",
|
||||
home);
|
||||
|
||||
return new ConnectionTestResult(true, detail, null);
|
||||
} catch (JSchException e) {
|
||||
log.error("SFTP connection test failed for server {}: {}", serverId, e.getMessage(), e);
|
||||
String errorDetail = analyzeJSchException(e, server);
|
||||
return new ConnectionTestResult(false, null, errorDetail);
|
||||
} catch (SftpException e) {
|
||||
log.error("SFTP operation failed for server {}: {}", serverId, e.getMessage());
|
||||
return new ConnectionTestResult(false, null, "SFTP 오류: " + e.getMessage());
|
||||
} finally {
|
||||
disconnect(channel, session);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 서버 시간 조회 및 로컬 시간과의 차이 계산 (분 단위)
|
||||
* 반환값: 로그시간 + offset = 로컬시간
|
||||
*/
|
||||
public TimeCheckResult checkServerTime(Long serverId) {
|
||||
Server server = serverRepository.findById(serverId)
|
||||
.orElseThrow(() -> new RuntimeException("Server not found: " + serverId));
|
||||
|
||||
return checkServerTime(server);
|
||||
}
|
||||
|
||||
public TimeCheckResult checkServerTime(Server server) {
|
||||
Session session = null;
|
||||
ChannelExec channel = null;
|
||||
|
||||
try {
|
||||
session = createSession(server);
|
||||
session.connect(CONNECT_TIMEOUT);
|
||||
|
||||
// 서버 시간 조회 (epoch seconds)
|
||||
channel = (ChannelExec) session.openChannel("exec");
|
||||
channel.setCommand("date +%s");
|
||||
|
||||
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
|
||||
channel.setOutputStream(outputStream);
|
||||
channel.connect(CHANNEL_TIMEOUT);
|
||||
|
||||
// 명령 실행 대기
|
||||
while (!channel.isClosed()) {
|
||||
Thread.sleep(100);
|
||||
}
|
||||
|
||||
String serverTimeStr = outputStream.toString().trim();
|
||||
long serverEpoch = Long.parseLong(serverTimeStr);
|
||||
long localEpoch = Instant.now().getEpochSecond();
|
||||
|
||||
// 분 단위 차이 계산 (로컬 - 서버)
|
||||
int offsetMinutes = (int) ((localEpoch - serverEpoch) / 60);
|
||||
|
||||
LocalDateTime serverTime = LocalDateTime.ofInstant(
|
||||
Instant.ofEpochSecond(serverEpoch), ZoneId.systemDefault());
|
||||
LocalDateTime localTime = LocalDateTime.now();
|
||||
|
||||
log.info("Server {} time check - Server: {}, Local: {}, Offset: {} minutes",
|
||||
server.getId(), serverTime, localTime, offsetMinutes);
|
||||
|
||||
return new TimeCheckResult(true, serverTime, localTime, offsetMinutes, null);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to check server time for {}: {}", server.getId(), e.getMessage());
|
||||
return new TimeCheckResult(false, null, LocalDateTime.now(), 0, e.getMessage());
|
||||
} finally {
|
||||
if (channel != null && channel.isConnected()) {
|
||||
channel.disconnect();
|
||||
}
|
||||
if (session != null && session.isConnected()) {
|
||||
session.disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* JSchException 분석하여 상세 에러 메시지 생성
|
||||
*/
|
||||
private String analyzeJSchException(JSchException e, Server server) {
|
||||
String msg = e.getMessage();
|
||||
StringBuilder detail = new StringBuilder();
|
||||
|
||||
detail.append(String.format("대상 서버: %s:%d\n", server.getHost(), server.getPort()));
|
||||
detail.append(String.format("사용자: %s\n", server.getUsername()));
|
||||
detail.append(String.format("인증방식: %s\n\n",
|
||||
"PASSWORD".equals(server.getAuthType()) ? "비밀번호" : "키 파일"));
|
||||
|
||||
if (msg.contains("Auth fail") || msg.contains("Auth cancel")) {
|
||||
detail.append("❌ 인증 실패\n\n");
|
||||
detail.append("✔ IP/포트 연결은 성공했습니다.\n\n");
|
||||
detail.append("확인 사항:\n");
|
||||
if ("PASSWORD".equals(server.getAuthType())) {
|
||||
detail.append("- 사용자명이 정확한가요?\n");
|
||||
detail.append("- 비밀번호가 정확한가요?\n");
|
||||
detail.append("- 서버에서 비밀번호 인증이 허용되어 있나요?");
|
||||
} else {
|
||||
detail.append("- 키 파일 경로가 정확한가요? (").append(server.getKeyFilePath()).append(")\n");
|
||||
detail.append("- 키 파일이 존재하나요?\n");
|
||||
detail.append("- Passphrase가 필요한 경우 입력했나요?\n");
|
||||
detail.append("- 서버에 공개키가 등록되어 있나요?");
|
||||
}
|
||||
} else if (msg.contains("Connection refused")) {
|
||||
detail.append("❌ 연결 거부\n\n");
|
||||
detail.append("확인 사항:\n");
|
||||
detail.append("- 포트 번호가 정확한가요? (현재: ").append(server.getPort()).append(")\n");
|
||||
detail.append("- 서버에서 SSH 서비스가 실행 중인가요?\n");
|
||||
detail.append("- 방화벽에서 해당 포트가 열려 있나요?");
|
||||
} else if (msg.contains("UnknownHost") || msg.contains("No route to host")) {
|
||||
detail.append("❌ 호스트 연결 불가\n\n");
|
||||
detail.append("확인 사항:\n");
|
||||
detail.append("- IP 주소가 정확한가요? (현재: ").append(server.getHost()).append(")\n");
|
||||
detail.append("- 서버가 실행 중인가요?\n");
|
||||
detail.append("- 네트워크 연결이 가능한가요?");
|
||||
} else if (msg.contains("timeout") || msg.contains("timed out")) {
|
||||
detail.append("❌ 연결 시간 초과\n\n");
|
||||
detail.append("확인 사항:\n");
|
||||
detail.append("- IP 주소가 정확한가요?\n");
|
||||
detail.append("- 방화벽에서 차단되어 있지 않나요?\n");
|
||||
detail.append("- 서버가 응답하는지 확인해주세요.");
|
||||
} else if (msg.contains("invalid privatekey")) {
|
||||
detail.append("❌ 키 파일 오류\n\n");
|
||||
detail.append("확인 사항:\n");
|
||||
detail.append("- 키 파일 형식이 올바른가요? (OpenSSH 형식 필요)\n");
|
||||
detail.append("- 키 파일이 손상되지 않았나요?");
|
||||
} else {
|
||||
detail.append("❌ 연결 실패\n\n");
|
||||
detail.append("에러 메시지: ").append(msg);
|
||||
}
|
||||
|
||||
return detail.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 지정된 경로의 모든 파일 목록 조회 (패턴 매칭 포함)
|
||||
*/
|
||||
public List<RemoteFile> listAllFiles(Server server, ServerLogPath logPath) {
|
||||
List<RemoteFile> files = new ArrayList<>();
|
||||
Session session = null;
|
||||
ChannelSftp channel = null;
|
||||
|
||||
try {
|
||||
session = createSession(server);
|
||||
session.connect(CONNECT_TIMEOUT);
|
||||
|
||||
channel = (ChannelSftp) session.openChannel("sftp");
|
||||
channel.connect(CHANNEL_TIMEOUT);
|
||||
|
||||
String path = logPath.getPath();
|
||||
String filePattern = logPath.getFilePattern();
|
||||
Pattern pattern = convertGlobToRegex(filePattern);
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
Vector<ChannelSftp.LsEntry> entries = channel.ls(path);
|
||||
|
||||
for (ChannelSftp.LsEntry entry : entries) {
|
||||
if (entry.getAttrs().isDir()) continue;
|
||||
|
||||
String fileName = entry.getFilename();
|
||||
if (!pattern.matcher(fileName).matches()) continue;
|
||||
|
||||
long mtime = entry.getAttrs().getMTime() * 1000L;
|
||||
LocalDateTime fileTime = LocalDateTime.ofInstant(
|
||||
Instant.ofEpochMilli(mtime), ZoneId.systemDefault());
|
||||
|
||||
files.add(new RemoteFile(
|
||||
path + (path.endsWith("/") ? "" : "/") + fileName,
|
||||
fileName,
|
||||
entry.getAttrs().getSize(),
|
||||
fileTime
|
||||
));
|
||||
}
|
||||
|
||||
// 최신 파일 순 정렬
|
||||
files.sort((a, b) -> b.modifiedAt().compareTo(a.modifiedAt()));
|
||||
|
||||
log.info("Found {} files matching pattern '{}' in path '{}' on server {}",
|
||||
files.size(), filePattern, path, server.getId());
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to list files from server {}: {}", server.getId(), e.getMessage());
|
||||
throw new RuntimeException("파일 목록 조회 실패: " + e.getMessage(), e);
|
||||
} finally {
|
||||
disconnect(channel, session);
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그 파일 목록 조회 (since 이후 수정된 파일만)
|
||||
*/
|
||||
public List<RemoteFile> listLogFiles(Server server, ServerLogPath logPath, LocalDateTime since) {
|
||||
List<RemoteFile> allFiles = listAllFiles(server, logPath);
|
||||
|
||||
if (since == null) {
|
||||
return allFiles;
|
||||
}
|
||||
|
||||
return allFiles.stream()
|
||||
.filter(f -> f.modifiedAt().isAfter(since))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 다운로드 (내용 반환)
|
||||
*/
|
||||
public String downloadFileContent(Server server, String remotePath) {
|
||||
Session session = null;
|
||||
ChannelSftp channel = null;
|
||||
|
||||
try {
|
||||
session = createSession(server);
|
||||
session.connect(CONNECT_TIMEOUT);
|
||||
|
||||
channel = (ChannelSftp) session.openChannel("sftp");
|
||||
channel.connect(CHANNEL_TIMEOUT);
|
||||
|
||||
try (InputStream is = channel.get(remotePath);
|
||||
BufferedReader reader = new BufferedReader(new InputStreamReader(is))) {
|
||||
|
||||
StringBuilder content = new StringBuilder();
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
content.append(line).append("\n");
|
||||
}
|
||||
return content.toString();
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to download file {} from server {}: {}",
|
||||
remotePath, server.getId(), e.getMessage());
|
||||
throw new RuntimeException("파일 다운로드 실패: " + e.getMessage(), e);
|
||||
} finally {
|
||||
disconnect(channel, session);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 다운로드 (로컬 파일로)
|
||||
*/
|
||||
public Path downloadFileToLocal(Server server, String remotePath, Path localDir) {
|
||||
Session session = null;
|
||||
ChannelSftp channel = null;
|
||||
|
||||
try {
|
||||
session = createSession(server);
|
||||
session.connect(CONNECT_TIMEOUT);
|
||||
|
||||
channel = (ChannelSftp) session.openChannel("sftp");
|
||||
channel.connect(CHANNEL_TIMEOUT);
|
||||
|
||||
String fileName = remotePath.substring(remotePath.lastIndexOf('/') + 1);
|
||||
Path localPath = localDir.resolve(fileName);
|
||||
|
||||
Files.createDirectories(localDir);
|
||||
|
||||
try (OutputStream os = Files.newOutputStream(localPath)) {
|
||||
channel.get(remotePath, os);
|
||||
}
|
||||
|
||||
return localPath;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to download file {} from server {}: {}",
|
||||
remotePath, server.getId(), e.getMessage());
|
||||
throw new RuntimeException("파일 다운로드 실패: " + e.getMessage(), e);
|
||||
} finally {
|
||||
disconnect(channel, session);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 세션 생성
|
||||
*/
|
||||
private Session createSession(Server server) throws JSchException {
|
||||
JSch jsch = new JSch();
|
||||
|
||||
if ("KEY_FILE".equals(server.getAuthType())) {
|
||||
String passphrase = cryptoUtil.decrypt(server.getEncryptedPassphrase());
|
||||
if (passphrase != null && !passphrase.isEmpty()) {
|
||||
jsch.addIdentity(server.getKeyFilePath(), passphrase);
|
||||
} else {
|
||||
jsch.addIdentity(server.getKeyFilePath());
|
||||
}
|
||||
}
|
||||
|
||||
Session session = jsch.getSession(server.getUsername(), server.getHost(), server.getPort());
|
||||
|
||||
if ("PASSWORD".equals(server.getAuthType())) {
|
||||
String password = cryptoUtil.decrypt(server.getEncryptedPassword());
|
||||
session.setPassword(password);
|
||||
}
|
||||
|
||||
// StrictHostKeyChecking 비활성화 (실제 운영에서는 known_hosts 사용 권장)
|
||||
Properties config = new Properties();
|
||||
config.put("StrictHostKeyChecking", "no");
|
||||
session.setConfig(config);
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* 연결 해제
|
||||
*/
|
||||
private void disconnect(ChannelSftp channel, Session session) {
|
||||
if (channel != null && channel.isConnected()) {
|
||||
channel.disconnect();
|
||||
}
|
||||
if (session != null && session.isConnected()) {
|
||||
session.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Glob 패턴을 정규식으로 변환
|
||||
*/
|
||||
private Pattern convertGlobToRegex(String glob) {
|
||||
StringBuilder regex = new StringBuilder("^");
|
||||
for (char c : glob.toCharArray()) {
|
||||
switch (c) {
|
||||
case '*': regex.append(".*"); break;
|
||||
case '?': regex.append("."); break;
|
||||
case '.': regex.append("\\."); break;
|
||||
default: regex.append(c);
|
||||
}
|
||||
}
|
||||
regex.append("$");
|
||||
return Pattern.compile(regex.toString());
|
||||
}
|
||||
|
||||
// DTOs
|
||||
public record ConnectionTestResult(boolean success, String message, String error) {}
|
||||
|
||||
public record TimeCheckResult(
|
||||
boolean success,
|
||||
LocalDateTime serverTime,
|
||||
LocalDateTime localTime,
|
||||
int offsetMinutes,
|
||||
String error
|
||||
) {}
|
||||
|
||||
public record RemoteFile(String path, String name, long size, LocalDateTime modifiedAt) {}
|
||||
}
|
||||
Reference in New Issue
Block a user