diff --git a/src/main/java/com/aolda/itda/controller/certificate/CertificateController.java b/src/main/java/com/aolda/itda/controller/certificate/CertificateController.java index cf5e160f14b9e873f554e32d18c6fa9b0f7d4096..d08968bd39f0076c4ffcaf6d3881b792020f8c7c 100644 --- a/src/main/java/com/aolda/itda/controller/certificate/CertificateController.java +++ b/src/main/java/com/aolda/itda/controller/certificate/CertificateController.java @@ -1,5 +1,6 @@ package com.aolda.itda.controller.certificate; +import com.aolda.itda.dto.PageResp; import com.aolda.itda.dto.certificate.CertificateDTO; import com.aolda.itda.service.certificate.CertificateService; import jakarta.servlet.http.HttpServletRequest; @@ -19,17 +20,17 @@ public class CertificateController { /** * 인증서 생성 * POST /api/certificate?projectId=xxx - * Body: CertificateDTO */ @PostMapping("/certificate") - public ResponseEntity<Object> create(@RequestParam String projectId, - @RequestBody CertificateDTO dto, - HttpServletRequest request) { - System.out.println("1"); + public ResponseEntity<Void> create( + @RequestParam String projectId, + @RequestBody CertificateDTO dto, + HttpServletRequest request + ) { certificateService.createCertificate( projectId, dto, - (List<String>) request.getAttribute("projects") // Forwarding과 동일하게 + (List<String>) request.getAttribute("projects") ); return ResponseEntity.ok().build(); } @@ -39,8 +40,10 @@ public class CertificateController { * GET /api/certificate?certificateId=xxx */ @GetMapping("/certificate") - public ResponseEntity<Object> view(@RequestParam Long certificateId, - HttpServletRequest request) { + public ResponseEntity<CertificateDTO> view( + @RequestParam Long certificateId, + HttpServletRequest request + ) { return ResponseEntity.ok( certificateService.getCertificate( certificateId, @@ -50,25 +53,29 @@ public class CertificateController { } /** - * 인증서 목록 조회 - * GET /api/certificates?projectId=xxx + * 인증서 목록 조회 (domain 필터링 optional) + * GET /api/certificates?projectId=xxx&domain=foo */ @GetMapping("/certificates") - public ResponseEntity<Object> lists(@RequestParam String projectId) { + public ResponseEntity<PageResp<CertificateDTO>> lists( + @RequestParam String projectId, + @RequestParam(required = false) String domain + ) { return ResponseEntity.ok( - certificateService.getCertificates(projectId) + certificateService.getCertificates(projectId, domain) ); } /** * 인증서 수정 * PATCH /api/certificate?certificateId=xxx - * Body: CertificateDTO */ @PatchMapping("/certificate") - public ResponseEntity<Object> edit(@RequestParam Long certificateId, - @RequestBody CertificateDTO dto, - HttpServletRequest request) { + public ResponseEntity<Void> edit( + @RequestParam Long certificateId, + @RequestBody CertificateDTO dto, + HttpServletRequest request + ) { certificateService.editCertificate( certificateId, dto, @@ -82,14 +89,14 @@ public class CertificateController { * DELETE /api/certificate?certificateId=xxx */ @DeleteMapping("/certificate") - public ResponseEntity<Object> delete(@RequestParam Long certificateId, - HttpServletRequest request) { + public ResponseEntity<Void> delete( + @RequestParam Long certificateId, + HttpServletRequest request + ) { certificateService.deleteCertificate( certificateId, (List<String>) request.getAttribute("projects") ); return ResponseEntity.ok().build(); } - /*인증서 검색*/ - } diff --git a/src/main/java/com/aolda/itda/dto/certificate/CertificateDTO.java b/src/main/java/com/aolda/itda/dto/certificate/CertificateDTO.java index e37b75e531e2cdcd0487a9db4df45042368e0010..dcdefcf7841391251062aa0397babca938d40b4d 100644 --- a/src/main/java/com/aolda/itda/dto/certificate/CertificateDTO.java +++ b/src/main/java/com/aolda/itda/dto/certificate/CertificateDTO.java @@ -14,14 +14,17 @@ import java.time.LocalDateTime; public class CertificateDTO { private Long certificateId; // 인증서 고유 ID - private String projectId; // 프로젝트 식별자 +// private String projectId; // 프로젝트 식별자 private String domain; // SSL 인증받을 도메인 주소 private String email; // 도메인 소유자의 이메일 private LocalDateTime expiredAt; // 인증서 만료일 + private LocalDateTime createdAt; // 인증서 생성일 + private LocalDateTime updatedAt; // 인증서 업데이트일 private Challenge challenge; // 챌린지 방식 (HTTP, DNS_CLOUDFLARE) private Boolean isDeleted; // 삭제 여부 (soft delete) - private String description; // 설명 (필요시 자유롭게 작성) + private String apiToken; } /* 이메일, 챌린지 방식, http인지 dns인지... "*/ //도메인, 소유자 이메일, 챌린지 방식 확실하게 들어가야함!! /*erd 보고 만들기*/ +//Challenge 키는 따로 (private으로 api 키 받기) \ No newline at end of file diff --git a/src/main/java/com/aolda/itda/entity/certificate/Certificate.java b/src/main/java/com/aolda/itda/entity/certificate/Certificate.java index 489138ff8f7af05c064e395f3178e71afbc4352b..6f52656623f073abf365f356a9e795f8f9a7ff6c 100644 --- a/src/main/java/com/aolda/itda/entity/certificate/Certificate.java +++ b/src/main/java/com/aolda/itda/entity/certificate/Certificate.java @@ -32,15 +32,15 @@ public class Certificate extends BaseTimeEntity { @Column(length = 64) private String email; - private LocalDateTime expiredAt; + private LocalDateTime expiredAt; //인증서 만료일 + private LocalDateTime createdAt; + private LocalDateTime updatedAt; @Enumerated(EnumType.STRING) private Challenge challenge; private Boolean isDeleted; - @Column(length = 256) - private String description; public String formatDomain() { return domain == null ? null : domain.replace("*", "_"); @@ -52,7 +52,17 @@ public class Certificate extends BaseTimeEntity { public void setDomain(String domain) { } - public void setDescription(String description) { + // public void setDescription(String description) { +// +// } + @Transient + private String apiToken; + public void setExpiredAt(LocalDateTime localDateTime) { + + } + + public void setEmail(String email) { } } + diff --git a/src/main/java/com/aolda/itda/repository/certificate/CertificateRepository.java b/src/main/java/com/aolda/itda/repository/certificate/CertificateRepository.java index 1ce5c1fa17c006138df409444d9476bdd8a9b3fa..b8cc1489734eb787564f6bd7677ea34e7a4161e9 100644 --- a/src/main/java/com/aolda/itda/repository/certificate/CertificateRepository.java +++ b/src/main/java/com/aolda/itda/repository/certificate/CertificateRepository.java @@ -4,6 +4,7 @@ import com.aolda.itda.entity.certificate.Certificate; import com.aolda.itda.entity.forwarding.Forwarding; import org.springframework.data.jpa.repository.JpaRepository; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; @@ -15,4 +16,14 @@ public interface CertificateRepository extends JpaRepository<Certificate, Long> // 프로젝트별 목록 조회 (Soft Delete 고려) List<Certificate> findByProjectIdAndIsDeleted(String projectId, Boolean isDeleted); + // 만료일이 주어진 날짜 이전인(=만료 30일 이내) 인증서 조회 + //List<Certificate> findByExpiredAtBeforeAndIsDeleted(LocalDateTime date, Boolean isDeleted); + + // 1) domain 필터링용 메서드 + List<Certificate> findByProjectIdAndDomainContainingIgnoreCaseAndIsDeleted( + String projectId, String domain, Boolean isDeleted); + + // 3) 만료 30일 이내 대상 조회 + List<Certificate> findByExpiredAtBeforeAndIsDeleted( + LocalDateTime date, Boolean isDeleted); } diff --git a/src/main/java/com/aolda/itda/service/certificate/CertificateService.java b/src/main/java/com/aolda/itda/service/certificate/CertificateService.java index f6f83bbcf832ef929fd52709477ef1976657f1fd..92d4c835b9f93f38506230bd2cd1a89a4662e98f 100644 --- a/src/main/java/com/aolda/itda/service/certificate/CertificateService.java +++ b/src/main/java/com/aolda/itda/service/certificate/CertificateService.java @@ -12,9 +12,14 @@ import jakarta.validation.ConstraintViolation; import jakarta.validation.Validation; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; @@ -27,193 +32,190 @@ public class CertificateService { private final CertificateRepository certificateRepository; private final AuthService authService; - /* 인증서 하나 조회 */ + /** 1) 단건 조회 **/ public CertificateDTO getCertificate(Long certificateId, List<String> projects) { - Certificate certificate = certificateRepository + Certificate cert = certificateRepository .findByCertificateIdAndIsDeleted(certificateId, false) .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND_FORWARDING)); - // 여기서는 ErrorCode.NOT_FOUND_CERTIFICATE 등 새로운 코드로 교체 가능 - - // 프로젝트 권한 검증 - authService.validateProjectAuth(projects, certificate.getProjectId()); - - return toDTO(certificate); + authService.validateProjectAuth(projects, cert.getProjectId()); + return toDTO(cert); } - /* 인증서 목록 조회 */ - public PageResp<CertificateDTO> getCertificates(String projectId) { - List<CertificateDTO> list = certificateRepository - .findByProjectIdAndIsDeleted(projectId, false) - .stream() + /** 1) 목록 조회 (domain 필터 optional) **/ + public PageResp<CertificateDTO> getCertificates(String projectId, String domain) { + List<Certificate> list; + if (domain != null && !domain.isBlank()) { + list = certificateRepository + .findByProjectIdAndDomainContainingIgnoreCaseAndIsDeleted( + projectId, domain, false); + } else { + list = certificateRepository + .findByProjectIdAndIsDeleted(projectId, false); + } + List<CertificateDTO> dtos = list.stream() .map(this::toDTO) .toList(); - return PageResp.<CertificateDTO>builder() - .contents(list) + .contents(dtos) .build(); } - /* 인증서 생성 */ - /*public CertificateDTO createCertificate(String projectId, + /** 2) 생성: expiredAt 자동 90일 설정 + 로깅 **/ + public CertificateDTO createCertificate(String projectId, CertificateDTO dto, List<String> projects) { - // 프로젝트 권한 검증 - authService.validateProjectAuth(projects, projectId); - System.out.println("2"); - // DTO 유효성 검사 - validateDTO(dto); - - Certificate certificate = Certificate.builder() - .projectId(projectId) - .domain(dto.getDomain()) - .description(dto.getDescription()) - .isDeleted(false) - .build(); - - certificateRepository.save(certificate); - System.out.println("3"); - // 생성 로직 (certbot 호출 등) 필요 시 추가 - - return toDTO(certificate); - }*/ - /* 인증서 생성 + lego 호출 */ - public CertificateDTO createCertificate(String projectId, CertificateDTO dto, List<String> projects) { - - // 1) 권한 체크 + log.info("createCertificate start (project={})", projectId); authService.validateProjectAuth(projects, projectId); - - // 2) DTO 검증 validateDTO(dto); - // 3) lego 명령어 구성 - ProcessBuilder pb = buildLegoProcess(dto); + // 발급 + executeLego(dto); + log.info("certificate issued for domain={}", dto.getDomain()); - // 4) lego 실행 - int exitCode; - try { - Process process = pb.start(); - exitCode = process.waitFor(); - if (exitCode != 0) { - String err = new String(process.getErrorStream().readAllBytes()); - log.error("[lego-error] {}", err); - throw new CustomException(ErrorCode.FAIL_CREATE_CONF, - "lego 오류: " + err); - } - } catch (Exception e) { - log.error("[lego-exec] {}", e.getMessage()); - throw new CustomException(ErrorCode.FAIL_CREATE_CONF, - "lego 실행 실패"); - } - - // 5) 엔티티 저장 - Certificate certificate = Certificate.builder() + // 엔티티 저장 (expiredAt 기본 90일 뒤) + Certificate cert = Certificate.builder() .projectId(projectId) .domain(dto.getDomain()) .email(dto.getEmail()) .challenge(dto.getChallenge()) - .expiredAt(dto.getExpiredAt()) // 필요 시 lego 출력 파싱 + .expiredAt(LocalDateTime.now().plusDays(90)) .isDeleted(false) - .description(dto.getDescription()) + .apiToken(dto.getApiToken()) .build(); + certificateRepository.save(cert); + log.info("certificate saved (id={}, domain={})", + cert.getCertificateId(), cert.getDomain()); - certificateRepository.save(certificate); - return toDTO(certificate); + return toDTO(cert); } - /* 인증서 수정 */ - public CertificateDTO editCertificate(Long certificateId, CertificateDTO dto, List<String> projects) { - Certificate certificate = certificateRepository + /** 4) 수정 **/ + public CertificateDTO editCertificate(Long certificateId, + CertificateDTO dto, + List<String> projects) { + Certificate cert = certificateRepository .findByCertificateIdAndIsDeleted(certificateId, false) .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND_FORWARDING)); + authService.validateProjectAuth(projects, cert.getProjectId()); - // 프로젝트 권한 검증 - authService.validateProjectAuth(projects, certificate.getProjectId()); - - // 필요한 필드만 수정 - if (dto.getDomain() != null) { - certificate.setDomain(dto.getDomain()); - } - if (dto.getDescription() != null) { - certificate.setDescription(dto.getDescription()); - } - // 기타 수정할 필드가 있다면 추가 - - // DB 저장 - certificateRepository.save(certificate); + if (dto.getDomain() != null) cert.setDomain(dto.getDomain()); + if (dto.getEmail() != null) cert.setEmail(dto.getEmail()); - // 수정 로직(certbot 재발급 등) 필요 시 추가 - - return toDTO(certificate); + certificateRepository.save(cert); + log.info("certificate edited (id={}, domain={})", + cert.getCertificateId(), cert.getDomain()); + return toDTO(cert); } - /* 인증서 삭제 (soft delete) */ + /** 4) 삭제 + 로깅 **/ public void deleteCertificate(Long certificateId, List<String> projects) { - Certificate certificate = certificateRepository + Certificate cert = certificateRepository .findByCertificateIdAndIsDeleted(certificateId, false) .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND_FORWARDING)); + authService.validateProjectAuth(projects, cert.getProjectId()); - // 권한 검증 - authService.validateProjectAuth(projects, certificate.getProjectId()); - - // soft delete - certificate.setIsDeleted(true); - certificateRepository.save(certificate); + cert.setIsDeleted(true); + certificateRepository.save(cert); + log.info("certificate deleted (id={}, domain={})", + cert.getCertificateId(), cert.getDomain()); + } - // (추가) 파일 제거 / certbot revoke 등 로직 필요 시 + /** 3) 만료 30일 전 자동 갱신 배치 **/ + @Scheduled(cron = "0 0 3 * * *", zone = "Asia/Seoul") + public void renewExpiringCertificates() { + LocalDateTime threshold = LocalDateTime.now().plusDays(30); + List<Certificate> expiring = certificateRepository + .findByExpiredAtBeforeAndIsDeleted(threshold, false); + + for (Certificate cert : expiring) { + try { + log.info("renewing (id={}, domain={})", cert.getCertificateId(), cert.getDomain()); + CertificateDTO dto = CertificateDTO.builder() + .domain(cert.getDomain()) + .email(cert.getEmail()) + .challenge(cert.getChallenge()) + .apiToken(cert.getApiToken()) + .build(); + executeLego(dto); + + cert.setExpiredAt(LocalDateTime.now().plusDays(90)); + certificateRepository.save(cert); + log.info("renewed (id={}, newExpiry={})", + cert.getCertificateId(), cert.getExpiredAt()); + } catch (Exception e) { + log.error("failed to renew (id={}, domain={}): {}", + cert.getCertificateId(), cert.getDomain(), e.getMessage()); + } + } } - /* DTO 유효성 검사 */ + /** DTO 유효성 검사 **/ private void validateDTO(CertificateDTO dto) { - for (ConstraintViolation<CertificateDTO> violation - : Validation.buildDefaultValidatorFactory().getValidator().validate(dto)) { - throw new CustomException(ErrorCode.INVALID_CONF_INPUT, violation.getMessage()); + for (ConstraintViolation<CertificateDTO> v : + Validation.buildDefaultValidatorFactory() + .getValidator().validate(dto)) { + throw new CustomException(ErrorCode.INVALID_CONF_INPUT, v.getMessage()); } } - /* Entity -> DTO 변환 */ - private CertificateDTO toDTO(Certificate certificate) { + /** Entity→DTO 변환 **/ + private CertificateDTO toDTO(Certificate cert) { return CertificateDTO.builder() - .certificateId(certificate.getCertificateId()) - .projectId(certificate.getProjectId()) - .domain(certificate.getDomain()) - .description(certificate.getDescription()) - .isDeleted(certificate.getIsDeleted()) - .expiredAt(certificate.getExpiredAt()) + .certificateId(cert.getCertificateId()) + + .domain(cert.getDomain()) + .email(cert.getEmail()) + .challenge(cert.getChallenge()) + .expiredAt(cert.getExpiredAt()) + .createdAt(cert.getCreatedAt()) + .updatedAt(cert.getUpdatedAt()) + .isDeleted(cert.getIsDeleted()) + .apiToken(cert.getApiToken()) .build(); } + /** 인증서 발급용 lego 실행 **/ + private void executeLego(CertificateDTO dto) { + if (dto.getChallenge() == Challenge.DNS_CLOUDFLARE && dto.getApiToken() == null) { + throw new CustomException(ErrorCode.INVALID_CONF_INPUT, + "DNS_CLOUDFLARE 챌린지는 apiToken 필요"); + } - - /* lego ProcessBuilder 생성 */ - private ProcessBuilder buildLegoProcess(CertificateDTO dto) { - - String basePath = "/data/lego"; // 인증서 저장 루트(볼륨) List<String> cmd = new ArrayList<>(); cmd.add("/usr/local/bin/lego"); cmd.add("--accept-tos"); - cmd.add("--email"); cmd.add(dto.getEmail()); - cmd.add("--path"); cmd.add(basePath); + cmd.add("--email"); cmd.add(dto.getEmail()); + cmd.add("--path"); cmd.add("/data/lego"); if (dto.getChallenge() == Challenge.HTTP) { cmd.add("--http"); - cmd.add("--http.webroot"); - cmd.add("/data/letsencrypt-acme-challenge"); - } else if (dto.getChallenge() == Challenge.DNS_CLOUDFLARE) { - cmd.add("--dns"); - cmd.add("cloudflare"); - // CLOUDFLARE_API_TOKEN 환경변수를 컨테이너에 세팅했다고 가정 + cmd.add("--http.webroot"); cmd.add("/data/letsencrypt-acme-challenge"); + } else { + cmd.add("--dns"); cmd.add("cloudflare"); } - cmd.add("--domains"); cmd.add(dto.getDomain()); - cmd.add("run"); // 최초 발급(run) / renew(갱신) + cmd.add("--domains"); cmd.add(dto.getDomain()); + cmd.add("run"); - return new ProcessBuilder(cmd) + log.info("executing lego: {}", String.join(" ", cmd)); + ProcessBuilder pb = new ProcessBuilder(cmd) .redirectErrorStream(true); - } -} - + pb.environment().put("CF_DNS_API_TOKEN", dto.getApiToken()); + try { + Process p = pb.start(); + try (BufferedReader r = new BufferedReader(new InputStreamReader(p.getInputStream()))) { + r.lines().forEach(line -> log.info("[lego] {}", line)); + } + if (p.waitFor() != 0) { + throw new CustomException(ErrorCode.FAIL_CREATE_CERT, + "lego exit code=" + p.exitValue()); + } + } catch (IOException | InterruptedException e) { + log.error("lego error", e); + throw new CustomException(ErrorCode.FAIL_CREATE_CERT, + "lego 실행 실패: " + e.getMessage()); + } + } -// 여기서 매소드를 create로 해서 lego --accept-tos --email "email@example.com" --http --http.webroot data/letsencrypt-acme-challenge --path /data/lego --domains www.example.com run -// 이거 이메일 . 도메인 으로 넣어서 실제 인증서 연동하기!!! -// 그리고 Dto에 관리자 이메일, 인증서 완료일, 챌린지 방식 추가하기 \ No newline at end of file +}