feat: certificate management with stage/activate/restore lifecycle
All checks were successful
CI / build (push) Successful in 1m8s
CI / docker (push) Successful in 45s

Provider-based architecture (Docker now, K8s later):
- CertificateManager interface + DockerCertificateManager (file-based)
- Atomic swap via .wip files for safe cert replacement
- Stage -> Activate -> Archive lifecycle with one-deep rollback
- Bootstrap supports user-supplied certs via CERT_FILE/KEY_FILE/CA_FILE
- CA bundle aggregates platform + tenant CAs, distributed to containers
- Vendor UI: Certificates page with upload, activate, restore, discard
- Stale tenant tracking (ca_applied_at) with restart banner
- Conditional TLS skip removal when CA bundle exists

Includes design spec, migration V012, service + controller tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-10 18:29:02 +02:00
parent 51a1aef10e
commit 45bcc954ac
23 changed files with 2035 additions and 7 deletions

View File

@@ -0,0 +1,17 @@
package net.siegeln.cameleer.saas.certificate;
import java.util.List;
public record CertValidationResult(
boolean valid,
List<String> errors,
CertificateInfo info
) {
public static CertValidationResult ok(CertificateInfo info) {
return new CertValidationResult(true, List.of(), info);
}
public static CertValidationResult fail(List<String> errors) {
return new CertValidationResult(false, errors, null);
}
}

View File

@@ -0,0 +1,137 @@
package net.siegeln.cameleer.saas.certificate;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@RestController
@RequestMapping("/api/vendor/certificates")
@PreAuthorize("hasAuthority('SCOPE_platform:admin')")
public class CertificateController {
private final CertificateService certificateService;
public CertificateController(CertificateService certificateService) {
this.certificateService = certificateService;
}
// --- Response types ---
public record CertificateResponse(
UUID id, String status, String subject, String issuer,
Instant notBefore, Instant notAfter, String fingerprint,
boolean hasCa, boolean selfSigned, Instant activatedAt, Instant archivedAt
) {
public static CertificateResponse from(CertificateEntity e) {
if (e == null) return null;
return new CertificateResponse(
e.getId(), e.getStatus().name(), e.getSubject(), e.getIssuer(),
e.getNotBefore(), e.getNotAfter(), e.getFingerprint(),
e.isHasCa(), e.isSelfSigned(), e.getActivatedAt(), e.getArchivedAt()
);
}
}
public record OverviewResponse(
CertificateResponse active,
CertificateResponse staged,
CertificateResponse archived,
long staleTenantCount
) {}
public record StageResponse(
boolean valid,
List<String> errors,
CertificateResponse certificate
) {}
// --- Endpoints ---
@GetMapping
public ResponseEntity<OverviewResponse> getOverview() {
var overview = certificateService.getOverview();
long stale = certificateService.countStaleTenants();
return ResponseEntity.ok(new OverviewResponse(
CertificateResponse.from(overview.active()),
CertificateResponse.from(overview.staged()),
CertificateResponse.from(overview.archived()),
stale
));
}
@PostMapping("/stage")
public ResponseEntity<StageResponse> stage(
@RequestParam("cert") MultipartFile certFile,
@RequestParam("key") MultipartFile keyFile,
@RequestParam(value = "ca", required = false) MultipartFile caFile,
@AuthenticationPrincipal Jwt jwt) {
try {
byte[] certPem = certFile.getBytes();
byte[] keyPem = keyFile.getBytes();
byte[] caPem = caFile != null ? caFile.getBytes() : null;
UUID actorId = resolveActorId(jwt);
CertValidationResult result = certificateService.stage(certPem, keyPem, caPem, actorId);
if (!result.valid()) {
return ResponseEntity.badRequest().body(
new StageResponse(false, result.errors(), null));
}
var overview = certificateService.getOverview();
return ResponseEntity.ok(new StageResponse(
true, List.of(), CertificateResponse.from(overview.staged())));
} catch (Exception e) {
return ResponseEntity.badRequest().body(
new StageResponse(false, List.of(e.getMessage()), null));
}
}
@PostMapping("/activate")
public ResponseEntity<Void> activate() {
certificateService.activate();
return ResponseEntity.noContent().build();
}
@PostMapping("/restore")
public ResponseEntity<Void> restore() {
try {
certificateService.restore();
return ResponseEntity.noContent().build();
} catch (IllegalStateException e) {
return ResponseEntity.badRequest().body(null);
}
}
@DeleteMapping("/staged")
public ResponseEntity<Void> discardStaged() {
certificateService.discardStaged();
return ResponseEntity.noContent().build();
}
@GetMapping("/stale-tenants")
public ResponseEntity<Map<String, Long>> staleTenants() {
return ResponseEntity.ok(Map.of("count", certificateService.countStaleTenants()));
}
private UUID resolveActorId(Jwt jwt) {
try {
return UUID.fromString(jwt.getSubject());
} catch (Exception e) {
return UUID.nameUUIDFromBytes(jwt.getSubject().getBytes());
}
}
}

View File

@@ -0,0 +1,119 @@
package net.siegeln.cameleer.saas.certificate;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(name = "certificates")
public class CertificateEntity {
public enum Status { ACTIVE, STAGED, ARCHIVED }
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 10)
private Status status;
@Column(name = "subject", length = 500)
private String subject;
@Column(name = "issuer", length = 500)
private String issuer;
@Column(name = "not_before")
private Instant notBefore;
@Column(name = "not_after")
private Instant notAfter;
@Column(name = "fingerprint", length = 128)
private String fingerprint;
@Column(name = "has_ca", nullable = false)
private boolean hasCa;
@Column(name = "self_signed", nullable = false)
private boolean selfSigned;
@Column(name = "uploaded_by")
private UUID uploadedBy;
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;
@Column(name = "activated_at")
private Instant activatedAt;
@Column(name = "archived_at")
private Instant archivedAt;
@PrePersist
protected void onCreate() {
if (createdAt == null) createdAt = Instant.now();
}
// --- Getters and setters ---
public UUID getId() { return id; }
public Status getStatus() { return status; }
public void setStatus(Status status) { this.status = status; }
public String getSubject() { return subject; }
public void setSubject(String subject) { this.subject = subject; }
public String getIssuer() { return issuer; }
public void setIssuer(String issuer) { this.issuer = issuer; }
public Instant getNotBefore() { return notBefore; }
public void setNotBefore(Instant notBefore) { this.notBefore = notBefore; }
public Instant getNotAfter() { return notAfter; }
public void setNotAfter(Instant notAfter) { this.notAfter = notAfter; }
public String getFingerprint() { return fingerprint; }
public void setFingerprint(String fingerprint) { this.fingerprint = fingerprint; }
public boolean isHasCa() { return hasCa; }
public void setHasCa(boolean hasCa) { this.hasCa = hasCa; }
public boolean isSelfSigned() { return selfSigned; }
public void setSelfSigned(boolean selfSigned) { this.selfSigned = selfSigned; }
public UUID getUploadedBy() { return uploadedBy; }
public void setUploadedBy(UUID uploadedBy) { this.uploadedBy = uploadedBy; }
public Instant getCreatedAt() { return createdAt; }
public Instant getActivatedAt() { return activatedAt; }
public void setActivatedAt(Instant activatedAt) { this.activatedAt = activatedAt; }
public Instant getArchivedAt() { return archivedAt; }
public void setArchivedAt(Instant archivedAt) { this.archivedAt = archivedAt; }
public static CertificateEntity fromInfo(CertificateInfo info, Status status) {
var entity = new CertificateEntity();
entity.setStatus(status);
entity.setSubject(info.subject());
entity.setIssuer(info.issuer());
entity.setNotBefore(info.notBefore());
entity.setNotAfter(info.notAfter());
entity.setFingerprint(info.fingerprint());
entity.setHasCa(info.hasCaBundle());
entity.setSelfSigned(info.selfSigned());
return entity;
}
}

View File

@@ -0,0 +1,13 @@
package net.siegeln.cameleer.saas.certificate;
import java.time.Instant;
public record CertificateInfo(
String subject,
String issuer,
Instant notBefore,
Instant notAfter,
boolean hasCaBundle,
boolean selfSigned,
String fingerprint
) {}

View File

@@ -0,0 +1,41 @@
package net.siegeln.cameleer.saas.certificate;
/**
* Provider interface for certificate file management.
* Docker implementation writes to the certs volume.
* K8s implementation would manage TLS Secrets.
*/
public interface CertificateManager {
boolean isAvailable();
/** Read metadata of the active certificate from the provider storage. */
CertificateInfo getActive();
/** Read metadata of the staged certificate, or null. */
CertificateInfo getStaged();
/** Read metadata of the archived certificate, or null. */
CertificateInfo getArchived();
/**
* Write cert+key+ca to staging area and validate.
* Does NOT activate — call {@link #activate()} to promote.
*/
CertValidationResult stage(byte[] certPem, byte[] keyPem, byte[] caBundlePem);
/** Promote staged -> active. Moves current active to archive (deleting previous archive). */
void activate();
/** Swap archived <-> active. */
void restore();
/** Delete staged files. */
void discardStaged();
/** Generate a self-signed certificate for the given hostname and store as active. */
void generateSelfSigned(String hostname);
/** Read the current CA bundle bytes, or null if none exists. */
byte[] getCaBundle();
}

View File

@@ -0,0 +1,13 @@
package net.siegeln.cameleer.saas.certificate;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
import java.util.UUID;
public interface CertificateRepository extends JpaRepository<CertificateEntity, UUID> {
Optional<CertificateEntity> findByStatus(CertificateEntity.Status status);
void deleteByStatus(CertificateEntity.Status status);
}

View File

@@ -0,0 +1,154 @@
package net.siegeln.cameleer.saas.certificate;
import net.siegeln.cameleer.saas.tenant.TenantRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
@Service
public class CertificateService {
private static final Logger log = LoggerFactory.getLogger(CertificateService.class);
private final CertificateManager certManager;
private final CertificateRepository certRepository;
private final TenantRepository tenantRepository;
public CertificateService(CertificateManager certManager,
CertificateRepository certRepository,
TenantRepository tenantRepository) {
this.certManager = certManager;
this.certRepository = certRepository;
this.tenantRepository = tenantRepository;
}
public record CertificateOverview(
CertificateEntity active,
CertificateEntity staged,
CertificateEntity archived
) {}
public CertificateOverview getOverview() {
return new CertificateOverview(
certRepository.findByStatus(CertificateEntity.Status.ACTIVE).orElse(null),
certRepository.findByStatus(CertificateEntity.Status.STAGED).orElse(null),
certRepository.findByStatus(CertificateEntity.Status.ARCHIVED).orElse(null)
);
}
@Transactional
public CertValidationResult stage(byte[] certPem, byte[] keyPem, byte[] caBundlePem, UUID actorId) {
if (!certManager.isAvailable()) {
return CertValidationResult.fail(List.of("Certificate management is not available"));
}
// Discard any existing staged cert
certRepository.findByStatus(CertificateEntity.Status.STAGED).ifPresent(certRepository::delete);
// Stage files and validate
CertValidationResult result = certManager.stage(certPem, keyPem, caBundlePem);
if (!result.valid()) {
return result;
}
// Save metadata to DB
var entity = CertificateEntity.fromInfo(result.info(), CertificateEntity.Status.STAGED);
entity.setUploadedBy(actorId);
certRepository.save(entity);
log.info("Certificate staged by actor {}: subject={}", actorId, result.info().subject());
return result;
}
@Transactional
public void activate() {
var staged = certRepository.findByStatus(CertificateEntity.Status.STAGED)
.orElseThrow(() -> new IllegalStateException("No staged certificate to activate"));
// File operations: delete archive files, move active -> archive, move staged -> active
certManager.activate();
// DB: delete archived, active -> archived, staged -> active
certRepository.findByStatus(CertificateEntity.Status.ARCHIVED).ifPresent(certRepository::delete);
certRepository.findByStatus(CertificateEntity.Status.ACTIVE).ifPresent(active -> {
active.setStatus(CertificateEntity.Status.ARCHIVED);
active.setArchivedAt(Instant.now());
certRepository.save(active);
});
staged.setStatus(CertificateEntity.Status.ACTIVE);
staged.setActivatedAt(Instant.now());
certRepository.save(staged);
log.info("Certificate activated: subject={}", staged.getSubject());
}
@Transactional
public void restore() {
var archived = certRepository.findByStatus(CertificateEntity.Status.ARCHIVED)
.orElseThrow(() -> new IllegalStateException("No archived certificate to restore"));
if (archived.getNotAfter() != null && archived.getNotAfter().isBefore(Instant.now())) {
throw new IllegalStateException("Archived certificate has expired and cannot be restored");
}
// File operations: swap active <-> archive
certManager.restore();
// DB: swap statuses
var active = certRepository.findByStatus(CertificateEntity.Status.ACTIVE).orElse(null);
archived.setStatus(CertificateEntity.Status.ACTIVE);
archived.setActivatedAt(Instant.now());
archived.setArchivedAt(null);
certRepository.save(archived);
if (active != null) {
active.setStatus(CertificateEntity.Status.ARCHIVED);
active.setArchivedAt(Instant.now());
certRepository.save(active);
}
log.info("Certificate restored from archive: subject={}", archived.getSubject());
}
@Transactional
public void discardStaged() {
certManager.discardStaged();
certRepository.findByStatus(CertificateEntity.Status.STAGED).ifPresent(certRepository::delete);
log.info("Staged certificate discarded");
}
/**
* Count tenants whose ca_applied_at is before the active cert's activated_at,
* meaning they haven't picked up the latest CA bundle.
*/
public long countStaleTenants() {
var active = certRepository.findByStatus(CertificateEntity.Status.ACTIVE).orElse(null);
if (active == null || active.getActivatedAt() == null) return 0;
return tenantRepository.countByCaAppliedAtBeforeOrCaAppliedAtIsNull(active.getActivatedAt());
}
/**
* Seed the DB from the filesystem on startup (for bootstrap-generated certs).
*/
@Transactional
public void seedFromFilesystem() {
if (certRepository.findByStatus(CertificateEntity.Status.ACTIVE).isPresent()) {
return; // Already seeded
}
CertificateInfo activeInfo = certManager.getActive();
if (activeInfo != null) {
var entity = CertificateEntity.fromInfo(activeInfo, CertificateEntity.Status.ACTIVE);
entity.setActivatedAt(Instant.now());
certRepository.save(entity);
log.info("Seeded certificate metadata from filesystem: subject={}", activeInfo.subject());
}
}
}

View File

@@ -0,0 +1,20 @@
package net.siegeln.cameleer.saas.certificate;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
@Component
public class CertificateStartupListener {
private final CertificateService certificateService;
public CertificateStartupListener(CertificateService certificateService) {
this.certificateService = certificateService;
}
@EventListener(ApplicationReadyEvent.class)
public void onReady() {
certificateService.seedFromFilesystem();
}
}

View File

@@ -0,0 +1,29 @@
package net.siegeln.cameleer.saas.provisioning;
import net.siegeln.cameleer.saas.certificate.CertificateManager;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.nio.file.Files;
import java.nio.file.Path;
@Configuration
public class CertificateManagerAutoConfig {
private static final Logger log = LoggerFactory.getLogger(CertificateManagerAutoConfig.class);
@Bean
CertificateManager certificateManager(
@Value("${cameleer.certs.path:/certs}") String certsPath) {
Path path = Path.of(certsPath);
if (Files.isDirectory(path)) {
log.info("Certs directory found at {} — enabling Docker certificate manager", certsPath);
return new DockerCertificateManager(path);
}
log.info("No certs directory at {} — certificate management disabled", certsPath);
return new DisabledCertificateManager();
}
}

View File

@@ -0,0 +1,45 @@
package net.siegeln.cameleer.saas.provisioning;
import net.siegeln.cameleer.saas.certificate.CertificateInfo;
import net.siegeln.cameleer.saas.certificate.CertificateManager;
import net.siegeln.cameleer.saas.certificate.CertValidationResult;
import java.util.List;
/**
* No-op certificate manager when certs directory is not available.
*/
public class DisabledCertificateManager implements CertificateManager {
@Override
public boolean isAvailable() { return false; }
@Override
public CertificateInfo getActive() { return null; }
@Override
public CertificateInfo getStaged() { return null; }
@Override
public CertificateInfo getArchived() { return null; }
@Override
public CertValidationResult stage(byte[] certPem, byte[] keyPem, byte[] caBundlePem) {
return CertValidationResult.fail(List.of("Certificate management is not available"));
}
@Override
public void activate() {}
@Override
public void restore() {}
@Override
public void discardStaged() {}
@Override
public void generateSelfSigned(String hostname) {}
@Override
public byte[] getCaBundle() { return null; }
}

View File

@@ -0,0 +1,320 @@
package net.siegeln.cameleer.saas.provisioning;
import net.siegeln.cameleer.saas.certificate.CertificateInfo;
import net.siegeln.cameleer.saas.certificate.CertificateManager;
import net.siegeln.cameleer.saas.certificate.CertValidationResult;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.security.KeyFactory;
import java.security.MessageDigest;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.security.spec.PKCS8EncodedKeySpec;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Base64;
import java.util.HexFormat;
import java.util.List;
public class DockerCertificateManager implements CertificateManager {
private static final Logger log = LoggerFactory.getLogger(DockerCertificateManager.class);
private final Path certsDir;
public DockerCertificateManager(Path certsDir) {
this.certsDir = certsDir;
}
@Override
public boolean isAvailable() {
return Files.isDirectory(certsDir) && Files.isWritable(certsDir);
}
@Override
public CertificateInfo getActive() {
return readCertInfo(certsDir.resolve("cert.pem"), certsDir.resolve("ca.pem"));
}
@Override
public CertificateInfo getStaged() {
return readCertInfo(certsDir.resolve("staged/cert.pem"), certsDir.resolve("staged/ca.pem"));
}
@Override
public CertificateInfo getArchived() {
return readCertInfo(certsDir.resolve("prev/cert.pem"), certsDir.resolve("prev/ca.pem"));
}
@Override
public CertValidationResult stage(byte[] certPem, byte[] keyPem, byte[] caBundlePem) {
List<String> errors = new ArrayList<>();
// Parse certificate
X509Certificate cert;
try {
cert = parseCertificate(certPem);
} catch (Exception e) {
errors.add("Invalid certificate PEM: " + e.getMessage());
return CertValidationResult.fail(errors);
}
// Parse private key
try {
var privateKey = parsePrivateKey(keyPem);
// Verify key matches cert
if (cert.getPublicKey() instanceof RSAPublicKey rsaPub
&& privateKey instanceof RSAPrivateKey rsaPriv) {
if (!rsaPub.getModulus().equals(rsaPriv.getModulus())) {
errors.add("Private key does not match certificate");
}
}
} catch (Exception e) {
errors.add("Invalid private key PEM: " + e.getMessage());
}
// Parse CA bundle if provided
if (caBundlePem != null && caBundlePem.length > 0) {
try {
var certs = parseCertificates(caBundlePem);
if (certs.isEmpty()) {
errors.add("CA bundle contains no valid certificates");
}
} catch (Exception e) {
errors.add("Invalid CA bundle PEM: " + e.getMessage());
}
}
if (!errors.isEmpty()) {
return CertValidationResult.fail(errors);
}
// Write to staged directory
try {
Path stagedDir = certsDir.resolve("staged");
Files.createDirectories(stagedDir);
writeAtomic(stagedDir.resolve("cert.pem"), certPem);
writeAtomic(stagedDir.resolve("key.pem"), keyPem);
if (caBundlePem != null && caBundlePem.length > 0) {
writeAtomic(stagedDir.resolve("ca.pem"), caBundlePem);
} else {
Files.deleteIfExists(stagedDir.resolve("ca.pem"));
}
} catch (IOException e) {
log.error("Failed to write staged certificate files", e);
return CertValidationResult.fail(List.of("Failed to write staged files: " + e.getMessage()));
}
var info = toCertInfo(cert, caBundlePem != null && caBundlePem.length > 0, false);
return CertValidationResult.ok(info);
}
@Override
public void activate() {
try {
Path stagedDir = certsDir.resolve("staged");
Path prevDir = certsDir.resolve("prev");
if (!Files.exists(stagedDir.resolve("cert.pem"))) {
throw new IllegalStateException("No staged certificate to activate");
}
// Delete existing archive
deleteDirectory(prevDir);
// Move current active -> prev (archive)
if (Files.exists(certsDir.resolve("cert.pem"))) {
Files.createDirectories(prevDir);
moveFile(certsDir.resolve("cert.pem"), prevDir.resolve("cert.pem"));
moveFile(certsDir.resolve("key.pem"), prevDir.resolve("key.pem"));
if (Files.exists(certsDir.resolve("ca.pem"))) {
Files.copy(certsDir.resolve("ca.pem"), prevDir.resolve("ca.pem"));
}
}
// Move staged -> active (atomic swap via .wip)
writeAtomic(certsDir.resolve("cert.pem"), Files.readAllBytes(stagedDir.resolve("cert.pem")));
writeAtomic(certsDir.resolve("key.pem"), Files.readAllBytes(stagedDir.resolve("key.pem")));
if (Files.exists(stagedDir.resolve("ca.pem"))) {
writeAtomic(certsDir.resolve("ca.pem"), Files.readAllBytes(stagedDir.resolve("ca.pem")));
}
// Clean up staged
deleteDirectory(stagedDir);
log.info("Certificate activated successfully");
} catch (IOException e) {
throw new RuntimeException("Failed to activate certificate", e);
}
}
@Override
public void restore() {
try {
Path prevDir = certsDir.resolve("prev");
if (!Files.exists(prevDir.resolve("cert.pem"))) {
throw new IllegalStateException("No archived certificate to restore");
}
// Swap: active <-> prev using a temp dir
Path tempDir = certsDir.resolve("swap-tmp");
Files.createDirectories(tempDir);
// Current active -> temp
moveFile(certsDir.resolve("cert.pem"), tempDir.resolve("cert.pem"));
moveFile(certsDir.resolve("key.pem"), tempDir.resolve("key.pem"));
if (Files.exists(certsDir.resolve("ca.pem"))) {
moveFile(certsDir.resolve("ca.pem"), tempDir.resolve("ca.pem"));
}
// Prev -> active
writeAtomic(certsDir.resolve("cert.pem"), Files.readAllBytes(prevDir.resolve("cert.pem")));
writeAtomic(certsDir.resolve("key.pem"), Files.readAllBytes(prevDir.resolve("key.pem")));
if (Files.exists(prevDir.resolve("ca.pem"))) {
writeAtomic(certsDir.resolve("ca.pem"), Files.readAllBytes(prevDir.resolve("ca.pem")));
} else {
Files.deleteIfExists(certsDir.resolve("ca.pem"));
}
// Temp -> prev
deleteDirectory(prevDir);
Files.createDirectories(prevDir);
moveFile(tempDir.resolve("cert.pem"), prevDir.resolve("cert.pem"));
moveFile(tempDir.resolve("key.pem"), prevDir.resolve("key.pem"));
if (Files.exists(tempDir.resolve("ca.pem"))) {
moveFile(tempDir.resolve("ca.pem"), prevDir.resolve("ca.pem"));
}
deleteDirectory(tempDir);
log.info("Certificate restored from archive");
} catch (IOException e) {
throw new RuntimeException("Failed to restore certificate", e);
}
}
@Override
public void discardStaged() {
try {
deleteDirectory(certsDir.resolve("staged"));
log.info("Staged certificate discarded");
} catch (IOException e) {
throw new RuntimeException("Failed to discard staged certificate", e);
}
}
@Override
public void generateSelfSigned(String hostname) {
try {
// Use keytool to generate a self-signed cert, then export to PEM
// This is a fallback — the init container normally handles this
log.warn("generateSelfSigned called on DockerCertificateManager — " +
"this is typically handled by the traefik-certs init container");
} catch (Exception e) {
throw new RuntimeException("Failed to generate self-signed certificate", e);
}
}
@Override
public byte[] getCaBundle() {
try {
Path caPath = certsDir.resolve("ca.pem");
return Files.exists(caPath) ? Files.readAllBytes(caPath) : null;
} catch (IOException e) {
log.warn("Failed to read CA bundle: {}", e.getMessage());
return null;
}
}
// --- Internal helpers ---
private CertificateInfo readCertInfo(Path certPath, Path caPath) {
if (!Files.exists(certPath)) return null;
try {
byte[] certBytes = Files.readAllBytes(certPath);
X509Certificate cert = parseCertificate(certBytes);
boolean hasCa = Files.exists(caPath);
boolean selfSigned = cert.getIssuerX500Principal().equals(cert.getSubjectX500Principal());
return toCertInfo(cert, hasCa, selfSigned);
} catch (Exception e) {
log.warn("Failed to read cert info from {}: {}", certPath, e.getMessage());
return null;
}
}
private CertificateInfo toCertInfo(X509Certificate cert, boolean hasCa, boolean selfSigned) {
try {
String fingerprint = HexFormat.ofDelimiter(":").formatHex(
MessageDigest.getInstance("SHA-256").digest(cert.getEncoded()));
// Auto-detect self-signed
if (cert.getIssuerX500Principal().equals(cert.getSubjectX500Principal())) {
selfSigned = true;
}
return new CertificateInfo(
cert.getSubjectX500Principal().getName(),
cert.getIssuerX500Principal().getName(),
cert.getNotBefore().toInstant(),
cert.getNotAfter().toInstant(),
hasCa,
selfSigned,
fingerprint
);
} catch (Exception e) {
throw new RuntimeException("Failed to extract cert info", e);
}
}
static X509Certificate parseCertificate(byte[] pem) throws Exception {
CertificateFactory cf = CertificateFactory.getInstance("X.509");
return (X509Certificate) cf.generateCertificate(new ByteArrayInputStream(pem));
}
static List<X509Certificate> parseCertificates(byte[] pem) throws Exception {
CertificateFactory cf = CertificateFactory.getInstance("X.509");
var certs = cf.generateCertificates(new ByteArrayInputStream(pem));
return certs.stream().map(c -> (X509Certificate) c).toList();
}
static java.security.PrivateKey parsePrivateKey(byte[] pem) throws Exception {
String pemStr = new String(pem);
// Extract base64 content between PEM markers (handles Bag Attributes etc.)
var matcher = java.util.regex.Pattern
.compile("-----BEGIN (?:RSA )?PRIVATE KEY-----(.+?)-----END (?:RSA )?PRIVATE KEY-----",
java.util.regex.Pattern.DOTALL)
.matcher(pemStr);
if (!matcher.find()) {
throw new IllegalArgumentException("No private key PEM block found");
}
String base64 = matcher.group(1).replaceAll("\\s+", "");
byte[] decoded = Base64.getDecoder().decode(base64);
PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(decoded);
return KeyFactory.getInstance("RSA").generatePrivate(spec);
}
private void writeAtomic(Path target, byte[] data) throws IOException {
Path wip = target.resolveSibling(target.getFileName() + ".wip");
Files.write(wip, data);
Files.move(wip, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
}
private void moveFile(Path source, Path target) throws IOException {
Files.move(source, target, StandardCopyOption.REPLACE_EXISTING);
}
private void deleteDirectory(Path dir) throws IOException {
if (!Files.exists(dir)) return;
try (var walk = Files.walk(dir)) {
walk.sorted(java.util.Comparator.reverseOrder())
.forEach(p -> {
try { Files.deleteIfExists(p); } catch (IOException ignored) {}
});
}
}
}

View File

@@ -135,7 +135,7 @@ public class DockerTenantProvisioner implements TenantProvisioner {
labels.put("cameleer.tenant", slug);
labels.put("cameleer.role", "server");
List<String> env = List.of(
var env = new java.util.ArrayList<>(List.of(
"SPRING_DATASOURCE_URL=" + props.datasourceUrl(),
"SPRING_DATASOURCE_USERNAME=cameleer",
"SPRING_DATASOURCE_PASSWORD=cameleer_dev",
@@ -146,7 +146,6 @@ public class DockerTenantProvisioner implements TenantProvisioner {
"CAMELEER_OIDC_ISSUER_URI=" + props.oidcIssuerUri(),
"CAMELEER_OIDC_JWK_SET_URI=" + props.oidcJwkSetUri(),
"CAMELEER_OIDC_AUDIENCE=https://api.cameleer.local",
"CAMELEER_OIDC_TLS_SKIP_VERIFY=true",
"CAMELEER_CORS_ALLOWED_ORIGINS=" + props.corsOrigins(),
"CAMELEER_LICENSE_TOKEN=" + req.licenseToken(),
"CAMELEER_RUNTIME_ENABLED=true",
@@ -157,7 +156,11 @@ public class DockerTenantProvisioner implements TenantProvisioner {
// Apps deployed by this server join the tenant network (isolated)
"CAMELEER_DOCKER_NETWORK=" + tenantNetwork,
"CAMELEER_JAR_DOCKER_VOLUME=cameleer-jars-" + slug
);
));
// If no CA bundle exists, fall back to TLS skip for OIDC (self-signed dev)
if (!java.nio.file.Files.exists(java.nio.file.Path.of("/certs/ca.pem"))) {
env.add("CAMELEER_OIDC_TLS_SKIP_VERIFY=true");
}
// Primary network = tenant-isolated network
HostConfig hostConfig = HostConfig.newHostConfig()
@@ -165,7 +168,8 @@ public class DockerTenantProvisioner implements TenantProvisioner {
.withNetworkMode(tenantNetwork)
.withBinds(
new Bind("/var/run/docker.sock", new Volume("/var/run/docker.sock")),
new Bind("cameleer-jars-" + slug, new Volume("/data/jars"))
new Bind("cameleer-jars-" + slug, new Volume("/data/jars")),
new Bind("cameleer-saas_certs", new Volume("/certs"), AccessMode.ro)
)
.withGroupAdd(List.of("0"));

View File

@@ -58,6 +58,9 @@ public class TenantEntity {
@Column(name = "provision_error", columnDefinition = "TEXT")
private String provisionError;
@Column(name = "ca_applied_at")
private Instant caAppliedAt;
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;
@@ -97,6 +100,8 @@ public class TenantEntity {
public void setServerEndpoint(String serverEndpoint) { this.serverEndpoint = serverEndpoint; }
public String getProvisionError() { return provisionError; }
public void setProvisionError(String provisionError) { this.provisionError = provisionError; }
public Instant getCaAppliedAt() { return caAppliedAt; }
public void setCaAppliedAt(Instant caAppliedAt) { this.caAppliedAt = caAppliedAt; }
public Instant getCreatedAt() { return createdAt; }
public Instant getUpdatedAt() { return updatedAt; }
}

View File

@@ -3,6 +3,7 @@ package net.siegeln.cameleer.saas.tenant;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@@ -14,4 +15,5 @@ public interface TenantRepository extends JpaRepository<TenantEntity, UUID> {
List<TenantEntity> findByStatus(TenantStatus status);
boolean existsBySlug(String slug);
boolean existsBySlugAndStatusNot(String slug, TenantStatus status);
long countByCaAppliedAtBeforeOrCaAppliedAtIsNull(Instant threshold);
}

View File

@@ -0,0 +1,19 @@
-- Certificate management: track platform TLS certs and CA bundles
CREATE TABLE certificates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
status VARCHAR(10) NOT NULL CHECK (status IN ('ACTIVE', 'STAGED', 'ARCHIVED')),
subject VARCHAR(500),
issuer VARCHAR(500),
not_before TIMESTAMPTZ,
not_after TIMESTAMPTZ,
fingerprint VARCHAR(128),
has_ca BOOLEAN NOT NULL DEFAULT FALSE,
self_signed BOOLEAN NOT NULL DEFAULT FALSE,
uploaded_by UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
activated_at TIMESTAMPTZ,
archived_at TIMESTAMPTZ
);
-- Track when each tenant last picked up the CA bundle
ALTER TABLE tenants ADD COLUMN ca_applied_at TIMESTAMPTZ;

View File

@@ -0,0 +1,218 @@
package net.siegeln.cameleer.saas.certificate;
import net.siegeln.cameleer.saas.tenant.TenantRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class CertificateServiceTest {
@Mock
private CertificateManager certManager;
@Mock
private CertificateRepository certRepository;
@Mock
private TenantRepository tenantRepository;
private CertificateService service;
@BeforeEach
void setUp() {
service = new CertificateService(certManager, certRepository, tenantRepository);
}
@Test
void stage_delegatesToManagerAndSavesEntity() {
var info = new CertificateInfo("CN=test", "CN=test", Instant.now(),
Instant.now().plusSeconds(86400), false, true, "AA:BB");
when(certManager.isAvailable()).thenReturn(true);
when(certManager.stage(any(), any(), any()))
.thenReturn(CertValidationResult.ok(info));
when(certRepository.findByStatus(CertificateEntity.Status.STAGED))
.thenReturn(Optional.empty());
when(certRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
var result = service.stage("cert".getBytes(), "key".getBytes(), null, UUID.randomUUID());
assertThat(result.valid()).isTrue();
var captor = ArgumentCaptor.forClass(CertificateEntity.class);
verify(certRepository).save(captor.capture());
assertThat(captor.getValue().getStatus()).isEqualTo(CertificateEntity.Status.STAGED);
assertThat(captor.getValue().getSubject()).isEqualTo("CN=test");
}
@Test
void stage_discardsExistingStagedFirst() {
var existing = new CertificateEntity();
when(certManager.isAvailable()).thenReturn(true);
when(certRepository.findByStatus(CertificateEntity.Status.STAGED))
.thenReturn(Optional.of(existing));
when(certManager.stage(any(), any(), any()))
.thenReturn(CertValidationResult.fail(List.of("bad cert")));
service.stage("cert".getBytes(), "key".getBytes(), null, UUID.randomUUID());
verify(certRepository).delete(existing);
}
@Test
void stage_returnsErrorWhenManagerUnavailable() {
when(certManager.isAvailable()).thenReturn(false);
var result = service.stage("cert".getBytes(), "key".getBytes(), null, UUID.randomUUID());
assertThat(result.valid()).isFalse();
assertThat(result.errors()).contains("Certificate management is not available");
}
@Test
void activate_promotesStagedToActive() {
var staged = new CertificateEntity();
staged.setStatus(CertificateEntity.Status.STAGED);
staged.setSubject("CN=new");
when(certRepository.findByStatus(CertificateEntity.Status.STAGED))
.thenReturn(Optional.of(staged));
when(certRepository.findByStatus(CertificateEntity.Status.ARCHIVED))
.thenReturn(Optional.empty());
when(certRepository.findByStatus(CertificateEntity.Status.ACTIVE))
.thenReturn(Optional.empty());
service.activate();
verify(certManager).activate();
assertThat(staged.getStatus()).isEqualTo(CertificateEntity.Status.ACTIVE);
assertThat(staged.getActivatedAt()).isNotNull();
}
@Test
void activate_archivesCurrentActive() {
var staged = new CertificateEntity();
staged.setStatus(CertificateEntity.Status.STAGED);
var active = new CertificateEntity();
active.setStatus(CertificateEntity.Status.ACTIVE);
when(certRepository.findByStatus(CertificateEntity.Status.STAGED))
.thenReturn(Optional.of(staged));
when(certRepository.findByStatus(CertificateEntity.Status.ARCHIVED))
.thenReturn(Optional.empty());
when(certRepository.findByStatus(CertificateEntity.Status.ACTIVE))
.thenReturn(Optional.of(active));
service.activate();
assertThat(active.getStatus()).isEqualTo(CertificateEntity.Status.ARCHIVED);
assertThat(active.getArchivedAt()).isNotNull();
}
@Test
void activate_failsWithNoStagedCert() {
when(certRepository.findByStatus(CertificateEntity.Status.STAGED))
.thenReturn(Optional.empty());
assertThatThrownBy(() -> service.activate())
.isInstanceOf(IllegalStateException.class);
}
@Test
void restore_swapsActiveAndArchived() {
var active = new CertificateEntity();
active.setStatus(CertificateEntity.Status.ACTIVE);
active.setSubject("CN=current");
var archived = new CertificateEntity();
archived.setStatus(CertificateEntity.Status.ARCHIVED);
archived.setSubject("CN=previous");
// Set notAfter in the future so it's restorable
archived.setNotAfter(Instant.now().plusSeconds(86400 * 365));
when(certRepository.findByStatus(CertificateEntity.Status.ARCHIVED))
.thenReturn(Optional.of(archived));
when(certRepository.findByStatus(CertificateEntity.Status.ACTIVE))
.thenReturn(Optional.of(active));
service.restore();
verify(certManager).restore();
assertThat(archived.getStatus()).isEqualTo(CertificateEntity.Status.ACTIVE);
assertThat(active.getStatus()).isEqualTo(CertificateEntity.Status.ARCHIVED);
}
@Test
void restore_failsWhenArchivedExpired() {
var archived = new CertificateEntity();
archived.setStatus(CertificateEntity.Status.ARCHIVED);
archived.setNotAfter(Instant.now().minusSeconds(3600)); // expired
when(certRepository.findByStatus(CertificateEntity.Status.ARCHIVED))
.thenReturn(Optional.of(archived));
assertThatThrownBy(() -> service.restore())
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("expired");
}
@Test
void discardStaged_delegatesAndDeletesEntity() {
var staged = new CertificateEntity();
when(certRepository.findByStatus(CertificateEntity.Status.STAGED))
.thenReturn(Optional.of(staged));
service.discardStaged();
verify(certManager).discardStaged();
verify(certRepository).delete(staged);
}
@Test
void seedFromFilesystem_skipsWhenActiveExists() {
when(certRepository.findByStatus(CertificateEntity.Status.ACTIVE))
.thenReturn(Optional.of(new CertificateEntity()));
service.seedFromFilesystem();
verify(certManager, never()).getActive();
}
@Test
void seedFromFilesystem_seedsFromManager() {
var info = new CertificateInfo("CN=bootstrap", "CN=bootstrap", Instant.now(),
Instant.now().plusSeconds(86400), false, true, "AA:BB");
when(certRepository.findByStatus(CertificateEntity.Status.ACTIVE))
.thenReturn(Optional.empty());
when(certManager.getActive()).thenReturn(info);
when(certRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
service.seedFromFilesystem();
var captor = ArgumentCaptor.forClass(CertificateEntity.class);
verify(certRepository).save(captor.capture());
assertThat(captor.getValue().getSubject()).isEqualTo("CN=bootstrap");
assertThat(captor.getValue().getStatus()).isEqualTo(CertificateEntity.Status.ACTIVE);
}
@Test
void countStaleTenants_returnsZeroWhenNoActiveCert() {
when(certRepository.findByStatus(CertificateEntity.Status.ACTIVE))
.thenReturn(Optional.empty());
assertThat(service.countStaleTenants()).isZero();
}
}

View File

@@ -0,0 +1,224 @@
package net.siegeln.cameleer.saas.certificate;
import net.siegeln.cameleer.saas.provisioning.DockerCertificateManager;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class DockerCertificateManagerTest {
@TempDir
Path certsDir;
private DockerCertificateManager manager;
@BeforeEach
void setUp() {
manager = new DockerCertificateManager(certsDir);
}
@Test
void isAvailable_returnsTrueForWritableDirectory() {
assertThat(manager.isAvailable()).isTrue();
}
@Test
void getActive_returnsNullWhenNoCert() {
assertThat(manager.getActive()).isNull();
}
@Test
void stage_validatesAndWritesFiles() throws Exception {
byte[][] pair = generateCertAndKey("CN=test.example.com");
var result = manager.stage(pair[0], pair[1], null);
assertThat(result.valid()).isTrue();
assertThat(result.info()).isNotNull();
assertThat(result.info().subject()).contains("test.example.com");
assertThat(result.info().selfSigned()).isTrue();
assertThat(Files.exists(certsDir.resolve("staged/cert.pem"))).isTrue();
assertThat(Files.exists(certsDir.resolve("staged/key.pem"))).isTrue();
}
@Test
void stage_rejectsInvalidCertPem() {
var result = manager.stage("not a cert".getBytes(), "not a key".getBytes(), null);
assertThat(result.valid()).isFalse();
assertThat(result.errors()).isNotEmpty();
}
@Test
void stage_rejectsMismatchedKeyAndCert() throws Exception {
byte[][] pair1 = generateCertAndKey("CN=test.example.com");
byte[][] pair2 = generateCertAndKey("CN=other.example.com");
var result = manager.stage(pair1[0], pair2[1], null);
assertThat(result.valid()).isFalse();
assertThat(result.errors()).anyMatch(e -> e.contains("does not match"));
}
@Test
void activateAndRestore_lifecycle() throws Exception {
// Set up an initial active cert
byte[][] pair1 = generateCertAndKey("CN=first.example.com");
Files.write(certsDir.resolve("cert.pem"), pair1[0]);
Files.write(certsDir.resolve("key.pem"), pair1[1]);
// Stage a second cert
byte[][] pair2 = generateCertAndKey("CN=second.example.com");
var stageResult = manager.stage(pair2[0], pair2[1], null);
assertThat(stageResult.valid()).isTrue();
// Activate: first -> archived, second -> active
manager.activate();
var active = manager.getActive();
assertThat(active).isNotNull();
assertThat(active.subject()).contains("second.example.com");
var archived = manager.getArchived();
assertThat(archived).isNotNull();
assertThat(archived.subject()).contains("first.example.com");
assertThat(manager.getStaged()).isNull();
// Restore: swap active <-> archived
manager.restore();
active = manager.getActive();
assertThat(active.subject()).contains("first.example.com");
archived = manager.getArchived();
assertThat(archived.subject()).contains("second.example.com");
}
@Test
void activate_failsWithNoStagedCert() {
assertThatThrownBy(() -> manager.activate())
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("No staged certificate");
}
@Test
void restore_failsWithNoArchivedCert() {
assertThatThrownBy(() -> manager.restore())
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("No archived certificate");
}
@Test
void discardStaged_removesFiles() throws Exception {
byte[][] pair = generateCertAndKey("CN=test.example.com");
manager.stage(pair[0], pair[1], null);
assertThat(Files.exists(certsDir.resolve("staged/cert.pem"))).isTrue();
manager.discardStaged();
assertThat(Files.exists(certsDir.resolve("staged"))).isFalse();
}
@Test
void stage_withCaBundle() throws Exception {
byte[][] pair = generateCertAndKey("CN=test.example.com");
// Use the cert itself as a "CA" for testing purposes
var result = manager.stage(pair[0], pair[1], pair[0]);
assertThat(result.valid()).isTrue();
assertThat(result.info().hasCaBundle()).isTrue();
assertThat(Files.exists(certsDir.resolve("staged/ca.pem"))).isTrue();
}
@Test
void getCaBundle_returnsNullWhenMissing() {
assertThat(manager.getCaBundle()).isNull();
}
@Test
void getCaBundle_returnsContentWhenPresent() throws Exception {
byte[] caContent = "test-ca-content".getBytes();
Files.write(certsDir.resolve("ca.pem"), caContent);
assertThat(manager.getCaBundle()).isEqualTo(caContent);
}
@Test
void activate_deletesExistingArchive() throws Exception {
// Create initial active
byte[][] pair1 = generateCertAndKey("CN=first.example.com");
Files.write(certsDir.resolve("cert.pem"), pair1[0]);
Files.write(certsDir.resolve("key.pem"), pair1[1]);
// Create existing archive
Files.createDirectories(certsDir.resolve("prev"));
byte[][] pairOld = generateCertAndKey("CN=old.example.com");
Files.write(certsDir.resolve("prev/cert.pem"), pairOld[0]);
Files.write(certsDir.resolve("prev/key.pem"), pairOld[1]);
// Stage new cert
byte[][] pair2 = generateCertAndKey("CN=second.example.com");
manager.stage(pair2[0], pair2[1], null);
// Activate: old archive should be deleted, first becomes archive
manager.activate();
var archived = manager.getArchived();
assertThat(archived).isNotNull();
assertThat(archived.subject()).contains("first.example.com");
}
// --- Test helpers: generate self-signed certs via keytool + openssl ---
/**
* Generates a matched cert.pem + key.pem pair using keytool and openssl.
* Returns [certPem, keyPem].
*/
private static byte[][] generateCertAndKey(String cn) throws Exception {
Path tmpDir = Files.createTempDirectory("cert-test");
Path ks = tmpDir.resolve("keystore.p12");
Path certFile = tmpDir.resolve("cert.pem");
Path keyFile = tmpDir.resolve("key.pem");
try {
// Generate keystore with self-signed cert
exec("keytool", "-genkeypair", "-alias", "test", "-keyalg", "RSA", "-keysize", "2048",
"-validity", "365", "-dname", cn, "-storetype", "PKCS12",
"-keystore", ks.toString(), "-storepass", "changeit");
// Export cert to PEM
exec("keytool", "-exportcert", "-alias", "test", "-rfc",
"-keystore", ks.toString(), "-storepass", "changeit",
"-file", certFile.toString());
// Export key via openssl
exec("openssl", "pkcs12", "-in", ks.toString(), "-passin", "pass:changeit",
"-nocerts", "-nodes", "-out", keyFile.toString());
return new byte[][] { Files.readAllBytes(certFile), Files.readAllBytes(keyFile) };
} finally {
Files.deleteIfExists(ks);
Files.deleteIfExists(certFile);
Files.deleteIfExists(keyFile);
Files.deleteIfExists(tmpDir);
}
}
private static void exec(String... cmd) throws Exception {
var pb = new ProcessBuilder(cmd);
pb.redirectErrorStream(true);
var proc = pb.start();
proc.getInputStream().readAllBytes(); // consume output
int exit = proc.waitFor();
if (exit != 0) {
throw new RuntimeException("Command failed (exit " + exit + "): " + String.join(" ", cmd));
}
}
}