All Downloads are FREE. Search and download functionalities are using the official Maven repository.

sirius.web.http.WebServer Maven / Gradle / Ivy

/*
 * Made with all the love in the world
 * by scireum in Remshalden, Germany
 *
 * Copyright by scireum GmbH
 * http://www.scireum.de - [email protected]
 */

package sirius.web.http;

import com.google.common.collect.Maps;
import io.netty.bootstrap.ServerBootstrap;
import io.netty.buffer.PooledByteBufAllocator;
import io.netty.channel.Channel;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelOption;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.handler.codec.http.HttpRequest;
import io.netty.handler.codec.http.multipart.Attribute;
import io.netty.handler.codec.http.multipart.DefaultHttpDataFactory;
import io.netty.handler.codec.http.multipart.DiskAttribute;
import io.netty.handler.codec.http.multipart.DiskFileUpload;
import io.netty.handler.codec.http.multipart.HttpDataFactory;
import io.netty.util.ResourceLeakDetector;
import sirius.kernel.Lifecycle;
import sirius.kernel.Sirius;
import sirius.kernel.async.Operation;
import sirius.kernel.commons.Strings;
import sirius.kernel.commons.Value;
import sirius.kernel.di.GlobalContext;
import sirius.kernel.di.std.ConfigValue;
import sirius.kernel.di.std.Context;
import sirius.kernel.di.std.Part;
import sirius.kernel.di.std.Register;
import sirius.kernel.health.Average;
import sirius.kernel.health.Exceptions;
import sirius.kernel.health.Log;
import sirius.kernel.health.metrics.MetricProvider;
import sirius.kernel.health.metrics.MetricsCollector;
import sirius.kernel.timer.EveryTenSeconds;
import sirius.web.http.session.SessionManager;

import java.net.InetSocketAddress;
import java.time.Duration;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;

/**
 * Responsible for setting up and starting netty as HTTP server.
 */
@Register(framework = "web.http")
public class WebServer implements Lifecycle, MetricProvider {

    /**
     * Used to log all web / http relevant messages
     */
    public static final Log LOG = Log.get("web");

    /**
     * Config value of the HTTP port used (http.port). The default port for HTTP is 80, but the default value
     * here is 9000 because non root users cannot open ports less than 1024
     */
    @ConfigValue("http.port")
    private static int port;

    /**
     * Config value for additional ports (next to the default port). This is only used in edge cases and by default
     * turned off. Add additional ports by naming them under http.additionalPorts in the system config.
     */
    @ConfigValue("http.additionalPorts")
    private static List additionalPorts;

    /**
     * Returns the port used by the web server
     *
     * @return the port served by the HTTP server
     */
    public static int getPort() {
        return port;
    }

    /**
     * Config value of the HTTP bind address used (http.bindAddress). If the value is empty, we bind all
     * addresses. Otherwise this can be used to bind against a single IP address in order to setup multiple servers
     * on the same port.
     */
    @ConfigValue("http.bindAddress")
    private String bindAddress;

    /**
     * Config value of the max size for uploads which are kept entirely in memory ("http.uploadDiskThreshold).
     */
    @ConfigValue("http.uploadDiskThreshold")
    private long uploadDiskThreshold;

    /**
     * Config value of the min free space on disk ("http.minUploadFreespace). If the free space on disk drops
     * below this value, we cancel an upload. This provides large uploads which never crash a system by jamming a
     * disk.
     */
    @ConfigValue("http.minUploadFreespace")
    private static long minUploadFreespace;

    /**
     * Config value of the max upload size (http.maxUploadSize). Can be used to specify an upper limit for
     * file uploads.
     */
    @ConfigValue("http.maxUploadSize")
    private static long maxUploadSize;

    /**
     * Contains a list of IP ranges which are permitted to access this server. Access from unauthorized IPs will be
     * blocked at the lowest level possible (probably no connection will be accepted). The format accepted by this
     * field is defined by {@link IPRange#paraseRangeSet(String)}.
     *
     * @see IPRange#paraseRangeSet(String)
     */
    @ConfigValue("http.firewall.filterIPs")
    private static String ipFilter;
    private static IPRange.RangeSet filterRanges;
    private Channel channel;
    private Channel sslChannel;

    @ConfigValue("http.ssl.enabled")
    private boolean ssl;

    @ConfigValue("http.ssl.port")
    private int sslPort;

    @Part
    private static SessionManager sessionManager;

    /**
     * Returns an ip filter which determines which IPs may connect to the web server.
     *
     * @return a range set describing all accepted ranges
     */
    protected static IPRange.RangeSet getIPFilter() {
        if (filterRanges == null) {
            try {
                filterRanges = IPRange.paraseRangeSet(ipFilter);
            } catch (Throwable e) {
                Exceptions.handle()
                          .to(LOG)
                          .error(e)
                          .withSystemErrorMessage(
                                  "Error parsing config value: 'http.firewall.filterIPs': %s (%s). Defaulting to localhost!")
                          .handle();
                filterRanges = IPRange.LOCALHOST;
            }
        }

        return filterRanges;
    }

    /**
     * Contains a list of IP ranges which are trusted. This rule will not change the processing of requests, it will
     * only set the return value of {@link WebContext#isTrusted()}. The format accepted by this field is defined by
     * {@link IPRange#paraseRangeSet(String)}.
     *
     * @see IPRange#paraseRangeSet(String)
     */
    @ConfigValue("http.firewall.trustedIPs")
    private static String trustedIPs;
    private static IPRange.RangeSet trustedRanges;

    /**
     * Returns all trusted IPs as {@link IPRange.RangeSet}
     *
     * @return a range set describing all trusted ranges
     */
    protected static IPRange.RangeSet getTrustedRanges() {
        if (trustedRanges == null) {
            try {
                trustedRanges = IPRange.paraseRangeSet(trustedIPs);
                // If no trust range is given, we trust nobody but good old LOCALHOST....This seems to be a better
                // alternative than trusting everybody
                if (trustedRanges.isEmpty()) {
                    trustedRanges = IPRange.LOCALHOST;
                }
            } catch (Throwable e) {
                Exceptions.handle()
                          .to(LOG)
                          .error(e)
                          .withSystemErrorMessage("Error parsing config value: 'http.firewall.trustedIPs': %s (%s)")
                          .handle();
                trustedRanges = IPRange.LOCALHOST;
            }
        }

        return trustedRanges;
    }

    @ConfigValue("http.firewall.proxyIPs")
    private static String proxyIPs;
    private static IPRange.RangeSet proxyRanges;

    /**
     * Returns all proxy IPs as {@link IPRange.RangeSet}
     *
     * @return a range set describing all proxy ranges. This is probably just a single IP addressed, however, using
     * {@link IPRange.RangeSet} permit to name several, even sub nets. If a request comes from a proxy IP
     * its X-Forwarded-For header is checked for the original IP.
     */
    protected static IPRange.RangeSet getProxyIPs() {
        if (proxyRanges == null) {
            try {
                proxyRanges = IPRange.paraseRangeSet(proxyIPs);
            } catch (Throwable e) {
                Exceptions.handle()
                          .to(LOG)
                          .error(e)
                          .withSystemErrorMessage("Error parsing config value: 'http.firewall.proxyIPs': %s (%s)")
                          .handle();
                proxyRanges = IPRange.NO_FILTER;
            }
        }

        return proxyRanges;
    }

    @Context
    private GlobalContext ctx;

    private static HttpDataFactory httpDataFactory;
    // Indicates that netty itself will compute the optimal number of threads in the event loop
    private static final int AUTOSELECT_EVENT_LOOP_SIZE = 0;
    private EventLoopGroup eventLoop;

    /**
     * Determines how the web server should participate in the microtiming framework.
     */
    public enum MicrotimingMode {
        /**
         * Use the remote ip as key - this can be used to group requests by remote ip
         */
        IP,
        /**
         * Use the requested uri as key - this can be used to group requests by uri
         */
        URI,
        /**
         * Use remote ip and uri as key - this can be used to lear which peer accesses which uris frequently
         */
        BOTH;

        /*
         * Computes the key used for microtiming based on the current mode
         */
        protected String getMicrotimingKey(WebContext context) {
            switch (this) {
                case IP:
                    return context.getRemoteIP().toString();
                case BOTH:
                    return context.getRemoteIP().toString() + " <-- " + context.microtimingKey;
            }

            // URI is the default:
            return context.microtimingKey;
        }
    }

    /*
     * Statistics
     */
    protected static volatile long bytesIn = 0;
    protected static volatile long bytesOut = 0;
    protected static volatile long messagesIn = 0;
    protected static volatile long messagesOut = 0;
    protected static volatile long connections = 0;
    protected static volatile long blocks = 0;
    protected static volatile long requests = 0;
    protected static volatile long chunks = 0;
    protected static volatile long keepalives = 0;
    protected static volatile long idleTimeouts = 0;
    protected static volatile long clientErrors = 0;
    protected static volatile long serverErrors = 0;
    protected static volatile long websockets = 0;
    protected static Map openConnections = Maps.newConcurrentMap();
    protected static Average responseTime = new Average();
    protected static volatile MicrotimingMode microtimingMode = MicrotimingMode.URI;

    /**
     * Returns the minimal value of free disk space accepted until an upload is aborted.
     *
     * @return the minimal allowed free disk space in bytes
     */
    protected static long getMinUploadFreespace() {
        return minUploadFreespace;
    }

    /**
     * Returns the maximal upload size in bytes
     *
     * @return the max size of uploaded files
     */
    protected static long getMaxUploadSize() {
        return maxUploadSize;
    }

    /**
     * Returns the data factory used to handle file uploads and posts.
     *
     * @return the data factory used for processing POST and PUT requests.
     */
    protected static HttpDataFactory getHttpDataFactory() {
        return httpDataFactory;
    }

    /**
     * Determines the priority of the start of the web server. This is exposed as public so that other life cycles
     * can determine their own priority on this.
     */
    public static final int LIFECYCLE_PRIORITY = 500;

    @Override
    public int getPriority() {
        return LIFECYCLE_PRIORITY;
    }

    @Override
    public void started() {
        if (port <= 0) {
            LOG.INFO("web server is disabled (http.port is <= 0)");
            return;
        }
        reportSettings();
        configureNetty();
        Operation.cover("web",
                        () -> "WebServer.createHTTPChannel:" + port,
                        Duration.ofSeconds(15),
                        () -> createHTTPChannel(port));
        for (String additionalPort : additionalPorts) {
            Value additionalPortValue = Value.of(additionalPort);
            if (additionalPortValue.isNumeric()) {
                Operation.cover("web",
                                () -> "WebServer.createHTTPChannel:" + additionalPort,
                                Duration.ofSeconds(15),
                                () -> createHTTPChannel(additionalPortValue.asInt(-1)));
            }
        }
        if (ssl) {
            Operation.cover("web",
                            () -> "WebServer.createHTTPSChannel",
                            Duration.ofSeconds(15),
                            this::createHTTPSChannel);
        }
    }

    private void reportSettings() {
        LOG.INFO("Initializing netty at port %d", port);

        for (String additionalPort : additionalPorts) {
            LOG.INFO("Initializing netty also at port %s", additionalPort);
        }

        if (Strings.isFilled(bindAddress)) {
            LOG.INFO("Binding netty to %s", bindAddress);
        }
        if (ssl) {
            LOG.INFO("Starting SSL on port %d", sslPort);
        }

        if (Sirius.isDev() && !Sirius.getConfig().hasPath("http.noLeakDetection")) {
            ResourceLeakDetector.setLevel(ResourceLeakDetector.Level.PARANOID);
            LOG.INFO("Enabling PARANOID resource leak detection...");
        }
    }

    /**
     * As some client (namely ICEFaces) tend to create POSTs which contain empty
     * field names (which is forbidden by nettys implementation), we resort to
     * use "unknown" as backup name for those values so that the requests can
     * be processes without failing.
     */
    private static class SiriusHttpDataFactory extends DefaultHttpDataFactory {

        SiriusHttpDataFactory(long minSize) {
            super(minSize);
        }

        @Override
        public Attribute createAttribute(HttpRequest request, String name) {
            return super.createAttribute(request, Strings.isEmpty(name) ? "_sirius_unknown" : name);
        }

        @Override
        public Attribute createAttribute(HttpRequest request, String name, String value) {
            return super.createAttribute(request, Strings.isEmpty(name) ? "_sirius_unknown" : name, value);
        }
    }

    private void configureNetty() {
        DiskFileUpload.deleteOnExitTemporaryFile = true;
        DiskFileUpload.baseDirectory = null;
        DiskAttribute.deleteOnExitTemporaryFile = true;
        DiskAttribute.baseDirectory = null;
        httpDataFactory = new SiriusHttpDataFactory(uploadDiskThreshold);
        Operation.cover("web", () -> "WebServer.createEventLoop", Duration.ofSeconds(15), () -> {
            eventLoop = createEventLoop(AUTOSELECT_EVENT_LOOP_SIZE, "netty-");
        });
    }

    private static class PrefixThreadFactory implements ThreadFactory {
        private final String name;
        private final AtomicInteger counter = new AtomicInteger(1);

        private PrefixThreadFactory(String name) {
            this.name = name;
        }

        @Override
        public Thread newThread(Runnable r) {
            return new Thread(r, name + counter.getAndIncrement());
        }
    }

    private EventLoopGroup createEventLoop(int numThreads, String name) {
        return new NioEventLoopGroup(numThreads, new PrefixThreadFactory(name));
    }

    private ServerBootstrap createServerBootstrap(ChannelInitializer initializer) {
        ServerBootstrap bootstrap = new ServerBootstrap();
        bootstrap.childOption(ChannelOption.WRITE_BUFFER_HIGH_WATER_MARK, 64 * 1024);
        bootstrap.childOption(ChannelOption.WRITE_BUFFER_LOW_WATER_MARK, 32 * 1024);
        bootstrap.childOption(ChannelOption.ALLOCATOR, PooledByteBufAllocator.DEFAULT);
        // At mose have 128 connections waiting to be "connected" - drop everything else...
        bootstrap.option(ChannelOption.SO_BACKLOG, 128);
        // Send a KEEPALIVE packet every 2h and expect and ACK on the TCP layer
        bootstrap.childOption(ChannelOption.SO_KEEPALIVE, true);
        bootstrap.group(eventLoop);
        bootstrap.channel(NioServerSocketChannel.class);
        bootstrap.childHandler(ctx.wire(initializer));
        return bootstrap;
    }

    private void createHTTPChannel(int port) {
        try {
            ServerBootstrap bootstrap = createServerBootstrap(new WebServerInitializer());
            // Bind and start to accept incoming connections.
            if (Strings.isFilled(bindAddress)) {
                channel = bootstrap.bind(new InetSocketAddress(bindAddress, port)).sync().channel();
            } else {
                channel = bootstrap.bind(new InetSocketAddress(port)).sync().channel();
            }
        } catch (Throwable t) {
            Exceptions.handle()
                      .to(LOG)
                      .error(t)
                      .withSystemErrorMessage("Cannot setup HTTP (%d): %s (%s)", port)
                      .handle();
        }
    }

    private void createHTTPSChannel() {
        try {
            ServerBootstrap bootstrap = createServerBootstrap(new SSLWebServerInitializer());
            // Bind and start to accept incoming connections.
            if (Strings.isFilled(bindAddress)) {
                sslChannel = bootstrap.bind(new InetSocketAddress(bindAddress, sslPort)).sync().channel();
            } else {
                sslChannel = bootstrap.bind(new InetSocketAddress(sslPort)).sync().channel();
            }
        } catch (Throwable t) {
            Exceptions.handle().to(LOG).error(t).withSystemErrorMessage("Cannot setup HTTPS: %s (%s)").handle();
        }
    }

    @Override
    public void stopped() {
        stopChannel(channel, "http");
        stopChannel(sslChannel, "https");
        Operation.cover("web",
                        () -> "eventLoop.shutdownGracefully",
                        Duration.ofSeconds(15),
                        eventLoop::shutdownGracefully);
        Operation.cover("web", () -> "Response.closeAsyncClient", Duration.ofSeconds(15), Response::closeAsyncClient);
    }

    private void stopChannel(Channel channel, String name) {
        Operation op = Operation.create("web", () -> "stopChannel(" + name + ")", Duration.ofSeconds(15));
        try {
            if (channel != null) {
                channel.close().sync();
            }
        } catch (InterruptedException e) {
            Exceptions.ignore(e);
            LOG.SEVERE(Strings.apply("Interrupted while waiting for the %s channel to shut down", name));
        } finally {
            Operation.release(op);
        }
    }

    @Override
    public void awaitTermination() {
        try {
            if (!eventLoop.terminationFuture().await(10, TimeUnit.SECONDS)) {
                LOG.SEVERE("Worker Group did not shutdown within 10 seconds!");
            }
        } catch (InterruptedException e) {
            Exceptions.ignore(e);
            LOG.SEVERE("Interrupted while waiting for the Worker Group to shut down");
        }
    }

    @Override
    public String getName() {
        return "web (netty HTTP Server)";
    }

    /**
     * Returns the total bytes received so far
     *
     * @return the total bytes received via the http port
     */
    public static long getBytesIn() {
        return bytesIn;
    }

    /**
     * Returns the total bytes sent so far
     *
     * @return the total bytes sent via the http port
     */
    public static long getBytesOut() {
        return bytesOut;
    }

    /**
     * Returns the total messages (packets) sent so far
     *
     * @return the total messages sent via the http port
     */
    public static long getMessagesIn() {
        return messagesIn;
    }

    /**
     * Returns the total messages (packets) received so far
     *
     * @return the total messages received via the http port
     */
    public static long getMessagesOut() {
        return messagesOut;
    }

    /**
     * Returns the total number of connections opened so far
     *
     * @return the total number of connections opened on the http port
     */
    public static long getConnections() {
        return connections;
    }

    /**
     * Returns the total number of connections blocked so far
     *
     * @return the total number of connections blocked via a firewall rule on the http port
     */
    public static long getBlockedConnections() {
        return blocks;
    }

    /**
     * Returns the number of currently open connection
     *
     * @return the number of open connections on the http port
     */
    public static long getNumberOfOpenConnections() {
        return openConnections.size();
    }

    /**
     * Returns the number of currently open websockets
     *
     * @return the number of currently open websockets
     */
    public static long getNumberOfWebsockets() {
        return websockets;
    }

    /**
     * Returns the total number of HTTP requests received by the web server
     *
     * @return the total number of requests received
     */
    public static long getRequests() {
        return requests;
    }

    /**
     * Returns the total number of HTTP chunks received
     *
     * @return the total number of chunks received
     */
    public static long getChunks() {
        return chunks;
    }

    /**
     * Returns the number of keepalives supported
     *
     * @return the number of connections not closed in order to keep them alive.
     */
    public static long getKeepalives() {
        return keepalives;
    }

    /**
     * Returns the number of idle connections killed
     *
     * @return the number of connections closed because they were found to be idle.
     */
    public static long getIdleTimeouts() {
        return idleTimeouts;
    }

    /**
     * Returns the number of HTTP responses with an 4xx status code.
     *
     * @return the number of HTTP responses with an 4xx status code.
     */
    public static long getClientErrors() {
        return clientErrors;
    }

    /**
     * Returns the number of HTTP responses with an 5xx status code.
     *
     * @return the number of HTTP responses with an 5xx status code.
     */
    public static long getServerErrors() {
        return serverErrors;
    }

    /**
     * Returns the average response time of the last requests.
     *
     * @return the average response time of the last requests in milliseconds.
     */
    public static double getAvgResponseTime() {
        return responseTime.getAvg();
    }

    @Override
    public void gather(MetricsCollector collector) {
        collector.differentialMetric("http-bytes-in", "http-bytes-in", "HTTP Bytes-In", bytesIn / 1024d / 60, "KB/s");
        collector.differentialMetric("http-bytes-out",
                                     "http-bytes-out",
                                     "HTTP Bytes-Out",
                                     bytesOut / 1024d / 60,
                                     "KB/s");
        collector.differentialMetric("http-connects", "http-connects", "HTTP Connects", connections, "/min");
        collector.differentialMetric("http-requests", "http-requests", "HTTP Requests", requests, "/min");
        collector.differentialMetric("http-blocks", "http-blocks", "HTTP Blocked Requests", blocks, "/min");
        collector.differentialMetric("http-timeouts", "http-timeouts", "HTTP Idle Timeouts", idleTimeouts, "/min");
        collector.differentialMetric("http-client-errors",
                                     "http-client-errors",
                                     "HTTP Client Errors (4xx)",
                                     clientErrors,
                                     "/min");
        collector.differentialMetric("http-server-errors",
                                     "http-client-errors",
                                     "HTTP Server Errors (5xx)",
                                     serverErrors,
                                     "/min");
        collector.metric("http-open-connections", "HTTP Open Connections", openConnections.size(), null);
        collector.metric("http-response-time", "HTTP Avg. Reponse Time", responseTime.getAndClearAverage(), "ms");
        collector.metric("http-sessions", "HTTP Sessions", sessionManager.getNumberOfSessions(), null);
        collector.metric("websockets", "Open Websockets", websockets, null);
    }

    /**
     * Updates the measured bandwidth of all open http(s) connections.
     */
    @Register
    public static class BandwidthUpdater implements EveryTenSeconds {

        @Override
        public void runTimer() throws Exception {
            for (WebServerHandler handler : openConnections.values()) {
                handler.updateBandwidth();
            }
        }
    }

    /*
     * Used to notify the web server about an open connection
     */
    protected static void addOpenConnection(WebServerHandler webServerHandler) {
        openConnections.put(webServerHandler, webServerHandler);
    }

    /*
     * Used to notify the web server about an closed connection
     */
    protected static void removeOpenConnection(WebServerHandler webServerHandler) {
        openConnections.remove(webServerHandler);
    }

    /**
     * Returns all currently open connections of the HTTP server.
     *
     * @return a list of all currently open connections
     */
    public static Collection getOpenConnections() {
        return openConnections.values();
    }

    /**
     * Returns the {@link MicrotimingMode} used by the web server
     *
     * @return the current mode of interaction with the microtiming framework
     * @see sirius.kernel.health.Microtiming
     */
    public static MicrotimingMode getMicrotimingMode() {
        return microtimingMode;
    }

    /**
     * Changes the microtiming mode.
     * 

* Note that the microtiming framework still has to be enabled to generate any output. * * @param microtimingMode the new microtiming mode to use. */ public static void setMicrotimingMode(MicrotimingMode microtimingMode) { WebServer.microtimingMode = microtimingMode == null ? MicrotimingMode.URI : microtimingMode; } }





© 2015 - 2025 Weber Informatics LLC | Privacy Policy