001/* 002 * acme4j - Java ACME client 003 * 004 * Copyright (C) 2023 Richard "Shred" Körber 005 * http://acme4j.shredzone.org 006 * 007 * Licensed under the Apache License, Version 2.0 (the "License"); 008 * you may not use this file except in compliance with the License. 009 * 010 * This program is distributed in the hope that it will be useful, 011 * but WITHOUT ANY WARRANTY; without even the implied warranty of 012 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. 013 */ 014package org.shredzone.acme4j; 015 016import java.net.URL; 017import java.time.Instant; 018import java.time.temporal.TemporalAmount; 019import java.util.Optional; 020import java.util.concurrent.ThreadLocalRandom; 021 022import edu.umd.cs.findbugs.annotations.Nullable; 023import org.shredzone.acme4j.connector.Connection; 024import org.shredzone.acme4j.exception.AcmeException; 025import org.shredzone.acme4j.exception.AcmeProtocolException; 026import org.shredzone.acme4j.toolbox.JSON.Value; 027import org.slf4j.Logger; 028import org.slf4j.LoggerFactory; 029 030/** 031 * Renewal Information of a certificate. 032 * 033 * @draft This class is currently based on an RFC draft. It may be changed or 034 * removed without notice to reflect future changes to the draft. SemVer rules 035 * do not apply here. 036 * @since 3.0.0 037 */ 038public class RenewalInfo extends AcmeJsonResource { 039 private static final Logger LOG = LoggerFactory.getLogger(RenewalInfo.class); 040 041 protected RenewalInfo(Login login, URL location) { 042 super(login, location); 043 } 044 045 /** 046 * Returns the starting {@link Instant} of the time window the CA recommends for 047 * certificate renewal. 048 */ 049 public Instant getSuggestedWindowStart() { 050 return getJSON().get("suggestedWindow").asObject().get("start").asInstant(); 051 } 052 053 /** 054 * Returns the ending {@link Instant} of the time window the CA recommends for 055 * certificate renewal. 056 */ 057 public Instant getSuggestedWindowEnd() { 058 return getJSON().get("suggestedWindow").asObject().get("end").asInstant(); 059 } 060 061 /** 062 * An optional {@link URL} pointing to a page which may explain why the suggested 063 * renewal window is what it is. 064 */ 065 public Optional<URL> getExplanation() { 066 return getJSON().get("explanationURL").optional().map(Value::asURL); 067 } 068 069 /** 070 * Checks if the given {@link Instant} is before the suggested time window, so a 071 * certificate renewal is not required yet. 072 * 073 * @param instant 074 * {@link Instant} to check 075 * @return {@code true} if the {@link Instant} is before the time window, {@code 076 * false} otherwise. 077 */ 078 public boolean renewalIsNotRequired(Instant instant) { 079 assertValidTimeWindow(); 080 return instant.isBefore(getSuggestedWindowStart()); 081 } 082 083 /** 084 * Checks if the given {@link Instant} is within the suggested time window, and a 085 * certificate renewal is recommended. 086 * <p> 087 * An {@link Instant} is deemed to be within the time window if it is equal to, or 088 * after {@link #getSuggestedWindowStart()}, and before {@link 089 * #getSuggestedWindowEnd()}. 090 * 091 * @param instant 092 * {@link Instant} to check 093 * @return {@code true} if the {@link Instant} is within the time window, {@code 094 * false} otherwise. 095 */ 096 public boolean renewalIsRecommended(Instant instant) { 097 assertValidTimeWindow(); 098 return !instant.isBefore(getSuggestedWindowStart()) 099 && instant.isBefore(getSuggestedWindowEnd()); 100 } 101 102 /** 103 * Checks if the given {@link Instant} is past the time window, and a certificate 104 * renewal is overdue. 105 * <p> 106 * An {@link Instant} is deemed to be past the time window if it is equal to, or after 107 * {@link #getSuggestedWindowEnd()}. 108 * 109 * @param instant 110 * {@link Instant} to check 111 * @return {@code true} if the {@link Instant} is past the time window, {@code false} 112 * otherwise. 113 */ 114 public boolean renewalIsOverdue(Instant instant) { 115 assertValidTimeWindow(); 116 return !instant.isBefore(getSuggestedWindowEnd()); 117 } 118 119 /** 120 * Returns a proposed {@link Instant} when the certificate related to this 121 * {@link RenewalInfo} should be renewed. 122 * <p> 123 * This method is useful for setting alarms for renewal cron jobs. As a parameter, the 124 * frequency of the cron job is set. The resulting {@link Instant} is guaranteed to be 125 * executed in time, considering the cron job intervals. 126 * <p> 127 * This method uses {@link ThreadLocalRandom} for random numbers. It is sufficient for 128 * most cases, as only an "earliest" {@link Instant} is returned, but the actual 129 * renewal process also depends on cron job execution times and other factors like 130 * system load. 131 * <p> 132 * The result is empty if it is impossible to renew the certificate in time, under the 133 * given circumstances. This is either because the time window already ended in the 134 * past, or because the cron job would not be executed before the ending of the time 135 * window. In this case, it is recommended to renew the certificate immediately. 136 * 137 * @param frequency 138 * Frequency of the cron job executing the certificate renewals. May be 139 * {@code null} if there is no cron job, and the renewal is going to be 140 * executed exactly at the given {@link Instant}. 141 * @return Random {@link Instant} when the certificate should be renewed. This instant 142 * might be slightly in the past. In this case, start the renewal process at the next 143 * possible regular moment. 144 */ 145 public Optional<Instant> getRandomProposal(@Nullable TemporalAmount frequency) { 146 assertValidTimeWindow(); 147 Instant start = Instant.now(); 148 Instant suggestedStart = getSuggestedWindowStart(); 149 if (start.isBefore(suggestedStart)) { 150 start = suggestedStart; 151 } 152 153 Instant end = getSuggestedWindowEnd(); 154 if (frequency != null) { 155 end = end.minus(frequency); 156 } 157 158 if (!end.isAfter(start)) { 159 return Optional.empty(); 160 } 161 162 return Optional.of(Instant.ofEpochMilli(ThreadLocalRandom.current().nextLong( 163 start.toEpochMilli(), 164 end.toEpochMilli()))); 165 } 166 167 /** 168 * Asserts that the end of the suggested time window is after the start. 169 */ 170 private void assertValidTimeWindow() { 171 if (getSuggestedWindowStart().isAfter(getSuggestedWindowEnd())) { 172 throw new AcmeProtocolException("Received an invalid suggested window"); 173 } 174 } 175 176 @Override 177 public Optional<Instant> fetch() throws AcmeException { 178 LOG.debug("update RenewalInfo"); 179 try (Connection conn = getSession().connect()) { 180 conn.sendRequest(getLocation(), getSession(), null); 181 setJSON(conn.readJsonResponse()); 182 var retryAfterOpt = conn.getRetryAfter(); 183 retryAfterOpt.ifPresent(instant -> LOG.debug("Retry-After: {}", instant)); 184 setRetryAfter(retryAfterOpt.orElse(null)); 185 return retryAfterOpt; 186 } 187 } 188 189}