org.eclipse.hono.service.amqp.AmqpServiceBase.java Source code

Java tutorial

Introduction

Here is the source code for org.eclipse.hono.service.amqp.AmqpServiceBase.java

Source

/**
 * Copyright (c) 2017 Bosch Software Innovations GmbH.
 *
 * All rights reserved. This program and the accompanying materials
 * are made available under the terms of the Eclipse Public License v1.0
 * which accompanies this distribution, and is available at
 * http://www.eclipse.org/legal/epl-v10.html
 *
 * Contributors:
 *    Bosch Software Innovations GmbH - initial creation
 */

package org.eclipse.hono.service.amqp;

import java.util.*;

import io.vertx.core.AsyncResult;
import io.vertx.proton.*;
import org.apache.qpid.proton.amqp.transport.AmqpError;
import org.apache.qpid.proton.amqp.transport.Source;
import org.eclipse.hono.auth.Activity;
import org.eclipse.hono.auth.HonoUser;
import org.eclipse.hono.config.ServiceConfigProperties;
import org.eclipse.hono.service.AbstractServiceBase;
import org.eclipse.hono.service.auth.AuthorizationService;
import org.eclipse.hono.service.auth.ClaimsBasedAuthorizationService;
import org.eclipse.hono.util.Constants;
import org.eclipse.hono.util.ResourceIdentifier;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;

import io.vertx.core.CompositeFuture;
import io.vertx.core.Future;
import io.vertx.ext.healthchecks.HealthCheckHandler;
import io.vertx.proton.sasl.ProtonSaslAuthenticatorFactory;

/**
 * A base class for implementing services using AMQP 1.0 as the transport protocol.
 * <p>
 * This class provides support for implementing an AMQP 1.0 container hosting arbitrary
 * API {@link AmqpEndpoint}s. An endpoint can be used to handle messages being sent to and/or
 * received from an AMQP <em>node</em> represented by an address prefix.
 * 
 * @param <T> The type of configuration properties this service uses.
 */
public abstract class AmqpServiceBase<T extends ServiceConfigProperties> extends AbstractServiceBase<T> {

    // <name, node implementation>
    private final Map<String, AmqpEndpoint> endpoints = new HashMap<>();
    private ProtonServer server;
    private ProtonServer insecureServer;
    private ProtonSaslAuthenticatorFactory saslAuthenticatorFactory;
    private AuthorizationService authorizationService;

    /**
     * Gets the name of the service, that may be used for the container name on amqp connections e.g.
     * @return The name of the service.
     */
    protected abstract String getServiceName();

    @Autowired
    @Qualifier(Constants.QUALIFIER_AMQP)
    @Override
    public void setConfig(final T configuration) {
        setSpecificConfig(configuration);
    }

    /**
     * Gets the default port number of the secure AMQP port.
     * 
     * @return {@link Constants#PORT_AMQPS}
     */
    @Override
    public int getPortDefaultValue() {
        return Constants.PORT_AMQPS;
    }

    /**
     * Gets the default port number of the non-secure AMQP port.
     * 
     * @return {@link Constants#PORT_AMQP}
     */
    @Override
    public int getInsecurePortDefaultValue() {
        return Constants.PORT_AMQP;
    }

    /**
     * Adds multiple endpoints to this server.
     *
     * @param definedEndpoints The endpoints.
     */
    @Autowired(required = false)
    public final void addEndpoints(final List<AmqpEndpoint> definedEndpoints) {
        Objects.requireNonNull(definedEndpoints);
        for (AmqpEndpoint ep : definedEndpoints) {
            addEndpoint(ep);
        }
    }

    /**
     * Adds an endpoint to this server.
     *
     * @param ep The endpoint.
     */
    public final void addEndpoint(final AmqpEndpoint ep) {
        if (endpoints.putIfAbsent(ep.getName(), ep) != null) {
            LOG.warn("multiple endpoints defined with name [{}]", ep.getName());
        } else {
            LOG.debug("registering endpoint [{}]", ep.getName());
        }
    }

    /**
     * Gets the endpoint registered for handling a specific target address.
     * 
     * @param targetAddress The address.
     * @return The endpoint for handling the address or {@code null} if no endpoint is registered
     *         for the target address.
     */
    protected final AmqpEndpoint getEndpoint(final ResourceIdentifier targetAddress) {
        return getEndpoint(targetAddress.getEndpoint());
    }

    /**
     * Gets the endpoint registered for a given name.
     * 
     * @param endpointName The name.
     * @return The endpoint registered under the given name or {@code null} if no endpoint has been registered
     *         under the name.
     */
    protected final AmqpEndpoint getEndpoint(final String endpointName) {
        return endpoints.get(endpointName);
    }

    /**
     * Iterates over the endpoints registered with this service.
     * 
     * @return The endpoints.
     */
    protected final Iterable<AmqpEndpoint> endpoints() {
        return endpoints.values();
    }

    /**
     * Sets the factory to use for creating objects performing SASL based authentication of clients.
     *
     * @param factory The factory.
     * @throws NullPointerException if factory is {@code null}.
     */
    @Autowired(required = false)
    public void setSaslAuthenticatorFactory(final ProtonSaslAuthenticatorFactory factory) {
        this.saslAuthenticatorFactory = Objects.requireNonNull(factory);
    }

    /**
     * Sets the object to use for authorizing access to resources and operations.
     * 
     * @param authService The authorization service to use.
     * @throws NullPointerException if the service is {@code null}.
     */
    public final void setAuthorizationService(final AuthorizationService authService) {
        this.authorizationService = authService;
    }

    /**
     * Gets the object used for authorizing access to resources and operations.
     * 
     * @return The authorization service to use.
     */
    protected final AuthorizationService getAuthorizationService() {
        return authorizationService;
    }

    @Override
    public Future<Void> startInternal() {

        if (authorizationService == null) {
            authorizationService = new ClaimsBasedAuthorizationService();
        }
        return preStartServers().compose(s -> checkPortConfiguration()).compose(s -> startEndpoints())
                .compose(s -> startSecureServer()).compose(s -> startInsecureServer());
    }

    /**
     * Invoked before binding listeners to the configured socket addresses.
     * <p>
     * Subclasses may override this method to do any kind of initialization work.
     * 
     * @return A future indicating the outcome of the operation. The listeners will not
     *         be bound if the returned future fails.
     */
    protected Future<Void> preStartServers() {
        return Future.succeededFuture();
    }

    private Future<Void> startEndpoints() {

        @SuppressWarnings("rawtypes")
        List<Future> endpointFutures = new ArrayList<>(endpoints.size());
        for (AmqpEndpoint ep : endpoints.values()) {
            LOG.info("starting endpoint [name: {}, class: {}]", ep.getName(), ep.getClass().getName());
            Future<Void> endpointFuture = Future.future();
            endpointFutures.add(endpointFuture);
            ep.start(endpointFuture);
        }
        final Future<Void> startFuture = Future.future();
        CompositeFuture.all(endpointFutures).setHandler(startup -> {
            if (startup.succeeded()) {
                startFuture.complete();
            } else {
                startFuture.fail(startup.cause());
            }
        });
        return startFuture;
    }

    private Future<Void> startInsecureServer() {

        if (isInsecurePortEnabled()) {
            int insecurePort = determineInsecurePort();
            final Future<Void> result = Future.future();
            final ProtonServerOptions options = createInsecureServerOptions();
            insecureServer = createProtonServer(options).connectHandler(this::onRemoteConnectionOpenInsecurePort)
                    .listen(insecurePort, getConfig().getInsecurePortBindAddress(), bindAttempt -> {
                        if (bindAttempt.succeeded()) {
                            if (getInsecurePort() == getInsecurePortDefaultValue()) {
                                LOG.info("server listens on standard insecure port [{}:{}]",
                                        getInsecurePortBindAddress(), getInsecurePort());
                            } else {
                                LOG.warn("server listens on non-standard insecure port [{}:{}], default is {}",
                                        getInsecurePortBindAddress(), getInsecurePort(),
                                        getInsecurePortDefaultValue());
                            }
                            result.complete();
                        } else {
                            LOG.error("cannot bind to insecure port", bindAttempt.cause());
                            result.fail(bindAttempt.cause());
                        }
                    });
            return result;
        } else {
            LOG.info("insecure port is not enabled");
            return Future.succeededFuture();
        }
    }

    private Future<Void> startSecureServer() {

        if (isSecurePortEnabled()) {
            int securePort = determineSecurePort();
            final Future<Void> result = Future.future();
            final ProtonServerOptions options = createServerOptions();
            server = createProtonServer(options).connectHandler(this::onRemoteConnectionOpen).listen(securePort,
                    getConfig().getBindAddress(), bindAttempt -> {
                        if (bindAttempt.succeeded()) {
                            if (getPort() == getPortDefaultValue()) {
                                LOG.info("server listens on standard secure port [{}:{}]", getBindAddress(),
                                        getPort());
                            } else {
                                LOG.warn("server listens on non-standard secure port [{}:{}], default is {}",
                                        getBindAddress(), getPort(), getPortDefaultValue());
                            }
                            result.complete();
                        } else {
                            LOG.error("cannot bind to secure port", bindAttempt.cause());
                            result.fail(bindAttempt.cause());
                        }
                    });
            return result;
        } else {
            LOG.info("secure port is not enabled");
            return Future.succeededFuture();
        }
    }

    private ProtonServer createProtonServer(final ProtonServerOptions options) {
        return ProtonServer.create(vertx, options).saslAuthenticatorFactory(saslAuthenticatorFactory);
    }

    /**
     * Invoked during start up to create options for the proton server instance binding to the secure port.
     * <p>
     * Subclasses may override this method to set custom options.
     * 
     * @return The options.
     */
    protected ProtonServerOptions createServerOptions() {
        ProtonServerOptions options = createInsecureServerOptions();

        addTlsKeyCertOptions(options);
        addTlsTrustOptions(options);
        return options;
    }

    /**
     * Invoked during start up to create options for the proton server instance binding to the insecure port.
     * <p>
     * Subclasses may override this method to set custom options.
     * 
     * @return The options.
     */
    protected ProtonServerOptions createInsecureServerOptions() {

        ProtonServerOptions options = new ProtonServerOptions();
        options.setHeartbeat(60000); // // close idle connections after two minutes of inactivity
        options.setReceiveBufferSize(16 * 1024); // 16kb
        options.setSendBufferSize(16 * 1024); // 16kb
        options.setLogActivity(getConfig().isNetworkDebugLoggingEnabled());

        return options;
    }

    @Override
    public final Future<Void> stopInternal() {

        Future<Void> shutdownHandler = Future.future();
        Future<Void> tracker = Future.future();
        if (server != null) {
            server.close(tracker.completer());
        } else {
            LOG.info("service has been already shut down");
            tracker.complete();
        }
        tracker.compose(t -> {
            if (insecureServer != null) {
                insecureServer.close(shutdownHandler.completer());
            } else {
                shutdownHandler.complete();
            }
        }, shutdownHandler);
        return shutdownHandler;
    }

    @Override
    protected final int getActualPort() {
        return (server != null ? server.actualPort() : Constants.PORT_UNCONFIGURED);
    }

    @Override
    protected final int getActualInsecurePort() {
        return (insecureServer != null ? insecureServer.actualPort() : Constants.PORT_UNCONFIGURED);
    }

    /**
     * Invoked when an AMQP <em>open</em> frame is received on the secure port.
     * <p>
     * Configures the AMQP connection for the secure port and provides a basic connection handling.
     * Subclasses may override this method to set custom handlers.
     *
     * @param connection The AMQP connection that the frame is supposed to establish.
     */
    protected void onRemoteConnectionOpen(final ProtonConnection connection) {
        connection.setContainer(String.format("%s-%s:%d", getServiceName(), getBindAddress(), getPort()));
        setRemoteConnectionOpenHandler(connection);
    }

    /**
     * Invoked when an AMQP <em>open</em> frame is received on the insecure port.
     * <p>
     * Configures the AMQP connection for the insecure port and provides a basic connection handling.
     * Subclasses may override this method to set custom handlers.
     *
     * @param connection The AMQP connection that the frame is supposed to establish.
     * @throws NullPointerException if connection is {@code null}.
     */
    protected void onRemoteConnectionOpenInsecurePort(final ProtonConnection connection) {
        connection.setContainer(
                String.format("%s-%s:%d", getServiceName(), getInsecurePortBindAddress(), getInsecurePort()));
        setRemoteConnectionOpenHandler(connection);
    }

    /**
     * Closes a link for an unknown target address.
     * <p>
     * The link is closed with AMQP error code <em>amqp:not-found</em>.
     * 
     * @param con The connection that the link belongs to.
     * @param link The link.
     * @param address The unknown target address.
     */
    protected final void handleUnknownEndpoint(final ProtonConnection con, final ProtonLink<?> link,
            final ResourceIdentifier address) {
        LOG.info("client [{}] wants to establish link for unknown endpoint [address: {}]", con.getRemoteContainer(),
                address);
        link.setCondition(ProtonHelper.condition(AmqpError.NOT_FOUND,
                String.format("no endpoint registered for address %s", address)));
        link.close();
    }

    /**
     * Creates a resource identifier for a given address.
     * 
     * @param address The address. If this service is configured for
     *         a single tenant only then the address is assumed to <em>not</em> contain a tenant
     *         component.
     * @return The identifier representing the address.
     * @throws IllegalArgumentException if the given address does not represent a valid resource identifier.
     */
    protected final ResourceIdentifier getResourceIdentifier(final String address) {
        if (getConfig().isSingleTenant()) {
            return ResourceIdentifier.fromStringAssumingDefaultTenant(address);
        } else {
            return ResourceIdentifier.fromString(address);
        }
    }

    /**
     * Handles a request from a client to establish a link for sending messages to this server.
     * The already established connection must have an authenticated user as principal for doing the authorization check.
     *
     * @param con the connection to the client.
     * @param receiver the receiver created for the link.
     */
    protected void handleReceiverOpen(final ProtonConnection con, final ProtonReceiver receiver) {
        if (receiver.getRemoteTarget().getAddress() == null) {
            LOG.debug(
                    "client [{}] wants to open an anonymous link for sending messages to arbitrary addresses, closing link",
                    con.getRemoteContainer());
            receiver.setCondition(
                    ProtonHelper.condition(AmqpError.NOT_FOUND.toString(), "anonymous relay not supported"))
                    .close();
        } else {
            LOG.debug("client [{}] wants to open a link for sending messages [address: {}]",
                    con.getRemoteContainer(), receiver.getRemoteTarget());
            try {
                final ResourceIdentifier targetResource = getResourceIdentifier(
                        receiver.getRemoteTarget().getAddress());
                final AmqpEndpoint endpoint = getEndpoint(targetResource);
                if (endpoint == null) {
                    handleUnknownEndpoint(con, receiver, targetResource);
                } else {
                    final HonoUser user = Constants.getClientPrincipal(con);
                    getAuthorizationService().isAuthorized(user, targetResource, Activity.WRITE)
                            .setHandler(authAttempt -> {
                                if (authAttempt.succeeded() && authAttempt.result()) {
                                    Constants.copyProperties(con, receiver);
                                    receiver.setTarget(receiver.getRemoteTarget());
                                    endpoint.onLinkAttach(con, receiver, targetResource);
                                } else {
                                    LOG.debug("subject [{}] is not authorized to WRITE to [{}]", user.getName(),
                                            targetResource);
                                    receiver.setCondition(ProtonHelper
                                            .condition(AmqpError.UNAUTHORIZED_ACCESS.toString(), "unauthorized"))
                                            .close();
                                }
                            });
                }
            } catch (final IllegalArgumentException e) {
                LOG.debug("client has provided invalid resource identifier as target address", e);
                receiver.close();
            }
        }
    }

    /**
     * Handles a request from a client to establish a link for receiving messages from this server.
     *
     * @param con the connection to the client.
     * @param sender the sender created for the link.
     */
    protected void handleSenderOpen(final ProtonConnection con, final ProtonSender sender) {
        final Source remoteSource = sender.getRemoteSource();
        LOG.debug("client [{}] wants to open a link for receiving messages [address: {}]", con.getRemoteContainer(),
                remoteSource);
        try {
            final ResourceIdentifier targetResource = getResourceIdentifier(remoteSource.getAddress());
            final AmqpEndpoint endpoint = getEndpoint(targetResource);
            if (endpoint == null) {
                handleUnknownEndpoint(con, sender, targetResource);
            } else {
                final HonoUser user = Constants.getClientPrincipal(con);
                getAuthorizationService().isAuthorized(user, targetResource, Activity.READ)
                        .setHandler(authAttempt -> {
                            if (authAttempt.succeeded() && authAttempt.result()) {
                                Constants.copyProperties(con, sender);
                                sender.setSource(sender.getRemoteSource());
                                endpoint.onLinkAttach(con, sender, targetResource);
                            } else {
                                LOG.debug("subject [{}] is not authorized to READ from [{}]", user.getName(),
                                        targetResource);
                                sender.setCondition(ProtonHelper.condition(AmqpError.UNAUTHORIZED_ACCESS.toString(),
                                        "unauthorized")).close();
                            }
                        });
            }
        } catch (final IllegalArgumentException e) {
            LOG.debug("client has provided invalid resource identifier as target address", e);
            sender.close();
        }
    }

    private void setRemoteConnectionOpenHandler(final ProtonConnection connection) {
        connection.sessionOpenHandler(remoteOpenSession -> handleSessionOpen(connection, remoteOpenSession));
        connection.receiverOpenHandler(remoteOpenReceiver -> handleReceiverOpen(connection, remoteOpenReceiver));
        connection.senderOpenHandler(remoteOpenSender -> handleSenderOpen(connection, remoteOpenSender));
        connection.disconnectHandler(this::handleRemoteDisconnect);
        connection.closeHandler(remoteClose -> handleRemoteConnectionClose(connection, remoteClose));
        connection.openHandler(remoteOpen -> {
            LOG.debug("client [container: {}, user: {}] connected", connection.getRemoteContainer(),
                    Constants.getClientPrincipal(connection).getName());
            connection.open();
            // attach an ID so that we can later inform downstream components when connection is closed
            connection.attachments().set(Constants.KEY_CONNECTION_ID, String.class, UUID.randomUUID().toString());
        });
    }

    /**
     * Invoked when a client initiates a session (which is then opened in this method).
     * <p>
     * Subclasses should override this method if other behaviour shall be implemented on session open.
     *
     * @param con The connection of the session.
     * @param session The session that is initiated.
     */
    protected void handleSessionOpen(final ProtonConnection con, final ProtonSession session) {
        LOG.debug("opening new session with client [{}]", con.getRemoteContainer());
        session.closeHandler(sessionResult -> {
            if (sessionResult.succeeded()) {
                sessionResult.result().close();
            }
        }).open();
    }

    /**
     * Is called whenever a proton connection was closed. The implementation is intentionally empty.
     * <p>
     * Subclasses should override this method to publish this as an event on the vertx bus if desired.
     *
     * @param con The connection that was closed.
     */
    protected void publishConnectionClosedEvent(final ProtonConnection con) {
    }

    /**
     * Invoked when a client closes the connection with this server.
     * <p>
     * The implementation closes and disconnects the connection.
     *
     * @param con The connection to close.
     * @param res The client's close frame.
     */
    protected void handleRemoteConnectionClose(final ProtonConnection con,
            final AsyncResult<ProtonConnection> res) {
        if (res.succeeded()) {
            LOG.debug("client [{}] closed connection", con.getRemoteContainer());
        } else {
            LOG.debug("client [{}] closed connection with error", con.getRemoteContainer(), res.cause());
        }
        con.close();
        con.disconnect();
        publishConnectionClosedEvent(con);
    }

    /**
     * Invoked when the client's transport connection is disconnected from this server.
     *
     * @param con The connection that was disconnected.
     */
    protected void handleRemoteDisconnect(final ProtonConnection con) {
        LOG.debug("client [{}] disconnected", con.getRemoteContainer());
        con.disconnect();
        publishConnectionClosedEvent(con);
    }

    /**
     * Registers this service's endpoints' readiness checks.
     * <p>
     * This default implementation invokes {@link AmqpEndpoint#registerReadinessChecks(HealthCheckHandler)}
     * for all registered endpoints.
     * <p>
     * Subclasses should override this method to register more specific checks.
     * 
     * @param handler The health check handler to register the checks with.
     */
    @Override
    public void registerReadinessChecks(final HealthCheckHandler handler) {

        for (AmqpEndpoint ep : endpoints()) {
            ep.registerReadinessChecks(handler);
        }
    }

    /**
     * Registers this service's endpoints' liveness checks.
     * <p>
     * This default implementation invokes {@link AmqpEndpoint#registerLivenessChecks(HealthCheckHandler)}
     * for all registered endpoints.
     * <p>
     * Subclasses should override this method to register more specific checks.
     * 
     * @param handler The health check handler to register the checks with.
     */
    @Override
    public void registerLivenessChecks(HealthCheckHandler handler) {
        for (AmqpEndpoint ep : endpoints()) {
            ep.registerLivenessChecks(handler);
        }
    }
}