Skip to content
Snippets Groups Projects
Commit 0b97807c authored by nahyun's avatar nahyun
Browse files

feat: lego

parent fee78971
No related branches found
No related tags found
1 merge request!15Feat/certificate
package com.aolda.itda.controller.certificate; package com.aolda.itda.controller.certificate;
import com.aolda.itda.dto.PageResp;
import com.aolda.itda.dto.certificate.CertificateDTO; import com.aolda.itda.dto.certificate.CertificateDTO;
import com.aolda.itda.service.certificate.CertificateService; import com.aolda.itda.service.certificate.CertificateService;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
...@@ -19,17 +20,17 @@ public class CertificateController { ...@@ -19,17 +20,17 @@ public class CertificateController {
/** /**
* 인증서 생성 * 인증서 생성
* POST /api/certificate?projectId=xxx * POST /api/certificate?projectId=xxx
* Body: CertificateDTO
*/ */
@PostMapping("/certificate") @PostMapping("/certificate")
public ResponseEntity<Object> create(@RequestParam String projectId, public ResponseEntity<Void> create(
@RequestParam String projectId,
@RequestBody CertificateDTO dto, @RequestBody CertificateDTO dto,
HttpServletRequest request) { HttpServletRequest request
System.out.println("1"); ) {
certificateService.createCertificate( certificateService.createCertificate(
projectId, projectId,
dto, dto,
(List<String>) request.getAttribute("projects") // Forwarding과 동일하게 (List<String>) request.getAttribute("projects")
); );
return ResponseEntity.ok().build(); return ResponseEntity.ok().build();
} }
...@@ -39,8 +40,10 @@ public class CertificateController { ...@@ -39,8 +40,10 @@ public class CertificateController {
* GET /api/certificate?certificateId=xxx * GET /api/certificate?certificateId=xxx
*/ */
@GetMapping("/certificate") @GetMapping("/certificate")
public ResponseEntity<Object> view(@RequestParam Long certificateId, public ResponseEntity<CertificateDTO> view(
HttpServletRequest request) { @RequestParam Long certificateId,
HttpServletRequest request
) {
return ResponseEntity.ok( return ResponseEntity.ok(
certificateService.getCertificate( certificateService.getCertificate(
certificateId, certificateId,
...@@ -50,25 +53,29 @@ public class CertificateController { ...@@ -50,25 +53,29 @@ public class CertificateController {
} }
/** /**
* 인증서 목록 조회 * 인증서 목록 조회 (domain 필터링 optional)
* GET /api/certificates?projectId=xxx * GET /api/certificates?projectId=xxx&domain=foo
*/ */
@GetMapping("/certificates") @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( return ResponseEntity.ok(
certificateService.getCertificates(projectId) certificateService.getCertificates(projectId, domain)
); );
} }
/** /**
* 인증서 수정 * 인증서 수정
* PATCH /api/certificate?certificateId=xxx * PATCH /api/certificate?certificateId=xxx
* Body: CertificateDTO
*/ */
@PatchMapping("/certificate") @PatchMapping("/certificate")
public ResponseEntity<Object> edit(@RequestParam Long certificateId, public ResponseEntity<Void> edit(
@RequestParam Long certificateId,
@RequestBody CertificateDTO dto, @RequestBody CertificateDTO dto,
HttpServletRequest request) { HttpServletRequest request
) {
certificateService.editCertificate( certificateService.editCertificate(
certificateId, certificateId,
dto, dto,
...@@ -82,14 +89,14 @@ public class CertificateController { ...@@ -82,14 +89,14 @@ public class CertificateController {
* DELETE /api/certificate?certificateId=xxx * DELETE /api/certificate?certificateId=xxx
*/ */
@DeleteMapping("/certificate") @DeleteMapping("/certificate")
public ResponseEntity<Object> delete(@RequestParam Long certificateId, public ResponseEntity<Void> delete(
HttpServletRequest request) { @RequestParam Long certificateId,
HttpServletRequest request
) {
certificateService.deleteCertificate( certificateService.deleteCertificate(
certificateId, certificateId,
(List<String>) request.getAttribute("projects") (List<String>) request.getAttribute("projects")
); );
return ResponseEntity.ok().build(); return ResponseEntity.ok().build();
} }
/*인증서 검색*/
} }
...@@ -14,14 +14,17 @@ import java.time.LocalDateTime; ...@@ -14,14 +14,17 @@ import java.time.LocalDateTime;
public class CertificateDTO { public class CertificateDTO {
private Long certificateId; // 인증서 고유 ID private Long certificateId; // 인증서 고유 ID
private String projectId; // 프로젝트 식별자 // private String projectId; // 프로젝트 식별자
private String domain; // SSL 인증받을 도메인 주소 private String domain; // SSL 인증받을 도메인 주소
private String email; // 도메인 소유자의 이메일 private String email; // 도메인 소유자의 이메일
private LocalDateTime expiredAt; // 인증서 만료일 private LocalDateTime expiredAt; // 인증서 만료일
private LocalDateTime createdAt; // 인증서 생성일
private LocalDateTime updatedAt; // 인증서 업데이트일
private Challenge challenge; // 챌린지 방식 (HTTP, DNS_CLOUDFLARE) private Challenge challenge; // 챌린지 방식 (HTTP, DNS_CLOUDFLARE)
private Boolean isDeleted; // 삭제 여부 (soft delete) private Boolean isDeleted; // 삭제 여부 (soft delete)
private String description; // 설명 (필요시 자유롭게 작성) private String apiToken;
} }
/* 이메일, 챌린지 방식, http인지 dns인지... "*/ /* 이메일, 챌린지 방식, http인지 dns인지... "*/
//도메인, 소유자 이메일, 챌린지 방식 확실하게 들어가야함!! //도메인, 소유자 이메일, 챌린지 방식 확실하게 들어가야함!!
/*erd 보고 만들기*/ /*erd 보고 만들기*/
//Challenge 키는 따로 (private으로 api 키 받기)
\ No newline at end of file
...@@ -32,15 +32,15 @@ public class Certificate extends BaseTimeEntity { ...@@ -32,15 +32,15 @@ public class Certificate extends BaseTimeEntity {
@Column(length = 64) @Column(length = 64)
private String email; private String email;
private LocalDateTime expiredAt; private LocalDateTime expiredAt; //인증서 만료일
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
private Challenge challenge; private Challenge challenge;
private Boolean isDeleted; private Boolean isDeleted;
@Column(length = 256)
private String description;
public String formatDomain() { public String formatDomain() {
return domain == null ? null : domain.replace("*", "_"); return domain == null ? null : domain.replace("*", "_");
...@@ -52,7 +52,17 @@ public class Certificate extends BaseTimeEntity { ...@@ -52,7 +52,17 @@ public class Certificate extends BaseTimeEntity {
public void setDomain(String domain) { 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) {
} }
} }
...@@ -4,6 +4,7 @@ import com.aolda.itda.entity.certificate.Certificate; ...@@ -4,6 +4,7 @@ import com.aolda.itda.entity.certificate.Certificate;
import com.aolda.itda.entity.forwarding.Forwarding; import com.aolda.itda.entity.forwarding.Forwarding;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import java.time.LocalDateTime;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
...@@ -15,4 +16,14 @@ public interface CertificateRepository extends JpaRepository<Certificate, Long> ...@@ -15,4 +16,14 @@ public interface CertificateRepository extends JpaRepository<Certificate, Long>
// 프로젝트별 목록 조회 (Soft Delete 고려) // 프로젝트별 목록 조회 (Soft Delete 고려)
List<Certificate> findByProjectIdAndIsDeleted(String projectId, Boolean isDeleted); 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);
} }
...@@ -12,9 +12,14 @@ import jakarta.validation.ConstraintViolation; ...@@ -12,9 +12,14 @@ import jakarta.validation.ConstraintViolation;
import jakarta.validation.Validation; import jakarta.validation.Validation;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; 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.ArrayList;
import java.util.List; import java.util.List;
...@@ -27,193 +32,190 @@ public class CertificateService { ...@@ -27,193 +32,190 @@ public class CertificateService {
private final CertificateRepository certificateRepository; private final CertificateRepository certificateRepository;
private final AuthService authService; private final AuthService authService;
/* 인증서 하나 조회 */ /** 1) 단건 조회 **/
public CertificateDTO getCertificate(Long certificateId, List<String> projects) { public CertificateDTO getCertificate(Long certificateId, List<String> projects) {
Certificate certificate = certificateRepository Certificate cert = certificateRepository
.findByCertificateIdAndIsDeleted(certificateId, false) .findByCertificateIdAndIsDeleted(certificateId, false)
.orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND_FORWARDING)); .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND_FORWARDING));
// 여기서는 ErrorCode.NOT_FOUND_CERTIFICATE 등 새로운 코드로 교체 가능 authService.validateProjectAuth(projects, cert.getProjectId());
return toDTO(cert);
// 프로젝트 권한 검증
authService.validateProjectAuth(projects, certificate.getProjectId());
return toDTO(certificate);
} }
/* 인증서 목록 조회 */ /** 1) 목록 조회 (domain 필터 optional) **/
public PageResp<CertificateDTO> getCertificates(String projectId) { public PageResp<CertificateDTO> getCertificates(String projectId, String domain) {
List<CertificateDTO> list = certificateRepository List<Certificate> list;
.findByProjectIdAndIsDeleted(projectId, false) if (domain != null && !domain.isBlank()) {
.stream() list = certificateRepository
.findByProjectIdAndDomainContainingIgnoreCaseAndIsDeleted(
projectId, domain, false);
} else {
list = certificateRepository
.findByProjectIdAndIsDeleted(projectId, false);
}
List<CertificateDTO> dtos = list.stream()
.map(this::toDTO) .map(this::toDTO)
.toList(); .toList();
return PageResp.<CertificateDTO>builder() return PageResp.<CertificateDTO>builder()
.contents(list) .contents(dtos)
.build(); .build();
} }
/* 인증서 생성 */ /** 2) 생성: expiredAt 자동 90일 설정 + 로깅 **/
/*public CertificateDTO createCertificate(String projectId, public CertificateDTO createCertificate(String projectId,
CertificateDTO dto, CertificateDTO dto,
List<String> projects) { List<String> projects) {
// 프로젝트 권한 검증 log.info("createCertificate start (project={})", projectId);
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) 권한 체크
authService.validateProjectAuth(projects, projectId); authService.validateProjectAuth(projects, projectId);
// 2) DTO 검증
validateDTO(dto); validateDTO(dto);
// 3) lego 명령어 구성 // 발급
ProcessBuilder pb = buildLegoProcess(dto); executeLego(dto);
log.info("certificate issued for domain={}", dto.getDomain());
// 4) lego 실행 // 엔티티 저장 (expiredAt 기본 90일 뒤)
int exitCode; Certificate cert = Certificate.builder()
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()
.projectId(projectId) .projectId(projectId)
.domain(dto.getDomain()) .domain(dto.getDomain())
.email(dto.getEmail()) .email(dto.getEmail())
.challenge(dto.getChallenge()) .challenge(dto.getChallenge())
.expiredAt(dto.getExpiredAt()) // 필요 시 lego 출력 파싱 .expiredAt(LocalDateTime.now().plusDays(90))
.isDeleted(false) .isDeleted(false)
.description(dto.getDescription()) .apiToken(dto.getApiToken())
.build(); .build();
certificateRepository.save(cert);
log.info("certificate saved (id={}, domain={})",
cert.getCertificateId(), cert.getDomain());
certificateRepository.save(certificate); return toDTO(cert);
return toDTO(certificate);
} }
/* 인증서 수정 */ /** 4) 수정 **/
public CertificateDTO editCertificate(Long certificateId, CertificateDTO dto, List<String> projects) { public CertificateDTO editCertificate(Long certificateId,
Certificate certificate = certificateRepository CertificateDTO dto,
List<String> projects) {
Certificate cert = certificateRepository
.findByCertificateIdAndIsDeleted(certificateId, false) .findByCertificateIdAndIsDeleted(certificateId, false)
.orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND_FORWARDING)); .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND_FORWARDING));
authService.validateProjectAuth(projects, cert.getProjectId());
// 프로젝트 권한 검증 if (dto.getDomain() != null) cert.setDomain(dto.getDomain());
authService.validateProjectAuth(projects, certificate.getProjectId()); if (dto.getEmail() != null) cert.setEmail(dto.getEmail());
// 필요한 필드만 수정 certificateRepository.save(cert);
if (dto.getDomain() != null) { log.info("certificate edited (id={}, domain={})",
certificate.setDomain(dto.getDomain()); cert.getCertificateId(), cert.getDomain());
return toDTO(cert);
} }
if (dto.getDescription() != null) {
certificate.setDescription(dto.getDescription());
}
// 기타 수정할 필드가 있다면 추가
// DB 저장
certificateRepository.save(certificate);
// 수정 로직(certbot 재발급 등) 필요 시 추가
return toDTO(certificate); /** 4) 삭제 + 로깅 **/
}
/* 인증서 삭제 (soft delete) */
public void deleteCertificate(Long certificateId, List<String> projects) { public void deleteCertificate(Long certificateId, List<String> projects) {
Certificate certificate = certificateRepository Certificate cert = certificateRepository
.findByCertificateIdAndIsDeleted(certificateId, false) .findByCertificateIdAndIsDeleted(certificateId, false)
.orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND_FORWARDING)); .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND_FORWARDING));
authService.validateProjectAuth(projects, cert.getProjectId());
// 권한 검증 cert.setIsDeleted(true);
authService.validateProjectAuth(projects, certificate.getProjectId()); certificateRepository.save(cert);
log.info("certificate deleted (id={}, domain={})",
cert.getCertificateId(), cert.getDomain());
}
// soft delete /** 3) 만료 30일 전 자동 갱신 배치 **/
certificate.setIsDeleted(true); @Scheduled(cron = "0 0 3 * * *", zone = "Asia/Seoul")
certificateRepository.save(certificate); public void renewExpiringCertificates() {
LocalDateTime threshold = LocalDateTime.now().plusDays(30);
List<Certificate> expiring = certificateRepository
.findByExpiredAtBeforeAndIsDeleted(threshold, false);
// (추가) 파일 제거 / certbot revoke 등 로직 필요 시 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) { private void validateDTO(CertificateDTO dto) {
for (ConstraintViolation<CertificateDTO> violation for (ConstraintViolation<CertificateDTO> v :
: Validation.buildDefaultValidatorFactory().getValidator().validate(dto)) { Validation.buildDefaultValidatorFactory()
throw new CustomException(ErrorCode.INVALID_CONF_INPUT, violation.getMessage()); .getValidator().validate(dto)) {
throw new CustomException(ErrorCode.INVALID_CONF_INPUT, v.getMessage());
} }
} }
/* Entity -> DTO 변환 */ /** EntityDTO 변환 **/
private CertificateDTO toDTO(Certificate certificate) { private CertificateDTO toDTO(Certificate cert) {
return CertificateDTO.builder() return CertificateDTO.builder()
.certificateId(certificate.getCertificateId()) .certificateId(cert.getCertificateId())
.projectId(certificate.getProjectId())
.domain(certificate.getDomain()) .domain(cert.getDomain())
.description(certificate.getDescription()) .email(cert.getEmail())
.isDeleted(certificate.getIsDeleted()) .challenge(cert.getChallenge())
.expiredAt(certificate.getExpiredAt()) .expiredAt(cert.getExpiredAt())
.createdAt(cert.getCreatedAt())
.updatedAt(cert.getUpdatedAt())
.isDeleted(cert.getIsDeleted())
.apiToken(cert.getApiToken())
.build(); .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<>(); List<String> cmd = new ArrayList<>();
cmd.add("/usr/local/bin/lego"); cmd.add("/usr/local/bin/lego");
cmd.add("--accept-tos"); cmd.add("--accept-tos");
cmd.add("--email"); cmd.add(dto.getEmail()); cmd.add("--email"); cmd.add(dto.getEmail());
cmd.add("--path"); cmd.add(basePath); cmd.add("--path"); cmd.add("/data/lego");
if (dto.getChallenge() == Challenge.HTTP) { if (dto.getChallenge() == Challenge.HTTP) {
cmd.add("--http"); cmd.add("--http");
cmd.add("--http.webroot"); cmd.add("--http.webroot"); cmd.add("/data/letsencrypt-acme-challenge");
cmd.add("/data/letsencrypt-acme-challenge"); } else {
} else if (dto.getChallenge() == Challenge.DNS_CLOUDFLARE) { cmd.add("--dns"); cmd.add("cloudflare");
cmd.add("--dns");
cmd.add("cloudflare");
// CLOUDFLARE_API_TOKEN 환경변수를 컨테이너에 세팅했다고 가정
} }
cmd.add("--domains"); cmd.add(dto.getDomain()); cmd.add("--domains"); cmd.add(dto.getDomain());
cmd.add("run"); // 최초 발급(run) / renew(갱신) cmd.add("run");
return new ProcessBuilder(cmd) log.info("executing lego: {}", String.join(" ", cmd));
ProcessBuilder pb = new ProcessBuilder(cmd)
.redirectErrorStream(true); .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
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment