/* * Copyright 2016 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.server.http.tomcat; import static com.linecorp.armeria.common.util.Functions.voidFunction; import static java.util.Objects.requireNonNull; import java.net.InetSocketAddress; import java.nio.charset.StandardCharsets; import java.nio.file.Path; import java.util.ArrayDeque; import java.util.HashSet; import java.util.Map.Entry; import java.util.Queue; import java.util.Set; import java.util.function.Consumer; import java.util.function.Function; import javax.annotation.Nullable; import org.apache.catalina.Engine; import org.apache.catalina.LifecycleState; import org.apache.catalina.Service; import org.apache.catalina.connector.Connector; import org.apache.catalina.startup.Tomcat; import org.apache.catalina.util.ServerInfo; import org.apache.catalina.util.URLEncoder; import org.apache.coyote.Adapter; import org.apache.coyote.Request; import org.apache.coyote.Response; import org.apache.tomcat.util.buf.ByteChunk; import org.apache.tomcat.util.buf.CharChunk; import org.apache.tomcat.util.buf.MessageBytes; import org.apache.tomcat.util.http.MimeHeaders; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import com.google.common.collect.Sets; import com.linecorp.armeria.common.http.AggregatedHttpMessage; import com.linecorp.armeria.common.http.DefaultHttpResponse; import com.linecorp.armeria.common.http.HttpData; import com.linecorp.armeria.common.http.HttpHeaderNames; import com.linecorp.armeria.common.http.HttpHeaders; import com.linecorp.armeria.common.http.HttpMethod; import com.linecorp.armeria.common.http.HttpRequest; import com.linecorp.armeria.common.http.HttpResponse; import com.linecorp.armeria.common.http.HttpStatus; import com.linecorp.armeria.common.util.CompletionActions; import com.linecorp.armeria.server.Server; import com.linecorp.armeria.server.ServerListener; import com.linecorp.armeria.server.ServerListenerAdapter; import com.linecorp.armeria.server.ServiceConfig; import com.linecorp.armeria.server.ServiceRequestContext; import com.linecorp.armeria.server.ServiceUnavailableException; import com.linecorp.armeria.server.http.HttpService; import io.netty.util.AsciiString; /** * An {@link HttpService} that dispatches its requests to a web application running in an embedded * <a href="http://tomcat.apache.org/">Tomcat</a>. * * @see TomcatServiceBuilder */ public final class TomcatService implements HttpService { private static final Logger logger = LoggerFactory.getLogger(TomcatService.class); private static final Set<LifecycleState> TOMCAT_START_STATES = Sets.immutableEnumSet( LifecycleState.STARTED, LifecycleState.STARTING, LifecycleState.STARTING_PREP); private static final URLEncoder TOMCAT_URL_ENCODER; static { // Initialize the default URLEncoder. // NB: We could have used URLEncoder.DEFAULT, but it's not available in pre-8.5. TOMCAT_URL_ENCODER = new URLEncoder(); TOMCAT_URL_ENCODER.addSafeCharacter('~'); TOMCAT_URL_ENCODER.addSafeCharacter('-'); TOMCAT_URL_ENCODER.addSafeCharacter('_'); TOMCAT_URL_ENCODER.addSafeCharacter('.'); TOMCAT_URL_ENCODER.addSafeCharacter('*'); TOMCAT_URL_ENCODER.addSafeCharacter('/'); } static final TomcatHandler TOMCAT_HANDLER; static { final String prefix = TomcatService.class.getPackage().getName() + '.'; final ClassLoader classLoader = TomcatService.class.getClassLoader(); final Class<?> handlerClass; try { if (TomcatVersion.major() < 8 || TomcatVersion.major() == 8 && TomcatVersion.minor() < 5) { handlerClass = Class.forName(prefix + "Tomcat80Handler", true, classLoader); } else { handlerClass = Class.forName(prefix + "Tomcat85Handler", true, classLoader); } TOMCAT_HANDLER = (TomcatHandler) handlerClass.getDeclaredConstructor().newInstance(); } catch (ReflectiveOperationException e) { throw new IllegalStateException( "could not find the matching classes for Tomcat version " + ServerInfo.getServerNumber() + "; using a wrong armeria-tomcat JAR?", e); } } private static final Set<String> activeEngines = new HashSet<>(); /** * Creates a new {@link TomcatService} with the web application at the root directory inside the * JAR/WAR/directory where the caller class is located at. */ public static TomcatService forCurrentClassPath() { return TomcatServiceBuilder.forCurrentClassPath(3).build(); } /** * Creates a new {@link TomcatService} with the web application at the specified document base directory * inside the JAR/WAR/directory where the caller class is located at. */ public static TomcatService forCurrentClassPath(String docBase) { return TomcatServiceBuilder.forCurrentClassPath(docBase, 3).build(); } /** * Creates a new {@link TomcatService} with the web application at the root directory inside the * JAR/WAR/directory where the specified class is located at. */ public static TomcatService forClassPath(Class<?> clazz) { return TomcatServiceBuilder.forClassPath(clazz).build(); } /** * Creates a new {@link TomcatService} with the web application at the specified document base directory * inside the JAR/WAR/directory where the specified class is located at. */ public static TomcatService forClassPath(Class<?> clazz, String docBase) { return TomcatServiceBuilder.forClassPath(clazz, docBase).build(); } /** * Creates a new {@link TomcatService} with the web application at the specified document base, which can * be a directory or a JAR/WAR file. */ public static TomcatService forFileSystem(String docBase) { return TomcatServiceBuilder.forFileSystem(docBase).build(); } /** * Creates a new {@link TomcatService} with the web application at the specified document base, which can * be a directory or a JAR/WAR file. */ public static TomcatService forFileSystem(Path docBase) { return TomcatServiceBuilder.forFileSystem(docBase).build(); } /** * Creates a new {@link TomcatService} from an existing {@link Tomcat} instance. * If the specified {@link Tomcat} instance is not configured properly, the returned {@link TomcatService} * may respond with '503 Service Not Available' error. */ public static TomcatService forTomcat(Tomcat tomcat) { requireNonNull(tomcat, "tomcat"); final String hostname = tomcat.getEngine().getDefaultHost(); if (hostname == null) { throw new IllegalArgumentException("default hostname not configured: " + tomcat); } final Connector connector = tomcat.getConnector(); if (connector == null) { throw new IllegalArgumentException("connector not configured: " + tomcat); } return forConnector(hostname, connector); } /** * Creates a new {@link TomcatService} from an existing Tomcat {@link Connector} instance. * If the specified {@link Connector} instance is not configured properly, the returned * {@link TomcatService} may respond with '503 Service Not Available' error. */ public static TomcatService forConnector(Connector connector) { requireNonNull(connector, "connector"); return new TomcatService(null, hostname -> connector); } /** * Creates a new {@link TomcatService} from an existing Tomcat {@link Connector} instance. * If the specified {@link Connector} instance is not configured properly, the returned * {@link TomcatService} may respond with '503 Service Not Available' error. */ public static TomcatService forConnector(String hostname, Connector connector) { requireNonNull(hostname, "hostname"); requireNonNull(connector, "connector"); return new TomcatService(hostname, h -> connector); } static TomcatService forConfig(TomcatServiceConfig config) { final Consumer<Connector> postStopTask = connector -> { final org.apache.catalina.Server server = connector.getService().getServer(); if (server.getState() == LifecycleState.STOPPED) { try { logger.info("Destroying an embedded Tomcat: {}", toString(server)); server.destroy(); } catch (Exception e) { logger.warn("Failed to destroy an embedded Tomcat: {}", toString(server), e); } } }; return new TomcatService(null, new ManagedConnectorFactory(config), postStopTask); } static String toString(org.apache.catalina.Server server) { requireNonNull(server, "server"); final Service[] services = server.findServices(); final String serviceName; if (services.length == 0) { serviceName = "<unknown>"; } else { serviceName = services[0].getName(); } final StringBuilder buf = new StringBuilder(128); buf.append("(serviceName: "); buf.append(serviceName); if (TomcatVersion.major() >= 8) { buf.append(", catalinaBase: " + server.getCatalinaBase()); } buf.append(')'); return buf.toString(); } private final Function<String, Connector> connectorFactory; private final Consumer<Connector> postStopTask; private final ServerListener configurator; private org.apache.catalina.Server server; private Server armeriaServer; private String hostname; private Connector connector; private String engineName; private boolean started; private TomcatService(String hostname, Function<String, Connector> connectorFactory) { this(hostname, connectorFactory, unused -> { /* unused */ }); } private TomcatService(String hostname, Function<String, Connector> connectorFactory, Consumer<Connector> postStopTask) { this.hostname = hostname; this.connectorFactory = connectorFactory; this.postStopTask = postStopTask; configurator = new Configurator(); } @Override public void serviceAdded(ServiceConfig cfg) throws Exception { if (hostname == null) { hostname = cfg.server().defaultHostname(); } if (armeriaServer != null) { if (armeriaServer != cfg.server()) { throw new IllegalStateException("cannot be added to more than one server"); } else { return; } } armeriaServer = cfg.server(); armeriaServer.addListener(configurator); } /** * Returns Tomcat {@link Connector}. */ public Connector connector() { final Connector connector = this.connector; if (connector == null) { throw new IllegalStateException("not started yet"); } return connector; } void start() throws Exception { started = false; connector = connectorFactory.apply(hostname); final Service service = connector.getService(); if (service == null) { return; } final Engine engine = TomcatUtil.engine(service); if (engine == null) { return; } final String engineName = engine.getName(); if (engineName == null) { return; } if (activeEngines.contains(engineName)) { throw new TomcatServiceException("duplicate engine name: " + engineName); } server = service.getServer(); if (!TOMCAT_START_STATES.contains(server.getState())) { logger.info("Starting an embedded Tomcat: {}", toString(server)); server.start(); started = true; } activeEngines.add(engineName); this.engineName = engineName; } void stop() throws Exception { final org.apache.catalina.Server server = this.server; final Connector connector = this.connector; this.server = null; this.connector = null; if (engineName != null) { activeEngines.remove(engineName); engineName = null; } if (server == null || !started) { return; } try { logger.info("Stopping an embedded Tomcat: {}", toString(server)); server.stop(); } catch (Exception e) { logger.warn("Failed to stop an embedded Tomcat: {}", toString(server), e); } postStopTask.accept(connector); } @Override public HttpResponse serve(ServiceRequestContext ctx, HttpRequest req) throws Exception { final Adapter coyoteAdapter = connector().getProtocolHandler().getAdapter(); if (coyoteAdapter == null) { // Tomcat is not configured / stopped. throw ServiceUnavailableException.get(); } final DefaultHttpResponse res = new DefaultHttpResponse(); req.aggregate().handle(voidFunction((aReq, cause) -> { if (cause != null) { logger.warn("{} Failed to aggregate a request:", ctx, cause); res.respond(HttpStatus.INTERNAL_SERVER_ERROR); return; } try { final Request coyoteReq = convertRequest(ctx, aReq); if (coyoteReq == null) { res.respond(HttpStatus.BAD_REQUEST); return; } final Response coyoteRes = new Response(); coyoteReq.setResponse(coyoteRes); coyoteRes.setRequest(coyoteReq); final Queue<HttpData> data = new ArrayDeque<>(); coyoteRes.setOutputBuffer(TOMCAT_HANDLER.outputBuffer(data)); ctx.blockingTaskExecutor().execute(() -> { if (!res.isOpen()) { return; } try { coyoteAdapter.service(coyoteReq, coyoteRes); final HttpHeaders headers = convertResponse(coyoteRes); res.write(headers); for (;;) { final HttpData d = data.poll(); if (d == null) { break; } res.write(d); } res.close(); } catch (Throwable t) { logger.warn("{} Failed to produce a response:", ctx, t); res.close(); } }); } catch (Throwable t) { logger.warn("{} Failed to invoke Tomcat:", ctx, t); res.close(); } })).exceptionally(CompletionActions::log); return res; } @Nullable private Request convertRequest(ServiceRequestContext ctx, AggregatedHttpMessage req) { final String mappedPath = ctx.mappedPath(); final Request coyoteReq = new Request(); coyoteReq.scheme().setString(req.scheme()); // Set the remote host/address. final InetSocketAddress remoteAddr = ctx.remoteAddress(); coyoteReq.remoteAddr().setString(remoteAddr.getAddress().getHostAddress()); coyoteReq.remoteHost().setString(remoteAddr.getHostString()); coyoteReq.setRemotePort(remoteAddr.getPort()); // Set the local host/address. final InetSocketAddress localAddr = ctx.localAddress(); coyoteReq.localAddr().setString(localAddr.getAddress().getHostAddress()); coyoteReq.localName().setString(hostname); coyoteReq.setLocalPort(localAddr.getPort()); final String hostHeader = req.headers().authority(); int colonPos = hostHeader.indexOf(':'); if (colonPos < 0) { coyoteReq.serverName().setString(hostHeader); } else { coyoteReq.serverName().setString(hostHeader.substring(0, colonPos)); try { int port = Integer.parseInt(hostHeader.substring(colonPos + 1)); coyoteReq.setServerPort(port); } catch (NumberFormatException e) { return null; } } // Set the method. final HttpMethod method = req.method(); coyoteReq.method().setString(method.name()); // Set the request URI. // Do not use URLEncoder.encode(path, encoding); it is not available in some older Tomcats. @SuppressWarnings("deprecation") final byte[] uriBytes = TOMCAT_URL_ENCODER.encode(mappedPath) .getBytes(StandardCharsets.US_ASCII); coyoteReq.requestURI().setBytes(uriBytes, 0, uriBytes.length); // Set the query string if any. final int queryIndex = req.path().indexOf('?'); if (queryIndex >= 0) { coyoteReq.queryString().setString(req.path().substring(queryIndex + 1)); } // Set the headers. final MimeHeaders cHeaders = coyoteReq.getMimeHeaders(); convertHeaders(req.headers(), cHeaders); convertHeaders(req.trailingHeaders(), cHeaders); // Set the content. final HttpData content = req.content(); coyoteReq.setInputBuffer(TOMCAT_HANDLER.inputBuffer(content)); return coyoteReq; } private static void convertHeaders(HttpHeaders headers, MimeHeaders cHeaders) { if (headers.isEmpty()) { return; } for (Entry<AsciiString, String> e : headers) { final AsciiString k = e.getKey(); final String v = e.getValue(); if (k.isEmpty() || k.byteAt(0) == ':') { continue; } final MessageBytes cValue = cHeaders.addValue(k.array(), k.arrayOffset(), k.length()); final byte[] valueBytes = v.getBytes(StandardCharsets.US_ASCII); cValue.setBytes(valueBytes, 0, valueBytes.length); } } private static HttpHeaders convertResponse(Response coyoteRes) { final HttpHeaders headers = HttpHeaders.of(HttpStatus.valueOf(coyoteRes.getStatus())); final String contentType = coyoteRes.getContentType(); if (contentType != null && !contentType.isEmpty()) { headers.set(HttpHeaderNames.CONTENT_TYPE, contentType); } final MimeHeaders cHeaders = coyoteRes.getMimeHeaders(); final int numHeaders = cHeaders.size(); for (int i = 0; i < numHeaders; i++) { final AsciiString name = toHeaderName(cHeaders.getName(i)); if (name == null) { continue; } final String value = toHeaderValue(cHeaders.getValue(i)); if (value == null) { continue; } headers.add(name.toLowerCase(), value); } return headers; } private static AsciiString toHeaderName(MessageBytes value) { switch (value.getType()) { case MessageBytes.T_BYTES: { final ByteChunk chunk = value.getByteChunk(); return new AsciiString(chunk.getBuffer(), chunk.getOffset(), chunk.getLength(), true); } case MessageBytes.T_CHARS: { final CharChunk chunk = value.getCharChunk(); return new AsciiString(chunk.getBuffer(), chunk.getOffset(), chunk.getLength()); } case MessageBytes.T_STR: { return new AsciiString(value.getString()); } } return null; } private static String toHeaderValue(MessageBytes value) { switch (value.getType()) { case MessageBytes.T_BYTES: { final ByteChunk chunk = value.getByteChunk(); return new String(chunk.getBuffer(), chunk.getOffset(), chunk.getLength(), StandardCharsets.US_ASCII); } case MessageBytes.T_CHARS: { final CharChunk chunk = value.getCharChunk(); return new String(chunk.getBuffer(), chunk.getOffset(), chunk.getLength()); } case MessageBytes.T_STR: { return value.getString(); } } return null; } private final class Configurator extends ServerListenerAdapter { @Override public void serverStarting(Server server) throws Exception { start(); } @Override public void serverStopped(Server server) throws Exception { stop(); } } }