Files
log-hunter/src/main/java/research/loghunter/service/SftpService.java
2026-01-06 21:44:36 +09:00

404 lines
16 KiB
Java

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) {}
}