com.linecorp.armeria.client.HttpConfigurator.java Source code

Java tutorial

Introduction

Here is the source code for com.linecorp.armeria.client.HttpConfigurator.java

Source

/*
 * Copyright 2015 LINE Corporation
 *
 * LINE Corporation licenses this file to you under the Apache License,
 * version 2.0 (the "License"); you may not use this file except in compliance
 * with the License. You may obtain a copy of the License at:
 *
 *   http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
 * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
 * License for the specific language governing permissions and limitations
 * under the License.
 */

package com.linecorp.armeria.client;

import static com.linecorp.armeria.common.SessionProtocol.H1;
import static com.linecorp.armeria.common.SessionProtocol.H1C;
import static com.linecorp.armeria.common.SessionProtocol.H2;
import static com.linecorp.armeria.common.SessionProtocol.H2C;
import static java.util.Objects.requireNonNull;

import java.net.InetSocketAddress;
import java.net.SocketAddress;
import java.util.List;

import javax.net.ssl.SSLException;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.linecorp.armeria.common.SessionProtocol;
import com.linecorp.armeria.common.http.AbstractHttpToHttp2ConnectionHandler;
import com.linecorp.armeria.common.http.Http1ClientCodec;
import com.linecorp.armeria.common.http.Http2GoAwayListener;
import com.linecorp.armeria.common.util.Exceptions;
import com.linecorp.armeria.common.util.NativeLibraries;

import io.netty.buffer.ByteBuf;
import io.netty.channel.Channel;
import io.netty.channel.ChannelDuplexHandler;
import io.netty.channel.ChannelHandler;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter;
import io.netty.channel.ChannelPipeline;
import io.netty.channel.ChannelPromise;
import io.netty.handler.codec.ByteToMessageDecoder;
import io.netty.handler.codec.http.DefaultFullHttpRequest;
import io.netty.handler.codec.http.FullHttpRequest;
import io.netty.handler.codec.http.FullHttpResponse;
import io.netty.handler.codec.http.HttpClientUpgradeHandler;
import io.netty.handler.codec.http.HttpClientUpgradeHandler.UpgradeEvent;
import io.netty.handler.codec.http.HttpHeaderNames;
import io.netty.handler.codec.http.HttpHeaders;
import io.netty.handler.codec.http.HttpMethod;
import io.netty.handler.codec.http.HttpObjectAggregator;
import io.netty.handler.codec.http.HttpResponse;
import io.netty.handler.codec.http.HttpResponseStatus;
import io.netty.handler.codec.http.HttpVersion;
import io.netty.handler.codec.http2.DefaultHttp2Connection;
import io.netty.handler.codec.http2.DefaultHttp2ConnectionDecoder;
import io.netty.handler.codec.http2.DefaultHttp2ConnectionEncoder;
import io.netty.handler.codec.http2.DefaultHttp2FrameReader;
import io.netty.handler.codec.http2.DefaultHttp2FrameWriter;
import io.netty.handler.codec.http2.Http2ClientUpgradeCodec;
import io.netty.handler.codec.http2.Http2CodecUtil;
import io.netty.handler.codec.http2.Http2Connection;
import io.netty.handler.codec.http2.Http2ConnectionDecoder;
import io.netty.handler.codec.http2.Http2ConnectionEncoder;
import io.netty.handler.codec.http2.Http2ConnectionHandler;
import io.netty.handler.codec.http2.Http2FrameReader;
import io.netty.handler.codec.http2.Http2FrameWriter;
import io.netty.handler.codec.http2.Http2SecurityUtil;
import io.netty.handler.codec.http2.Http2Settings;
import io.netty.handler.codec.http2.HttpConversionUtil.ExtensionHeaderNames;
import io.netty.handler.codec.http2.InboundHttp2ToHttpAdapter;
import io.netty.handler.codec.http2.InboundHttp2ToHttpAdapterBuilder;
import io.netty.handler.ssl.ApplicationProtocolConfig;
import io.netty.handler.ssl.ApplicationProtocolNames;
import io.netty.handler.ssl.SslContext;
import io.netty.handler.ssl.SslContextBuilder;
import io.netty.handler.ssl.SslHandler;
import io.netty.handler.ssl.SslHandshakeCompletionEvent;
import io.netty.handler.ssl.SslProvider;
import io.netty.handler.ssl.SupportedCipherSuiteFilter;
import io.netty.util.AsciiString;

class HttpConfigurator extends ChannelDuplexHandler {

    private static final Logger logger = LoggerFactory.getLogger(HttpConfigurator.class);

    private enum HttpPreference {
        HTTP1_REQUIRED, HTTP2_PREFERRED, HTTP2_REQUIRED
    }

    private final SslContext sslCtx;
    private final HttpPreference httpPreference;
    private final RemoteInvokerOptions options;
    private InetSocketAddress remoteAddress;

    HttpConfigurator(SessionProtocol sessionProtocol, RemoteInvokerOptions options) {
        switch (sessionProtocol) {
        case HTTP:
        case HTTPS:
            httpPreference = HttpPreference.HTTP2_PREFERRED;
            break;
        case H1:
        case H1C:
            httpPreference = HttpPreference.HTTP1_REQUIRED;
            break;
        case H2:
        case H2C:
            httpPreference = HttpPreference.HTTP2_REQUIRED;
            break;
        default:
            // Should never reach here.
            throw new Error();
        }

        this.options = requireNonNull(options, "options");

        if (sessionProtocol.isTls()) {
            try {
                final SslContextBuilder builder = SslContextBuilder.forClient();

                builder.sslProvider(NativeLibraries.isOpenSslAvailable() ? SslProvider.OPENSSL : SslProvider.JDK);
                options.trustManagerFactory().ifPresent(builder::trustManager);

                if (httpPreference == HttpPreference.HTTP2_REQUIRED
                        || httpPreference == HttpPreference.HTTP2_PREFERRED) {

                    builder.ciphers(Http2SecurityUtil.CIPHERS, SupportedCipherSuiteFilter.INSTANCE)
                            .applicationProtocolConfig(
                                    new ApplicationProtocolConfig(ApplicationProtocolConfig.Protocol.ALPN,
                                            // NO_ADVERTISE is currently the only mode supported by both OpenSsl and
                                            // JDK providers.
                                            ApplicationProtocolConfig.SelectorFailureBehavior.NO_ADVERTISE,
                                            // ACCEPT is currently the only mode supported by both OpenSsl and JDK
                                            // providers.
                                            ApplicationProtocolConfig.SelectedListenerFailureBehavior.ACCEPT,
                                            ApplicationProtocolNames.HTTP_2));
                }
                sslCtx = builder.build();
            } catch (SSLException e) {
                throw new IllegalStateException("failed to create a SslContext", e);
            }
        } else {
            sslCtx = null;
        }
    }

    @Override
    public void connect(ChannelHandlerContext ctx, SocketAddress remoteAddress, SocketAddress localAddress,
            ChannelPromise promise) throws Exception {

        // Remember the requested remote address for later use.
        this.remoteAddress = (InetSocketAddress) remoteAddress;

        // Configure the pipeline.
        final Channel ch = ctx.channel();
        try {
            if (sslCtx != null) {
                configureAsHttps(ch);
            } else {
                configureAsHttp(ch);
            }
        } catch (Throwable t) {
            promise.tryFailure(t);
            ctx.close();
        } finally {
            final ChannelPipeline pipeline = ch.pipeline();
            if (pipeline.context(this) != null) {
                pipeline.remove(this);
            }
        }

        ctx.connect(remoteAddress, localAddress, promise);
    }

    // refer https://http2.github.io/http2-spec/#discover-https
    private void configureAsHttps(Channel ch) {
        ChannelPipeline pipeline = ch.pipeline();
        SslHandler sslHandler = sslCtx.newHandler(ch.alloc());
        pipeline.addLast(sslHandler);
        pipeline.addLast(new ChannelInboundHandlerAdapter() {
            @Override
            public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
                if (!(evt instanceof SslHandshakeCompletionEvent)) {
                    ctx.fireUserEventTriggered(evt);
                    return;
                }

                final SslHandshakeCompletionEvent handshakeEvent = (SslHandshakeCompletionEvent) evt;
                if (!handshakeEvent.isSuccess()) {
                    // The connection will be closed automatically by SslHandler.
                    return;
                }

                final SessionProtocol protocol;
                if (isHttp2Protocol(sslHandler)) {
                    if (httpPreference == HttpPreference.HTTP1_REQUIRED) {
                        finishWithNegotiationFailure(ctx, H1, H2, "unexpected protocol negotiation result");
                        return;
                    }

                    addBeforeSessionHandler(pipeline, newHttp2ConnectionHandler(ch));
                    protocol = H2;
                } else {
                    if (httpPreference != HttpPreference.HTTP1_REQUIRED) {
                        SessionProtocolNegotiationCache.setUnsupported(ctx.channel().remoteAddress(), H2);
                    }

                    if (httpPreference == HttpPreference.HTTP2_REQUIRED) {
                        finishWithNegotiationFailure(ctx, H2, H1, "unexpected protocol negotiation result");
                        return;
                    }

                    addBeforeSessionHandler(pipeline, newHttp1Codec());
                    protocol = H1;
                }
                finishSuccessfully(pipeline, protocol);
                pipeline.remove(this);
            }

            @Override
            public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
                Exceptions.logIfUnexpected(logger, ctx.channel(), null, cause);
                ctx.close();
            }
        });
    }

    // refer https://http2.github.io/http2-spec/#discover-http
    private void configureAsHttp(Channel ch) {
        final ChannelPipeline pipeline = ch.pipeline();

        final boolean attemptUpgrade;
        switch (httpPreference) {
        case HTTP1_REQUIRED:
            attemptUpgrade = false;
            break;
        case HTTP2_PREFERRED:
            attemptUpgrade = !SessionProtocolNegotiationCache.isUnsupported(remoteAddress, H2C);
            break;
        case HTTP2_REQUIRED:
            attemptUpgrade = true;
            break;
        default:
            // Should never reach here.
            throw new Error();
        }

        if (attemptUpgrade) {
            if (options.useHttp2Preface()) {
                pipeline.addLast(new DowngradeHandler());
                pipeline.addLast(newHttp2ConnectionHandler(ch));
            } else {
                Http1ClientCodec http1Codec = newHttp1Codec();
                Http2ClientUpgradeCodec http2ClientUpgradeCodec = new Http2ClientUpgradeCodec(
                        newHttp2ConnectionHandler(ch));
                HttpClientUpgradeHandler http2UpgradeHandler = new HttpClientUpgradeHandler(http1Codec,
                        http2ClientUpgradeCodec, options.maxFrameLength());

                pipeline.addLast(http1Codec);
                pipeline.addLast(new WorkaroundHandler());
                pipeline.addLast(http2UpgradeHandler);
                pipeline.addLast(new UpgradeRequestHandler());
            }
        } else {
            pipeline.addLast(newHttp1Codec());

            // NB: We do not call finishSuccessfully() immediately here
            //     because it assumes HttpSessionHandler to be in the pipeline,
            //     which is only true after the connection attempt is successful.
            pipeline.addLast(new ChannelInboundHandlerAdapter() {
                @Override
                public void channelActive(ChannelHandlerContext ctx) throws Exception {
                    ctx.pipeline().remove(this);
                    finishSuccessfully(pipeline, H1C);
                    ctx.fireChannelActive();
                }
            });
        }
    }

    // FIXME: Ensure unnecessary handlers are all removed from the pipeline for all protocol types.
    void finishSuccessfully(ChannelPipeline pipeline, SessionProtocol protocol) {

        switch (protocol) {
        case H1:
        case H1C:
            addBeforeSessionHandler(pipeline, new HttpObjectAggregator(options.maxFrameLength()));
            break;
        case H2:
        case H2C:
            // HTTP/2 does not require the aggregator because
            // InboundHttp2ToHttpAdapter always creates a FullHttpRequest.
            break;
        default:
            // Should never reach here.
            throw new Error();
        }

        final long idleTimeoutMillis = options.idleTimeoutMillis();
        if (idleTimeoutMillis > 0) {
            final HttpClientIdleTimeoutHandler timeoutHandler;
            if (protocol == H2 || protocol == H2C) {
                timeoutHandler = new Http2ClientIdleTimeoutHandler(idleTimeoutMillis);
            } else {
                // Note: We should not use Http2ClientIdleTimeoutHandler for HTTP/1 connections,
                //       because we cannot tell if the headers defined in ExtensionHeaderNames such as
                //       'x-http2-stream-id' have been set by us or a malicious server.
                timeoutHandler = new HttpClientIdleTimeoutHandler(idleTimeoutMillis);
            }
            addBeforeSessionHandler(pipeline, timeoutHandler);
        }

        pipeline.channel().eventLoop().execute(() -> pipeline.fireUserEventTriggered(protocol));
    }

    void addBeforeSessionHandler(ChannelPipeline pipeline, ChannelHandler handler) {
        // Get the name of the HttpSessionHandler so that we can put our handlers before it.
        final String sessionHandlerName = pipeline.context(HttpSessionHandler.class).name();
        pipeline.addBefore(sessionHandlerName, null, handler);
    }

    void finishWithNegotiationFailure(ChannelHandlerContext ctx, SessionProtocol expected, SessionProtocol actual,
            String reason) {

        final ChannelPipeline pipeline = ctx.pipeline();
        pipeline.channel().eventLoop().execute(() -> pipeline
                .fireUserEventTriggered(new SessionProtocolNegotiationException(expected, actual, reason)));
        ctx.close();
    }

    boolean isHttp2Protocol(SslHandler sslHandler) {
        return ApplicationProtocolNames.HTTP_2.equals(sslHandler.applicationProtocol());
    }

    /**
     * A handler that triggers the cleartext upgrade to HTTP/2 by sending an initial HTTP request.
     */
    private final class UpgradeRequestHandler extends ChannelInboundHandlerAdapter {

        private UpgradeEvent upgradeEvt;
        private boolean receivedUpgradeRes;

        /**
         * Sends the initial upgrade request, which is {@code "HEAD / HTTP/1.1"}
         */
        @Override
        public void channelActive(ChannelHandlerContext ctx) throws Exception {
            final FullHttpRequest upgradeReq = new DefaultFullHttpRequest(HttpVersion.HTTP_1_1, HttpMethod.HEAD,
                    "/");

            // Note: There's no need to fill Connection, Upgrade, and HTTP2-Settings headers here
            //       because they are filled by Http2ClientUpgradeCodec.

            final String host = HttpHostHeaderUtil.hostHeader(remoteAddress.getHostString(),
                    remoteAddress.getPort(), sslCtx != null);

            upgradeReq.headers().set(HttpHeaderNames.HOST, host);

            ctx.writeAndFlush(upgradeReq);
            ctx.fireChannelActive();
        }

        /**
         * Keeps the upgrade result in {@link #upgradeEvt}.
         */
        @Override
        public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
            if (!(evt instanceof UpgradeEvent)) {
                ctx.fireUserEventTriggered(evt);
                return;
            }

            final UpgradeEvent upgradeEvt = (UpgradeEvent) evt;
            if (upgradeEvt == UpgradeEvent.UPGRADE_ISSUED) {
                // Uninterested in this event
                return;
            }

            this.upgradeEvt = upgradeEvt;
        }

        /**
         * Waits until the upgrade response is received, and performs the final configuration of the pipeline
         * based on the upgrade result.
         */
        @Override
        public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
            if (!receivedUpgradeRes && msg instanceof FullHttpResponse) {
                final FullHttpResponse res = (FullHttpResponse) msg;
                final HttpHeaders headers = res.headers();
                final String streamId = headers.get(ExtensionHeaderNames.STREAM_ID.text());
                if (streamId == null || "1".equals(streamId)) {
                    // Received the response for the upgrade request sent in channelActive().
                    receivedUpgradeRes = true;
                    onUpgradeResponse(ctx, res);
                    return;
                }
            }

            ctx.fireChannelRead(msg);
        }

        private void onUpgradeResponse(ChannelHandlerContext ctx, FullHttpResponse upgradeRes) {
            final UpgradeEvent upgradeEvt = this.upgradeEvt;
            assert upgradeEvt != null : "received an upgrade response before an UpgradeEvent";

            // Not interested in the content.
            upgradeRes.release();
            final ChannelPipeline p = ctx.pipeline();

            // Done with this handler, remove it from the pipeline.
            p.remove(this);

            if ("close".equalsIgnoreCase(upgradeRes.headers().get(HttpHeaderNames.CONNECTION))) {
                // Server wants us to close the connection, which means we cannot use this connection
                // to send the request that contains the actual invocation.
                SessionProtocolNegotiationCache.setUnsupported(ctx.channel().remoteAddress(), H2C);

                if (httpPreference == HttpPreference.HTTP2_REQUIRED) {
                    finishWithNegotiationFailure(ctx, H2C, H1C, "upgrade response with 'Connection: close' header");
                } else {
                    // We can silently retry with H1C.
                    retryWithH1C(ctx);
                }
                return;
            }

            switch (upgradeEvt) {
            case UPGRADE_SUCCESSFUL:
                finishSuccessfully(p, H2C);
                break;
            case UPGRADE_REJECTED:
                SessionProtocolNegotiationCache.setUnsupported(ctx.channel().remoteAddress(), H2C);

                if (httpPreference == HttpPreference.HTTP2_REQUIRED) {
                    finishWithNegotiationFailure(ctx, H2C, H1C, "upgrade request rejected");
                    return;
                }

                finishSuccessfully(p, H1C);
                break;
            default:
                // Should never reach here.
                throw new Error();
            }
        }
    }

    /**
     * A handler that closes an HTTP/2 connection when the server responds with an HTTP/1 response, so that
     * HTTP/1 is used instead of HTTP/2 on next connection attempt.
     */
    private final class DowngradeHandler extends ByteToMessageDecoder {

        private boolean handledResponse;

        @Override
        protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception {
            if (in.readableBytes() < 4) {
                return;
            }

            handledResponse = true;

            final ChannelPipeline p = ctx.pipeline();

            if (in.getInt(in.readerIndex()) == 0x48545450) { // If the response starts with 'HTTP'
                // Http2ConnectionHandler sent the preface string, but the server responded with an HTTP/1
                // response. i.e. The server does not support HTTP/2.
                SessionProtocolNegotiationCache.setUnsupported(ctx.channel().remoteAddress(), H2C);
                if (httpPreference == HttpPreference.HTTP2_REQUIRED) {
                    finishWithNegotiationFailure(ctx, H2C, H1C,
                            "received an HTTP/1 response for the HTTP/2 preface string");
                } else {
                    // We can silently retry with H1C.
                    retryWithH1C(ctx);
                }

                // We are going to close the connection really soon, so we don't need the response.
                in.skipBytes(in.readableBytes());
            } else {
                // The server responded with a non-HTTP/1 response. Continue treating the connection as HTTP/2.
                finishSuccessfully(p, H2C);
            }

            p.remove(this);
        }

        @Override
        protected void decodeLast(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception {
            super.decodeLast(ctx, in, out);
            if (!handledResponse) {
                // If the connection has been closed even without receiving anything useful,
                // it is likely that the server failed to decode the preface string.
                if (httpPreference == HttpPreference.HTTP2_REQUIRED) {
                    finishWithNegotiationFailure(ctx, H2C, H1C, "too little data to determine the HTTP version");
                } else {
                    // We can silently retry with H1C.
                    retryWithH1C(ctx);
                }
            }
        }
    }

    static void retryWithH1C(ChannelHandlerContext ctx) {
        HttpSessionHandler.get(ctx.channel()).retryWithH1C();
        ctx.close();
    }

    private Http2ConnectionHandler newHttp2ConnectionHandler(Channel ch) {
        final boolean validateHeaders = false;
        final Http2Connection conn = new DefaultHttp2Connection(false);
        conn.addListener(new Http2GoAwayListener(ch));
        final InboundHttp2ToHttpAdapter listener = new InboundHttp2ToHttpAdapterBuilder(conn)
                .propagateSettings(true).validateHttpHeaders(validateHeaders)
                .maxContentLength(options.maxFrameLength()).build();

        Http2FrameReader reader = new DefaultHttp2FrameReader(validateHeaders);
        Http2FrameWriter writer = new DefaultHttp2FrameWriter();

        Http2ConnectionEncoder encoder = new DefaultHttp2ConnectionEncoder(conn, writer);
        Http2ConnectionDecoder decoder = new DefaultHttp2ConnectionDecoder(conn, encoder, reader);

        final HttpToHttp2ClientConnectionHandler handler = new HttpToHttp2ClientConnectionHandler(decoder, encoder,
                new Http2Settings(), validateHeaders);

        // Setup post build options
        handler.gracefulShutdownTimeoutMillis(options.idleTimeoutMillis());
        handler.decoder().frameListener(listener);

        return handler;
    }

    private static Http1ClientCodec newHttp1Codec() {
        return new Http1ClientCodec() {
            @Override
            public void close(ChannelHandlerContext ctx, ChannelPromise promise) throws Exception {
                HttpSessionHandler.get(ctx.channel()).deactivate();
                super.close(ctx, promise);
            }
        };
    }

    private static final class HttpToHttp2ClientConnectionHandler extends AbstractHttpToHttp2ConnectionHandler {

        HttpToHttp2ClientConnectionHandler(Http2ConnectionDecoder decoder, Http2ConnectionEncoder encoder,
                Http2Settings initialSettings, boolean validateHeaders) {
            super(decoder, encoder, initialSettings, validateHeaders);
        }

        @Override
        public void channelActive(ChannelHandlerContext ctx) throws Exception {
            super.channelActive(ctx);

            // NB: Http2ConnectionHandler does not flush the preface string automatically.
            ctx.flush();
        }

        @Override
        protected void onCloseRequest(ChannelHandlerContext ctx) throws Exception {
            HttpSessionHandler.get(ctx.channel()).deactivate();
        }
    }

    /**
     * Workaround handler for interoperability with Jetty.
     * - Jetty performs case-sensitive comparison for the Connection header value. (upgrade vs Upgrade)
     * - Jetty does not send 'Upgrade: h2c' header in its 101 Switching Protocol response.
     */
    private static final class WorkaroundHandler extends ChannelDuplexHandler {

        private static final AsciiString CONNECTION_VALUE = new AsciiString("HTTP2-Settings,Upgrade");

        private boolean needsToFilterUpgradeResponse = true;
        private boolean needsToFilterUpgradeRequest = true;

        @Override
        public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
            if (needsToFilterUpgradeResponse && msg instanceof HttpResponse) {
                needsToFilterUpgradeResponse = false;
                final HttpResponse res = (HttpResponse) msg;
                if (res.status().code() == HttpResponseStatus.SWITCHING_PROTOCOLS.code()) {
                    final HttpHeaders headers = res.headers();
                    if (!headers.contains(HttpHeaderNames.UPGRADE)) {
                        headers.set(HttpHeaderNames.UPGRADE, Http2CodecUtil.HTTP_UPGRADE_PROTOCOL_NAME);
                    }
                }

                if (!needsToFilterUpgradeRequest) {
                    ctx.pipeline().remove(this);
                }
            }

            ctx.fireChannelRead(msg);
        }

        @Override
        public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception {
            if (needsToFilterUpgradeRequest) {
                needsToFilterUpgradeRequest = false;
                final FullHttpRequest req = (FullHttpRequest) msg;
                req.headers().set(HttpHeaderNames.CONNECTION, CONNECTION_VALUE);

                if (!needsToFilterUpgradeResponse) {
                    ctx.pipeline().remove(this);
                }
            }

            super.write(ctx, msg, promise);
        }
    }
}