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 listAllFiles(Server server, ServerLogPath logPath) { List 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 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 listLogFiles(Server server, ServerLogPath logPath, LocalDateTime since) { List 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) {} }