
org.openremote.manager.event.ClientEventService Maven / Gradle / Ivy
/*
* Copyright 2016, OpenRemote Inc.
*
* See the CONTRIBUTORS.txt file in the distribution for a
* full listing of individual contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see .
*/
package org.openremote.manager.event;
import io.undertow.websockets.core.WebSocketChannel;
import io.undertow.websockets.spi.WebSocketHttpExchange;
import org.apache.camel.Exchange;
import org.apache.camel.builder.RouteBuilder;
import org.apache.camel.component.undertow.UndertowComponent;
import org.apache.camel.component.undertow.UndertowConstants;
import org.apache.camel.component.undertow.UndertowHostKey;
import org.keycloak.KeycloakPrincipal;
import org.openremote.container.message.MessageBrokerService;
import org.openremote.container.security.AuthContext;
import org.openremote.container.security.basic.BasicAuthContext;
import org.openremote.container.security.keycloak.AccessTokenAuthContext;
import org.openremote.container.timer.TimerService;
import org.openremote.manager.gateway.GatewayService;
import org.openremote.manager.security.ManagerIdentityService;
import org.openremote.manager.web.ManagerWebService;
import org.openremote.model.Constants;
import org.openremote.model.Container;
import org.openremote.model.ContainerService;
import org.openremote.model.asset.AssetFilter;
import org.openremote.model.attribute.AttributeEvent;
import org.openremote.model.event.Event;
import org.openremote.model.event.RespondableEvent;
import org.openremote.model.event.TriggeredEventSubscription;
import org.openremote.model.event.shared.*;
import org.openremote.model.syslog.SyslogEvent;
import org.openremote.model.util.Pair;
import java.io.IOException;
import java.security.Principal;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArraySet;
import java.util.concurrent.ExecutorService;
import java.util.function.Consumer;
import static java.lang.System.Logger.Level.*;
import static org.openremote.manager.asset.AssetProcessingService.ATTRIBUTE_EVENT_PROCESSOR;
import static org.openremote.manager.asset.AssetProcessingService.ATTRIBUTE_EVENT_ROUTE_CONFIG_ID;
import static org.openremote.model.Constants.*;
/**
* Receives and publishes messages, handles the client/server event bus.
*
* Messages always start with a message discriminator in all uppercase letters, followed
* by an optional JSON payload.
*
* The following messages can be sent by a client:
*
* SUBSCRIBE:{...}
-
*
* The payload is a serialized representation of {@link EventSubscription} with an optional
* {@link org.openremote.model.event.shared.EventFilter}. Clients can subscribe to receive {@link SharedEvent}s
* when they are published on the server. Subscriptions are handled by {@link SharedEvent#getEventType}.
*
* UNSUBSCRIBE:{...}
*
* The payload is a serialized representation of {@link CancelEventSubscription}. If a client
* does not want to wait for expiration of its subscriptions, it can cancel a subscription.
*
* EVENT:{...}
*
* The payload is a serialized representation of a subtype of {@link SharedEvent}. If the server
* does not recognize the event, it is silently ignored.
*
*
*
* The following messages can be published/returned by the server:
*
* UNAUTHORIZED:{...}
*
* The payload is a serialized representation of {@link UnauthorizedEventSubscription}.
*
* EVENT:{...}
*
* The payload is a serialized representation of a subtype of {@link SharedEvent}.
*
* EVENT:[...]
*
* The payload is an array of {@link SharedEvent}s.
*
*
*/
public class ClientEventService extends RouteBuilder implements ContainerService {
public static final int PRIORITY = ManagerWebService.PRIORITY - 200;
public static final String WEBSOCKET_URI = "undertow://ws://0.0.0.0/websocket/events?fireWebSocketChannelEvents=true&sendTimeout=15000&keepAlive=false"; // Host is not used as existing undertow instance is utilised
protected static final System.Logger LOG = System.getLogger(ClientEventService.class.getName());
protected static final String PUBLISH_QUEUE = "direct://ClientPublishQueue";
final protected Collection eventSubscriptionAuthorizers = new CopyOnWriteArraySet<>();
final protected Collection eventAuthorizers = new CopyOnWriteArraySet<>();
final protected Set, Consumer extends Event>>> eventSubscriptions = new CopyOnWriteArraySet<>();
final protected Map sessionChannels = new ConcurrentHashMap<>();
final protected Map>> websocketSessionSubscriptionConsumers = new HashMap<>();
protected TimerService timerService;
protected ExecutorService executorService;
protected MessageBrokerService messageBrokerService;
protected ManagerIdentityService identityService;
protected GatewayService gatewayService;
protected boolean started;
protected Consumer websocketInterceptor;
public static String getSessionKey(Exchange exchange) {
return exchange.getIn().getHeader(UndertowConstants.CONNECTION_KEY, String.class);
}
public static String getClientId(Exchange exchange) {
AuthContext authContext = exchange.getIn().getHeader(AUTH_CONTEXT, AuthContext.class);
if(authContext != null) {
return authContext.getClientId();
}
return null;
}
@Override
public int getPriority() {
return PRIORITY;
}
@Override
public void init(Container container) throws Exception {
timerService = container.getService(TimerService.class);
messageBrokerService = container.getService(MessageBrokerService.class);
identityService = container.getService(ManagerIdentityService.class);
gatewayService = container.getService(GatewayService.class);
executorService = container.getExecutor();
UndertowComponent undertowWebsocketComponent = new UndertowComponent(messageBrokerService.getContext()) {
@Override
protected org.apache.camel.component.undertow.UndertowHost createUndertowHost(UndertowHostKey key) {
return new UndertowHost(container, key, getHostOptions());
}
};
messageBrokerService.getContext().addComponent("undertow", undertowWebsocketComponent);
messageBrokerService.getContext().getTypeConverterRegistry().addTypeConverters(
new EventTypeConverters()
);
messageBrokerService.getContext().addRoutes(this);
}
// TODO: Remove prefix and just use event type then use a subscription wrapper to pass subscription ID around
@SuppressWarnings({"unchecked", "rawtypes"})
@Override
public void configure() throws Exception {
// Route for handling inbound websocket messages
from(WEBSOCKET_URI)
.routeId("ClientInbound-Websocket")
.routeConfigurationId(ATTRIBUTE_EVENT_ROUTE_CONFIG_ID)
.choice()
.when(header(UndertowConstants.EVENT_TYPE))
.process(exchange -> {
UndertowConstants.EventType eventType = exchange.getIn().getHeader(UndertowConstants.EVENT_TYPE_ENUM, UndertowConstants.EventType.class);
WebSocketChannel webSocketChannel = exchange.getIn().getHeader(UndertowConstants.CHANNEL, WebSocketChannel.class);
switch (eventType) {
case ONOPEN -> {
WebSocketHttpExchange httpExchange = exchange.getIn().getHeader(UndertowConstants.EXCHANGE, WebSocketHttpExchange.class);
String realm = httpExchange.getRequestHeader(Constants.REALM_PARAM_NAME);
Principal principal = httpExchange.getUserPrincipal();
AuthContext authContext = null;
if (principal instanceof KeycloakPrincipal> keycloakPrincipal) {
authContext = new AccessTokenAuthContext(
keycloakPrincipal.getKeycloakSecurityContext().getRealm(),
keycloakPrincipal.getKeycloakSecurityContext().getToken()
);
} else if (principal instanceof BasicAuthContext) {
authContext = (BasicAuthContext) principal;
} else if (principal != null) {
LOG.log(INFO, "Unsupported user principal type: " + principal);
}
// Set an idle timeout value
webSocketChannel.setIdleTimeout(30000);
// Push auth and realm into channel for future use
webSocketChannel.setAttribute(Constants.AUTH_CONTEXT, authContext);
webSocketChannel.setAttribute(Constants.REALM_PARAM_NAME, realm);
exchange.getIn().setHeader(Constants.AUTH_CONTEXT, authContext);
exchange.getIn().setHeader(Constants.REALM_PARAM_NAME, realm);
exchange.getIn().setHeader(SESSION_OPEN, true);
sessionChannels.put(getSessionKey(exchange), webSocketChannel);
LOG.log(DEBUG, "Client connection created: " + webSocketChannel.getSourceAddress());
}
case ONCLOSE -> {
AuthContext authContext = (AuthContext)webSocketChannel.getAttribute(Constants.AUTH_CONTEXT);
String realm = (String)webSocketChannel.getAttribute(Constants.REALM_PARAM_NAME);
String sessionKey = getSessionKey(exchange);
exchange.getIn().setHeader(Constants.AUTH_CONTEXT, authContext);
exchange.getIn().setHeader(Constants.REALM_PARAM_NAME, realm);
exchange.getIn().setHeader(SESSION_CLOSE, true);
sessionChannels.remove(getSessionKey(exchange));
LOG.log(DEBUG, "Client connection closed: " + webSocketChannel.getSourceAddress());
LOG.log(TRACE, "Removing subscriptions for session: " + sessionKey);
synchronized (websocketSessionSubscriptionConsumers) {
websocketSessionSubscriptionConsumers.computeIfPresent(sessionKey, (s, subscriptionConsumers) -> {
subscriptionConsumers.forEach((subscriptionKey, consumer) -> removeSubscription(consumer));
return null;
});
}
}
case ONERROR -> {
AuthContext authContext = (AuthContext)webSocketChannel.getAttribute(Constants.AUTH_CONTEXT);
String realm = (String)webSocketChannel.getAttribute(Constants.REALM_PARAM_NAME);
String sessionKey = getSessionKey(exchange);
exchange.getIn().setHeader(Constants.AUTH_CONTEXT, authContext);
exchange.getIn().setHeader(Constants.REALM_PARAM_NAME, realm);
exchange.getIn().setHeader(SESSION_CLOSE_ERROR, true);
LOG.log(DEBUG, "Client connection error: " + webSocketChannel.getSourceAddress());
try {
webSocketChannel.close();
} catch (Exception ignored) {}
sessionChannels.remove(getSessionKey(exchange));
LOG.log(TRACE, "Removing subscriptions for session: " + sessionKey);
synchronized (websocketSessionSubscriptionConsumers) {
websocketSessionSubscriptionConsumers.computeIfPresent(sessionKey, (s, subscriptionConsumers) -> {
subscriptionConsumers.forEach((subscriptionKey, consumer) -> removeSubscription(consumer));
return null;
});
}
}
}
// Pass to gateway
if (websocketInterceptor != null) {
websocketInterceptor.accept(exchange);
}
})
.stop()
.endChoice()
.end()
.process(exchange -> {
WebSocketChannel webSocketChannel = exchange.getIn().getHeader(UndertowConstants.CHANNEL, WebSocketChannel.class);
AuthContext authContext = (AuthContext) webSocketChannel.getAttribute(Constants.AUTH_CONTEXT);
String realm = (String) webSocketChannel.getAttribute(Constants.REALM_PARAM_NAME);
exchange.getIn().setHeader(Constants.AUTH_CONTEXT, authContext);
exchange.getIn().setHeader(Constants.REALM_PARAM_NAME, realm);
// Do basic formatting of exchange
if (exchange.getIn().getBody() instanceof String bodyStr) {
if (bodyStr.startsWith(EventSubscription.SUBSCRIBE_MESSAGE_PREFIX)) {
exchange.getIn().setBody(exchange.getIn().getBody(EventSubscription.class));
} else if (bodyStr.startsWith(CancelEventSubscription.MESSAGE_PREFIX)) {
exchange.getIn().setBody(exchange.getIn().getBody(CancelEventSubscription.class));
} else if (bodyStr.startsWith(SharedEvent.MESSAGE_PREFIX)) {
exchange.getIn().setBody(exchange.getIn().getBody(SharedEvent.class));
}
}
if (exchange.getIn().getBody() instanceof RespondableEvent respondableEvent) {
// Inject a response consumer
respondableEvent.setResponseConsumer(ev -> sendToWebsocketSession(getSessionKey(exchange), ev));
}
// Pass to gateway
if (websocketInterceptor != null) {
websocketInterceptor.accept(exchange);
}
})
.process(exchange -> {
AuthContext authContext = exchange.getIn().getHeader(Constants.AUTH_CONTEXT, AuthContext.class);
String realm = exchange.getIn().getHeader(Constants.REALM_PARAM_NAME, String.class);
if (exchange.getIn().getBody() instanceof EventSubscription> subscription) {
String sessionKey = getSessionKey(exchange);
LOG.log(TRACE, () -> "Adding subscription for session '" + sessionKey + "': " + subscription);
if (!authorizeEventSubscription(realm, authContext, subscription)) {
sendToWebsocketSession(sessionKey, new UnauthorizedEventSubscription<>(subscription));
exchange.setRouteStop(true);
return;
}
// Force subscription to filter only value changed attribute events
if (subscription.getFilter() instanceof AssetFilter assetFilter) {
subscription.setFilter(assetFilter.setValueChanged(true));
}
// Notify the client that the subscription has been created
subscription.setSubscribed(true);
sendToWebsocketSession(sessionKey, subscription);
synchronized (websocketSessionSubscriptionConsumers) {
// Create subscription consumer and track it for future removal requests
Consumer extends SharedEvent> consumer = ev -> onWebsocketSubscriptionTriggered(sessionKey, subscription, ev);
Map> subscriptionConsumers = websocketSessionSubscriptionConsumers.computeIfAbsent(sessionKey, (s) -> new HashMap<>());
String subscriptionKey = subscription.getEventType() + subscription.getSubscriptionId();
subscriptionConsumers.put(subscriptionKey, consumer);
addSubscription(subscription, consumer);
}
exchange.setRouteStop(true);
} else if (exchange.getIn().getBody() instanceof CancelEventSubscription cancelEventSubscription) {
String sessionKey = getSessionKey(exchange);
LOG.log(TRACE, () -> "Cancelling subscription for session '" + sessionKey + "': " + cancelEventSubscription);
synchronized (websocketSessionSubscriptionConsumers) {
websocketSessionSubscriptionConsumers.computeIfPresent(sessionKey, (s, subscriptionConsumers) -> {
String subscriptionKey = cancelEventSubscription.getEventType() + cancelEventSubscription.getSubscriptionId();
Consumer extends Event> consumer = subscriptionConsumers.remove(subscriptionKey);
if (consumer != null) {
removeSubscription(consumer);
}
if (subscriptionConsumers.isEmpty()) {
return null;
}
return subscriptionConsumers;
});
}
exchange.setRouteStop(true);
} else if (exchange.getIn().getBody() instanceof SharedEvent event) {
if (!authorizeEventWrite(realm, authContext, event)) {
exchange.setRouteStop(true);
return;
}
// Special handling for incoming attribute events
if (event instanceof AttributeEvent attributeEvent) {
// Set timestamp as early as possible if not set
if (attributeEvent.getTimestamp() <= 0) {
attributeEvent.setTimestamp(timerService.getCurrentTimeMillis());
}
attributeEvent.setSource("WebsocketClient");
messageBrokerService.getFluentProducerTemplate()
.withBody(attributeEvent)
.to(ATTRIBUTE_EVENT_PROCESSOR)
.asyncSend();
exchange.setRouteStop(true);
return;
}
}
})
.to(PUBLISH_QUEUE)
.end();
// Send event to each interested subscribers
from(PUBLISH_QUEUE)
.routeId("ClientPublishToSubscribers")
.routeConfigurationId(ATTRIBUTE_EVENT_ROUTE_CONFIG_ID)
.threads().executorService(executorService)
.filter(body().isInstanceOf(SharedEvent.class))
.process(exchange -> {
SharedEvent event = exchange.getIn().getBody(SharedEvent.class);
sendToSubscribers(event);
});
}
@SuppressWarnings("unchecked")
protected void sendToSubscribers(T event) {
eventSubscriptions.forEach(eventSubscriptionConsumerPair -> {
EventSubscription> subscription = eventSubscriptionConsumerPair.getKey();
if (!subscription.getEventType().equals(event.getEventType())) {
return;
}
T filteredEvent = subscription.getFilter() == null ? event : ((EventSubscription) subscription).getFilter().apply(event);
if (filteredEvent == null) {
return;
}
Consumer consumer = (Consumer)eventSubscriptionConsumerPair.getValue();
consumer.accept(filteredEvent);
});
}
/**
* Authorisation must be done before adding the subscription and is the responsibility of subscription creators.
*/
public void addSubscription(EventSubscription extends Event> eventSubscription, Consumer extends Event> consumer) throws IllegalStateException {
eventSubscriptions.add(new Pair<>(eventSubscription, consumer));
}
public void addSubscription(Class eventClass, Consumer consumer) throws IllegalStateException {
addSubscription(new EventSubscription<>(eventClass, null), consumer);
}
public void addSubscription(Class eventClass, EventFilter filter, Consumer consumer) throws IllegalStateException {
addSubscription(new EventSubscription<>(eventClass, filter), consumer);
}
public void removeSubscription(Consumer extends Event> consumer) {
eventSubscriptions.removeIf(subscriptionConsumerPair -> subscriptionConsumerPair.value == consumer);
}
@Override
public void start(Container container) {
started = true;
}
@Override
public void stop(Container container) {
started = false;
}
public void addSubscriptionAuthorizer(EventSubscriptionAuthorizer authorizer) {
this.eventSubscriptionAuthorizers.add(authorizer);
}
public void addEventAuthorizer(EventAuthorizer authorizer) {
this.eventAuthorizers.add(authorizer);
}
/**
* This handles basic authorisation checks for clients that want to subscribe to events in the system
*/
public boolean authorizeEventSubscription(String realm, AuthContext authContext, EventSubscription> subscription) {
boolean authorized = eventSubscriptionAuthorizers.stream()
.anyMatch(authorizer -> authorizer.authorise(realm, authContext, subscription));
if (!authorized) {
if (authContext != null) {
LOG.log(DEBUG, "Client not authorised to subscribe: subscription=" + subscription + ", requestRealm=" + realm + ", username=" + authContext.getUsername() + ", userRealm=" + authContext.getAuthenticatedRealmName());
} else {
LOG.log(DEBUG, "Client not authorised to subscribe: subscription=" + subscription + ", requestRealm=" + realm + ", user=null");
}
}
return authorized;
}
/**
* This handles basic authorisation checks for clients that want to write an event to the system; this gets hit a lot
* so should be as performant as possible
*/
// TODO: Implement auth cache in OR that covers HTTP, WS and MQTT (ActiveMQ Authorization cache currently covers MQTT which is the main use case)
public boolean authorizeEventWrite(String realm, AuthContext authContext, T event) {
boolean authorized = eventAuthorizers.stream()
.anyMatch(authorizer -> authorizer.authorise(realm, authContext, event));
if (!authorized) {
if (authContext != null) {
LOG.log(DEBUG, () -> "Client not authorised to send event: type=" + event.getEventType() + ", requestRealm=" + realm + ", user=" + authContext.getUsername() + ", userRealm=" + authContext.getAuthenticatedRealmName());
} else {
LOG.log(DEBUG, () -> "Client not authorised to send event: type=" + event.getEventType() + ", requestRealm=" + realm + ", user=null");
}
}
return authorized;
}
/**
* Publish an event to interested subscribers
*/
public void publishEvent(T event) {
// Only publish if service is started
if (!started) {
return;
}
if (!(event instanceof SyslogEvent)) {
LOG.log(System.Logger.Level.TRACE, () -> "Publishing to subscribers: " + event);
}
messageBrokerService.getFluentProducerTemplate()
.withBody(event)
.to(PUBLISH_QUEUE)
.asyncSend();
}
public void setWebsocketInterceptor(Consumer consumer) {
this.websocketInterceptor = consumer;
}
protected void onWebsocketSubscriptionTriggered(String sessionKey, EventSubscription> subscription, SharedEvent event) {
// Wrap subscription event in triggered wrapper for client to easily route it
TriggeredEventSubscription> triggeredEventSubscription = new TriggeredEventSubscription<>(Collections.singletonList(event), subscription.getSubscriptionId());
messageBrokerService.getFluentProducerTemplate()
.withBody(triggeredEventSubscription)
.withHeader(UndertowConstants.CONNECTION_KEY, sessionKey)
.to(WEBSOCKET_URI)
.asyncSend();
}
public void sendToWebsocketSession(String sessionKey, Object data) {
messageBrokerService.getFluentProducerTemplate()
.withBody(data)
.withHeader(UndertowConstants.CONNECTION_KEY, sessionKey)
.to(WEBSOCKET_URI)
.asyncSend();
}
public void closeWebsocketSession(String sessionKey) {
WebSocketChannel webSocketChannel = sessionChannels.get(sessionKey);
if (webSocketChannel != null) {
LOG.log(INFO, () -> "Force closing websocket session: " + sessionKey);
try {
webSocketChannel.close();
} catch (IOException e) {
LOG.log(INFO, () -> "Failed to close websocket session: " + sessionKey);
throw new RuntimeException(e);
}
}
}
@Override
public String toString() {
return getClass().getSimpleName() + "{" +
'}';
}
}