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.connector;
015
016import static java.time.format.DateTimeFormatter.RFC_1123_DATE_TIME;
017import static java.util.function.Predicate.not;
018
019import java.io.IOException;
020import java.io.InputStream;
021import java.net.MalformedURLException;
022import java.net.URI;
023import java.net.URL;
024import java.net.http.HttpClient;
025import java.net.http.HttpRequest;
026import java.net.http.HttpResponse;
027import java.security.cert.CertificateException;
028import java.security.cert.CertificateFactory;
029import java.security.cert.X509Certificate;
030import java.time.Instant;
031import java.time.ZoneId;
032import java.time.ZonedDateTime;
033import java.time.format.DateTimeParseException;
034import java.util.Collection;
035import java.util.List;
036import java.util.Objects;
037import java.util.Optional;
038import java.util.Set;
039import java.util.function.Consumer;
040import java.util.regex.Matcher;
041import java.util.regex.Pattern;
042import java.util.zip.GZIPInputStream;
043
044import edu.umd.cs.findbugs.annotations.Nullable;
045import org.shredzone.acme4j.Login;
046import org.shredzone.acme4j.Problem;
047import org.shredzone.acme4j.Session;
048import org.shredzone.acme4j.exception.AcmeException;
049import org.shredzone.acme4j.exception.AcmeNetworkException;
050import org.shredzone.acme4j.exception.AcmeProtocolException;
051import org.shredzone.acme4j.exception.AcmeRateLimitedException;
052import org.shredzone.acme4j.exception.AcmeServerException;
053import org.shredzone.acme4j.exception.AcmeUnauthorizedException;
054import org.shredzone.acme4j.exception.AcmeUserActionRequiredException;
055import org.shredzone.acme4j.toolbox.AcmeUtils;
056import org.shredzone.acme4j.toolbox.JSON;
057import org.shredzone.acme4j.toolbox.JSONBuilder;
058import org.slf4j.Logger;
059import org.slf4j.LoggerFactory;
060
061/**
062 * Default implementation of {@link Connection}. It communicates with the ACME server via
063 * HTTP, with a client that is provided by the given {@link HttpConnector}.
064 */
065public class DefaultConnection implements Connection {
066    private static final Logger LOG = LoggerFactory.getLogger(DefaultConnection.class);
067
068    private static final int HTTP_OK = 200;
069    private static final int HTTP_CREATED = 201;
070    private static final int HTTP_NO_CONTENT = 204;
071    private static final int HTTP_NOT_MODIFIED = 304;
072
073    private static final String ACCEPT_HEADER = "Accept";
074    private static final String ACCEPT_CHARSET_HEADER = "Accept-Charset";
075    private static final String ACCEPT_LANGUAGE_HEADER = "Accept-Language";
076    private static final String ACCEPT_ENCODING_HEADER = "Accept-Encoding";
077    private static final String CACHE_CONTROL_HEADER = "Cache-Control";
078    private static final String CONTENT_TYPE_HEADER = "Content-Type";
079    private static final String DATE_HEADER = "Date";
080    private static final String EXPIRES_HEADER = "Expires";
081    private static final String IF_MODIFIED_SINCE_HEADER = "If-Modified-Since";
082    private static final String LAST_MODIFIED_HEADER = "Last-Modified";
083    private static final String LINK_HEADER = "Link";
084    private static final String LOCATION_HEADER = "Location";
085    private static final String REPLAY_NONCE_HEADER = "Replay-Nonce";
086    private static final String RETRY_AFTER_HEADER = "Retry-After";
087    private static final String DEFAULT_CHARSET = "utf-8";
088    private static final String MIME_JSON = "application/json";
089    private static final String MIME_JSON_PROBLEM = "application/problem+json";
090    private static final String MIME_CERTIFICATE_CHAIN = "application/pem-certificate-chain";
091
092    private static final URI BAD_NONCE_ERROR = URI.create("urn:ietf:params:acme:error:badNonce");
093    private static final int MAX_ATTEMPTS = 10;
094
095    private static final Pattern NO_CACHE_PATTERN = Pattern.compile("(?:^|.*?,)\\s*no-(?:cache|store)\\s*(?:,.*|$)", Pattern.CASE_INSENSITIVE);
096    private static final Pattern MAX_AGE_PATTERN = Pattern.compile("(?:^|.*?,)\\s*max-age=(\\d+)\\s*(?:,.*|$)", Pattern.CASE_INSENSITIVE);
097    private static final Pattern DIGITS_ONLY_PATTERN = Pattern.compile("^\\d+$");
098
099    protected final HttpConnector httpConnector;
100    protected final HttpClient httpClient;
101    protected @Nullable HttpResponse<InputStream> lastResponse;
102
103    /**
104     * Creates a new {@link DefaultConnection}.
105     *
106     * @param httpConnector
107     *         {@link HttpConnector} to be used for HTTP connections
108     */
109    public DefaultConnection(HttpConnector httpConnector) {
110        this.httpConnector = Objects.requireNonNull(httpConnector, "httpConnector");
111        this.httpClient = httpConnector.getHttpClient();
112    }
113
114    @Override
115    public void resetNonce(Session session) throws AcmeException {
116        assertConnectionIsClosed();
117
118        try (var nonceHolder = session.lockNonce()) {
119            nonceHolder.setNonce(null);
120
121            var newNonceUrl = session.resourceUrl(Resource.NEW_NONCE);
122
123            LOG.debug("HEAD {}", newNonceUrl);
124
125            sendRequest(session, newNonceUrl, b ->
126                    b.method("HEAD", HttpRequest.BodyPublishers.noBody()));
127
128            logHeaders();
129
130            var rc = getResponse().statusCode();
131            if (rc != HTTP_OK && rc != HTTP_NO_CONTENT) {
132                throw new AcmeException("Server responded with HTTP " + rc + " while trying to retrieve a nonce");
133            }
134
135            nonceHolder.setNonce(getNonce()
136                    .orElseThrow(() -> new AcmeProtocolException("Server did not provide a nonce"))
137            );
138        } catch (IOException ex) {
139            throw new AcmeNetworkException(ex);
140        } finally {
141            close();
142        }
143    }
144
145    @Override
146    public int sendRequest(URL url, Session session, @Nullable ZonedDateTime ifModifiedSince)
147            throws AcmeException {
148        Objects.requireNonNull(url, "url");
149        Objects.requireNonNull(session, "session");
150        assertConnectionIsClosed();
151
152        LOG.debug("GET {}", url);
153
154        try (var nonceHolder = session.lockNonce()) {
155            sendRequest(session, url, builder -> {
156                builder.GET();
157                builder.header(ACCEPT_HEADER, MIME_JSON);
158                if (ifModifiedSince != null) {
159                    builder.header(IF_MODIFIED_SINCE_HEADER, ifModifiedSince.format(RFC_1123_DATE_TIME));
160                }
161            });
162
163            logHeaders();
164
165            getNonce().ifPresent(nonceHolder::setNonce);
166
167            var rc = getResponse().statusCode();
168            if (rc != HTTP_OK && rc != HTTP_CREATED && (rc != HTTP_NOT_MODIFIED || ifModifiedSince == null)) {
169                throwAcmeException();
170            }
171            return rc;
172        } catch (IOException ex) {
173            throw new AcmeNetworkException(ex);
174        }
175    }
176
177    @Override
178    public int sendCertificateRequest(URL url, Login login) throws AcmeException {
179        return sendSignedRequest(url, null, login.getSession(), MIME_CERTIFICATE_CHAIN,
180                login::createJoseRequest);
181    }
182
183    @Override
184    public int sendSignedPostAsGetRequest(URL url, Login login) throws AcmeException {
185        return sendSignedRequest(url, null, login.getSession(), MIME_JSON,
186                login::createJoseRequest);
187    }
188
189    @Override
190    public int sendSignedRequest(URL url, JSONBuilder claims, Login login) throws AcmeException {
191        return sendSignedRequest(url, claims, login.getSession(), MIME_JSON,
192                login::createJoseRequest);
193    }
194
195    @Override
196    public int sendSignedRequest(URL url, JSONBuilder claims, Session session, RequestSigner signer)
197            throws AcmeException {
198        return sendSignedRequest(url, claims, session, MIME_JSON, signer);
199    }
200
201    @Override
202    public JSON readJsonResponse() throws AcmeException {
203        expectContentType(Set.of(MIME_JSON, MIME_JSON_PROBLEM));
204
205        try (var in = getResponseBody()) {
206            var result = JSON.parse(in);
207            LOG.debug("Result JSON: {}", result);
208            return result;
209        } catch (IOException ex) {
210            throw new AcmeNetworkException(ex);
211        }
212    }
213
214    @Override
215    public List<X509Certificate> readCertificates() throws AcmeException {
216        expectContentType(Set.of(MIME_CERTIFICATE_CHAIN));
217
218        try (var in = new TrimmingInputStream(getResponseBody())) {
219            var cf = CertificateFactory.getInstance("X.509");
220            return cf.generateCertificates(in).stream()
221                    .map(X509Certificate.class::cast)
222                    .toList();
223        } catch (IOException ex) {
224            throw new AcmeNetworkException(ex);
225        } catch (CertificateException ex) {
226            throw new AcmeProtocolException("Failed to read certificate", ex);
227        }
228    }
229
230    @Override
231    public Optional<String> getNonce() {
232        var nonceHeaderOpt = getResponse().headers()
233                .firstValue(REPLAY_NONCE_HEADER)
234                .map(String::trim)
235                .filter(not(String::isEmpty));
236        if (nonceHeaderOpt.isPresent()) {
237            var nonceHeader = nonceHeaderOpt.get();
238
239            if (!AcmeUtils.isValidBase64Url(nonceHeader)) {
240                throw new AcmeProtocolException("Invalid replay nonce: " + nonceHeader);
241            }
242
243            LOG.debug("Replay Nonce: {}", nonceHeader);
244        }
245        return nonceHeaderOpt;
246    }
247
248    @Override
249    public URL getLocation() {
250        return getResponse().headers()
251                .firstValue(LOCATION_HEADER)
252                .map(l -> {
253                    LOG.debug("Location: {}", l);
254                    return l;
255                })
256                .map(this::resolveRelative)
257                .orElseThrow(() -> new AcmeProtocolException("location header is missing"));
258    }
259
260    @Override
261    public Optional<ZonedDateTime> getLastModified() {
262        return getResponse().headers()
263                .firstValue(LAST_MODIFIED_HEADER)
264                .map(lm -> {
265                    try {
266                        return ZonedDateTime.parse(lm, RFC_1123_DATE_TIME);
267                    } catch (DateTimeParseException ex) {
268                        LOG.debug("Ignored invalid Last-Modified date: {}", lm, ex);
269                        return null;
270                    }
271                });
272    }
273
274    @Override
275    public Optional<ZonedDateTime> getExpiration() {
276        var cacheControlHeader = getResponse().headers()
277                .firstValue(CACHE_CONTROL_HEADER)
278                .filter(not(h -> NO_CACHE_PATTERN.matcher(h).matches()))
279                .map(MAX_AGE_PATTERN::matcher)
280                .filter(Matcher::matches)
281                .map(m -> Integer.parseInt(m.group(1)))
282                .filter(maxAge -> maxAge != 0)
283                .map(maxAge -> ZonedDateTime.now(ZoneId.of("UTC")).plusSeconds(maxAge));
284
285        if (cacheControlHeader.isPresent()) {
286            return cacheControlHeader;
287        }
288
289        return getResponse().headers()
290                .firstValue(EXPIRES_HEADER)
291                .flatMap(header -> {
292                    try {
293                        return Optional.of(ZonedDateTime.parse(header, RFC_1123_DATE_TIME));
294                    } catch (DateTimeParseException ex) {
295                        LOG.debug("Ignored invalid Expires date: {}", header, ex);
296                        return Optional.empty();
297                    }
298                });
299    }
300
301    @Override
302    public Collection<URL> getLinks(String relation) {
303        return collectLinks(relation).stream()
304                .map(this::resolveRelative)
305                .toList();
306    }
307
308    @Override
309    public void close() {
310        lastResponse = null;
311    }
312
313    /**
314     * Sends a HTTP request via http client. This is the central method to be used for
315     * sending. It will create a {@link HttpRequest} by using the request builder,
316     * configure commnon headers, and then send the request via {@link HttpClient}.
317     *
318     * @param session
319     *         {@link Session} to be used for sending
320     * @param url
321     *         Target {@link URL}
322     * @param body
323     *         Callback that completes the {@link HttpRequest.Builder} with the request
324     *         body (e.g. HTTP method, request body, more headers).
325     */
326    protected void sendRequest(Session session, URL url, Consumer<HttpRequest.Builder> body) throws IOException {
327        try {
328            var builder = httpConnector.createRequestBuilder(url)
329                    .header(ACCEPT_CHARSET_HEADER, DEFAULT_CHARSET)
330                    .header(ACCEPT_LANGUAGE_HEADER, session.getLanguageHeader());
331
332            if (session.networkSettings().isCompressionEnabled()) {
333                builder.header(ACCEPT_ENCODING_HEADER, "gzip");
334            }
335
336            body.accept(builder);
337
338            lastResponse = httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofInputStream());
339        } catch (InterruptedException ex) {
340            throw new IOException("Request was interrupted", ex);
341        }
342    }
343
344    /**
345     * Sends a signed POST request.
346     *
347     * @param url
348     *         {@link URL} to send the request to.
349     * @param claims
350     *         {@link JSONBuilder} containing claims. {@code null} for POST-as-GET
351     *         request.
352     * @param accept
353     *         Accept header
354     * @return HTTP 200 class status that was returned
355     */
356    protected int sendSignedRequest(URL url, @Nullable JSONBuilder claims,
357                                    Session session, String accept, RequestSigner signer)
358            throws AcmeException {
359        Objects.requireNonNull(url, "url");
360        Objects.requireNonNull(session, "session");
361        Objects.requireNonNull(accept, "accept");
362        Objects.requireNonNull(signer, "signer");
363        assertConnectionIsClosed();
364
365        var attempt = 1;
366        while (true) {
367            try {
368                return performRequest(url, claims, session, accept, signer);
369            } catch (AcmeServerException ex) {
370                if (!BAD_NONCE_ERROR.equals(ex.getType())) {
371                    throw ex;
372                }
373                if (attempt == MAX_ATTEMPTS) {
374                    throw ex;
375                }
376                LOG.info("Bad Replay Nonce, trying again (attempt {}/{})", attempt, MAX_ATTEMPTS);
377                attempt++;
378            }
379        }
380    }
381
382    /**
383     * Performs the POST request.
384     *
385     * @param url
386     *         {@link URL} to send the request to.
387     * @param claims
388     *         {@link JSONBuilder} containing claims. {@code null} for POST-as-GET
389     *         request.
390     * @param accept
391     *         Accept header
392     * @return HTTP 200 class status that was returned
393     */
394    private int performRequest(URL url, @Nullable JSONBuilder claims, Session session,
395                               String accept, RequestSigner signer) throws AcmeException {
396        try (var nonceHolder = session.lockNonce()) {
397            if (nonceHolder.getNonce() == null) {
398                resetNonce(session);
399            }
400
401            var jose = signer.createRequest(url, claims, nonceHolder.getNonce());
402            var outputData = jose.toString();
403
404            sendRequest(session, url, builder -> {
405                builder.POST(HttpRequest.BodyPublishers.ofString(outputData));
406                builder.header(ACCEPT_HEADER, accept);
407                builder.header(CONTENT_TYPE_HEADER, "application/jose+json");
408            });
409
410            logHeaders();
411
412            nonceHolder.setNonce(getNonce().orElse(null));
413
414            var rc = getResponse().statusCode();
415            if (rc != HTTP_OK && rc != HTTP_CREATED) {
416                throwAcmeException();
417            }
418            return rc;
419        } catch (IOException ex) {
420            throw new AcmeNetworkException(ex);
421        }
422    }
423
424    @Override
425    public Optional<Instant> getRetryAfter() {
426        return getResponse().headers()
427                .firstValue(RETRY_AFTER_HEADER)
428                .map(this::parseRetryAfterHeader);
429    }
430
431    /**
432     * Parses the content of a Retry-After header. The header can either contain a
433     * relative or an absolute time.
434     *
435     * @param header
436     *         Retry-After header
437     * @return Instant given in the header
438     * @throws AcmeProtocolException
439     *         if the header content is invalid
440     */
441    private Instant parseRetryAfterHeader(String header) {
442        // See RFC 2616 section 14.37
443        try {
444            // delta-seconds
445            if (DIGITS_ONLY_PATTERN.matcher(header).matches()) {
446                var delta = Integer.parseInt(header);
447                var date = getResponse().headers().firstValue(DATE_HEADER)
448                        .map(d -> ZonedDateTime.parse(d, RFC_1123_DATE_TIME).toInstant())
449                        .orElseGet(Instant::now);
450                return date.plusSeconds(delta);
451            }
452
453            // HTTP-date
454            return ZonedDateTime.parse(header, RFC_1123_DATE_TIME).toInstant();
455        } catch (RuntimeException ex) {
456            throw new AcmeProtocolException("Bad retry-after header value: " + header, ex);
457        }
458    }
459
460    /**
461     * Provides an {@link InputStream} of the response body. If the stream is compressed,
462     * it will also take care for decompression.
463     */
464    private InputStream getResponseBody() throws IOException {
465        var stream = getResponse().body();
466        if (stream == null) {
467            throw new AcmeProtocolException("Unexpected empty response");
468        }
469
470        if (getResponse().headers().firstValue("Content-Encoding")
471                .filter("gzip"::equalsIgnoreCase)
472                .isPresent()) {
473            stream = new GZIPInputStream(stream);
474        }
475
476        return stream;
477    }
478
479    /**
480     * Throws an {@link AcmeException}. This method throws an exception that tries to
481     * explain the error as precisely as possible.
482     */
483    private void throwAcmeException() throws AcmeException {
484        try {
485            if (getResponse().headers().firstValue(CONTENT_TYPE_HEADER)
486                    .map(AcmeUtils::getContentType)
487                    .filter(MIME_JSON_PROBLEM::equals)
488                    .isEmpty()) {
489                // Generic HTTP error
490                throw new AcmeException("HTTP " + getResponse().statusCode());
491            }
492
493            var problem = new Problem(readJsonResponse(), getResponse().request().uri().toURL());
494
495            var error = AcmeUtils.stripErrorPrefix(problem.getType().toString());
496
497            if ("unauthorized".equals(error)) {
498                throw new AcmeUnauthorizedException(problem);
499            }
500
501            if ("userActionRequired".equals(error)) {
502                var tos = collectLinks("terms-of-service").stream()
503                        .findFirst()
504                        .map(this::resolveUri)
505                        .orElse(null);
506                throw new AcmeUserActionRequiredException(problem, tos);
507            }
508
509            if ("rateLimited".equals(error)) {
510                var retryAfter = getRetryAfter();
511                var rateLimits = getLinks("help");
512                throw new AcmeRateLimitedException(problem, retryAfter.orElse(null), rateLimits);
513            }
514
515            throw new AcmeServerException(problem);
516        } catch (IOException ex) {
517            throw new AcmeNetworkException(ex);
518        }
519    }
520
521    /**
522     * Checks if the returned content type is in the list of expected types.
523     *
524     * @param expectedTypes
525     *         content types that are accepted
526     * @throws AcmeProtocolException
527     *         if the returned content type is different
528     */
529    private void expectContentType(Set<String> expectedTypes) {
530        var contentType = getResponse().headers()
531                .firstValue(CONTENT_TYPE_HEADER)
532                .map(AcmeUtils::getContentType)
533                .orElseThrow(() -> new AcmeProtocolException("No content type header found"));
534        if (!expectedTypes.contains(contentType)) {
535            throw new AcmeProtocolException("Unexpected content type: " + contentType);
536        }
537    }
538
539    /**
540     * Returns the response of the last request. If there is no connection currently
541     * open, an exception is thrown instead.
542     * <p>
543     * Note that the response provides an {@link InputStream} that can be read only
544     * once.
545     */
546    private HttpResponse<InputStream> getResponse() {
547        if (lastResponse == null) {
548            throw new IllegalStateException("Not connected.");
549        }
550        return lastResponse;
551    }
552
553    /**
554     * Asserts that the connection is currently closed. Throws an exception if not.
555     */
556    private void assertConnectionIsClosed() {
557        if (lastResponse != null) {
558            throw new IllegalStateException("Previous connection is not closed.");
559        }
560    }
561
562    /**
563     * Log all HTTP headers in debug mode.
564     */
565    private void logHeaders() {
566        if (!LOG.isDebugEnabled()) {
567            return;
568        }
569
570        getResponse().headers().map().forEach((key, headers) ->
571                headers.forEach(value ->
572                        LOG.debug("HEADER {}: {}", key, value)
573                )
574        );
575    }
576
577    /**
578     * Collects links of the given relation.
579     *
580     * @param relation
581     *         Link relation
582     * @return Collection of links, unconverted
583     */
584    private Collection<String> collectLinks(String relation) {
585        var p = Pattern.compile("<(.*?)>\\s*;\\s*rel=\"?" + Pattern.quote(relation) + "\"?");
586
587        return getResponse().headers().allValues(LINK_HEADER)
588                .stream()
589                .map(p::matcher)
590                .filter(Matcher::matches)
591                .map(m -> m.group(1))
592                .peek(location -> LOG.debug("Link: {} -> {}", relation, location))
593                .toList();
594    }
595
596    /**
597     * Resolves a relative link against the connection's last URL.
598     *
599     * @param link
600     *         Link to resolve. Absolute links are just converted to an URL.
601     * @return Absolute URL of the given link
602     */
603    private URL resolveRelative(String link) {
604        try {
605            return resolveUri(link).toURL();
606        } catch (MalformedURLException ex) {
607            throw new AcmeProtocolException("Cannot resolve relative link: " + link, ex);
608        }
609    }
610
611    /**
612     * Resolves a relative URI against the connection's last URL.
613     *
614     * @param uri
615     *         URI to resolve
616     * @return Absolute URI of the given link
617     */
618    private URI resolveUri(String uri) {
619        return getResponse().request().uri().resolve(uri);
620    }
621
622}