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}