/* Copyright (c) 2011 Danish Maritime Authority.
*
* Licensed 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 net.maritimecloud.internal.mms.transport;
import net.maritimecloud.internal.mms.messages.Close;
import net.maritimecloud.internal.mms.messages.Connected;
import net.maritimecloud.internal.mms.messages.Hello;
import net.maritimecloud.internal.mms.messages.PositionReport;
import net.maritimecloud.internal.mms.messages.spi.MmsMessage;
import net.maritimecloud.internal.net.messages.Broadcast;
import net.maritimecloud.internal.net.messages.BroadcastAck;
import net.maritimecloud.internal.net.messages.MethodInvoke;
import net.maritimecloud.internal.net.messages.MethodInvokeResult;
import net.maritimecloud.message.Message;
import net.maritimecloud.message.MessageFormatType;
import javax.script.Invocable;
import javax.script.ScriptEngine;
import javax.script.ScriptEngineManager;
import java.io.IOException;
import java.util.Base64;
import java.util.Date;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.logging.ConsoleHandler;
import java.util.logging.FileHandler;
import java.util.logging.Formatter;
import java.util.logging.Level;
import java.util.logging.LogRecord;
import java.util.logging.Logger;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Will handle logging of {@code MmsMessage} entities at the transport layer level.
* <p>
* The message log can be associated with a file name, whose name pattern must follow
* the guidelines of the
* <a href="http://docs.oracle.com/javase/7/docs/api/java/util/logging/FileHandler.html">FileHandler</a> class.<br/>
* If the file is undefined, the messages will be logged to {@code System.out}.
* <p>
* The access log format determines the format of the logged messages:
* <ul>
* <li>text: Logs all messages in a multi-line json format.</li>
* <li>text: Logs all messages as base64-encoded single-line format.</li>
* <li>compact: Skips certain messages, such as position reports, and formats the messages
* in a simplified compact format.</li>
* </ul>
* <p>
* If the <i>filter</i> is defined, only the messages matching the filter are logged.<br>
* Example: <code>inbound && clientId == 'mmsi:565009926' && msg.m.positionTime !== undefined</code>
*/
@SuppressWarnings("unused")
public class AccessLogManager {
public static final String LOG_TO_STDOUT = "stdout";
List<MessageLog> messageLogs = new CopyOnWriteArrayList<>();
/**
* Instantiated with an access log configuration
* @param conf the access log configuration
*/
public AccessLogManager(AccessLogConfiguration conf) {
Objects.requireNonNull(conf);
if (conf.getAccessLog() != null && conf.getAccessLog().trim().length() > 0) {
String logFile = conf.getAccessLog().equalsIgnoreCase(LOG_TO_STDOUT) ? null : conf.getAccessLog();
AccessLogFormat format = conf.getAccessLogFormat() != null
? conf.getAccessLogFormat()
: AccessLogFormat.TEXT;
String filter = conf.getAccessLogFilter();
addMessageLog(logFile, format, filter);
}
}
/**
* Adds a logger defined by the given log file.
* If no file is specified, System.out is used instead.
*
* @param file the log file. If null, System.out is used instead.
* @param format the message format
* @return the message log or null if undefined
*/
public MessageLog addMessageLog(String file, AccessLogFormat format) {
return addMessageLog(file, format, null);
}
/**
* Adds a logger defined by the given log file and filter.
* If no file is specified, System.out is used instead.
*
* @param file the log file. If null, System.out is used instead.
* @param format the message format
* @param filter the log filter
* @return the message log or null if undefined
*/
public MessageLog addMessageLog(String file, AccessLogFormat format, String filter) {
try {
MessageLog log = new MessageLog(file, format, filter);
messageLogs.add(log);
return log;
} catch (IOException e) {
return null;
}
}
/**
* Removes the given message log
*
* @param log the message log to remove
* @return if the log was removed
*/
public boolean removeMessageLog(MessageLog log) {
return messageLogs.removeIf(l -> l == log);
}
/**
* Removes the given message log
*
* @param file the file of the message log to remove.
* @return if the log was removed
*/
public boolean removeMessageLog(String file) {
return messageLogs.removeIf(l -> l.file.equalsIgnoreCase(file));
}
/**
* Logs the message
*
* @param msg the message to log
* @param clientId the id of the recipient or sender, or null if undefined
* @param inbound inbound or outbound
* @param type the message type
*/
public void logMessage(MmsMessage msg, String clientId, boolean inbound, MessageFormatType type) {
messageLogs.forEach(log -> {
try {
log.logMessage(msg, clientId, inbound, type);
} catch (Exception e) {
// Do not propagate exceptions;
}
});
}
/**
* The access log message format
*/
public enum AccessLogFormat {
TEXT, // Log in JSON format
BINARY, // Log in binary format (Base64 encoded)
COMPACT // Log a non-complete compact representation of the messages
}
/**
* Interface that should be implemented by the main configuration class
*/
public interface AccessLogConfiguration {
/**
* Returns the file path to the access log, or 'stdout' for System.out
* @return the accessLog
*/
String getAccessLog();
/**
* Returns the access log format
* @return the access log format
*/
AccessLogFormat getAccessLogFormat();
/**
* Returns the access log filter
* @return the access log filter
*/
String getAccessLogFilter();
}
/**
* Represents a message log as defined by a log file name and optionally
* a log filter
*/
public static class MessageLog extends Formatter {
private final Logger log = Logger.getLogger(MessageLog.class.getSimpleName());
private final String file;
private final String lineFormat = "%1$tb %1$td, %1$tY %1$tH:%1$tM:%1$tS:%1$tL %1$Tz - %2$s - %3$s - %4$s - %5$s%n";
private final AccessLogFormat accessLogFormat;
private Invocable filterFunction = null;
/**
* Constructor
* @param file the log file
* @param accessLogFormat the message format
* @param filter the log filter
*/
public MessageLog(String file, AccessLogFormat accessLogFormat, String filter) throws IOException {
this.file = file;
this.accessLogFormat = accessLogFormat;
// Instantiate the filter Javascript engine
if (filter != null && filter.trim().length() > 0) {
try {
// Considerations: Various documentation suggests that the ScriptEngine is indeed threadsafe.
// However, shared state is not isolated, so, setting the parameters (msg, clientId, etc.) as
// script engine state and evaluating the filter directly would not work correctly.
// Instead, we wrap the filter in a function and call that function.
ScriptEngine jsEngine = new ScriptEngineManager().getEngineByName("JavaScript");
jsEngine.eval("function doLog(msg, clientId, inbound, msgType) { return " + filter + "; }");
filterFunction = (Invocable)jsEngine;
} catch (Exception e) {
throw new IOException("Invalid access log filter: " + filter);
}
}
log.setUseParentHandlers(false);
if (file != null) {
FileHandler fh = new FileHandler(file, true);
fh.setFormatter(this);
fh.setLevel(Level.ALL);
log.addHandler(fh);
} else {
ConsoleHandler ch = new ConsoleHandler();
ch.setFormatter(this);
ch.setLevel(Level.ALL);
log.addHandler(ch);
}
}
/**
* Called to log a new inbound or outbound message
* @param msg the message to log
* @param clientId the id of the recipient or sender, or null if undefined
* @param inbound inbound or outbound
* @param type the message type
*/
public void logMessage(MmsMessage msg, String clientId, boolean inbound, MessageFormatType type) {
String msgType = type == MessageFormatType.MACHINE_READABLE ? "bin" : "txt";
boolean doLog = checkLogMessage(msg, inbound);
// Check if a message filter has been defined
if (filterFunction != null) {
try {
doLog = (Boolean)filterFunction.invokeFunction("doLog", msg, clientId, inbound, msgType);
} catch (Exception e) {
doLog = false;
}
}
// Log the message
if (doLog) {
try {
String record = String.format(
lineFormat,
new Date(),
msgType,
inbound ? "in " : "out",
clientId == null ? "N/A" : clientId,
encodeMessage(msg)
);
log.info(record);
} catch (Exception e) {
// Only include properly formatted messages in the access log
}
}
}
/**
* Check if the message should be logged. When the 'compact' access log format is selected
* certain types of messages are omitted.
* @param msg the message to check
* @param inbound inbound or outbound
* @return if the message should be logged
*/
private boolean checkLogMessage(MmsMessage msg, boolean inbound) {
if (accessLogFormat == AccessLogFormat.COMPACT) {
Message m = msg.getMessage();
// The different condition where we want to skip a log record in the 'compact' access log format
if (m instanceof Broadcast && !inbound) {
return false;
} else if (m instanceof BroadcastAck ||
m instanceof Connected ||
m instanceof PositionReport ||
m instanceof MethodInvokeResult) {
return false;
}
}
return true;
}
/**
* Encodes the given message
* @param msg the message
* @return the encoded message
*/
private String encodeMessage(MmsMessage msg) throws IOException {
if (accessLogFormat == AccessLogFormat.BINARY) {
return Base64.getEncoder().encodeToString(msg.toBinary());
} else if (accessLogFormat == AccessLogFormat.COMPACT) {
return formatMessageCompact(msg);
} else {
// Indent each line in the JSON blob
return String.format("%n%s", msg.toText().replaceAll("(?m)^", " "));
}
}
/** Simple utility method that extracts the parameter value */
public static String extractParam(String txt, String param, String defaultValue) {
try {
Matcher m = Pattern.compile(".*\"" + param + "\":\\s*\"(.*)\".*", Pattern.MULTILINE).matcher(txt);
return m.find() ? m.group(1) : defaultValue;
} catch (Exception e) {
return defaultValue;
}
}
/**
* Formats the message in a compact fashion
* @param msg the message to format
* @return the result
*/
private String formatMessageCompact(MmsMessage msg) {
if (msg.getMessage() instanceof Close) {
return String.format("Close[%s]", ((Close)msg.getMessage()).getCloseCode());
} else if (msg.getMessage().getClass().getPackage().equals(Hello.class.getPackage())) {
return msg.getMessage().getClass().getSimpleName();
} else if (msg.getMessage() instanceof Broadcast) {
return String.format("Broadcast[%s]", ((Broadcast) msg.getMessage()).getBroadcastType());
} else if (msg.getMessage() instanceof MethodInvoke) {
MethodInvoke invoke = (MethodInvoke)msg.getMessage();
if ("Services.registerEndpoint".equals(invoke.getEndpointMethod())) {
return String.format("Services.registerEndpoint[%s]", extractParam(invoke.getParameters(), "endpointName", ""));
} else if ("Services.unregisterEndpoint".equals(invoke.getEndpointMethod())) {
return String.format("Services.unregisterEndpoint[%s]", extractParam(invoke.getParameters(), "endpointName", ""));
} else if ("Services.locate".equals(invoke.getEndpointMethod())) {
return String.format("Services.locate[%s]", extractParam(invoke.getParameters(), "endpointName", ""));
} else if ("Services.subscribe".equals(invoke.getEndpointMethod())) {
return String.format("Services.subscribe[%s]", extractParam(invoke.getParameters(), "name", ""));
}
return String.format("MethodInvoke[%s]", invoke.getEndpointMethod());
} else if (msg.getMessage().getClass().getPackage().equals(MethodInvoke.class.getPackage())) {
return msg.getMessage().getClass().getSimpleName();
} else {
return msg.getMessage().getClass().getName();
}
}
/** {@inheritDoc} */
@Override
public String format(LogRecord record) {
return record.getMessage();
}
}
}