404 lines
16 KiB
Java
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) {}
|
|
}
|