package net.redhillsoftware.bonza;
import java.io.File;
import java.io.FileFilter;
import java.io.IOException;
import java.net.URI;
import java.net.URL;
import java.text.MessageFormat;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import javax.servlet.http.HttpServletRequest;
import org.apache.commons.io.FileUtils;
import org.apache.commons.io.monitor.FileAlterationListenerAdaptor;
import org.apache.commons.io.monitor.FileAlterationMonitor;
import org.apache.commons.io.monitor.FileAlterationObserver;
/**
* Hodge-podege mess of the bonza configuration.
*/
public class BonzaConfig {
private final Map<String, String> m_uri2HostMap;
private final int m_port;
private volatile boolean m_logRequests;
public static BonzaConfig parseOptions(String[] args) {
BonzaConfig cfg = null;
try {
if (args.length == 0) {
File file = new File(".bonza");
cfg = parse(file, true);
} else if (args.length == 1 && isFilename(args[0])) {
File file = new File(args[0]);
cfg = parse(file, true);
} else {
List<String> list = Arrays.asList(args);
cfg = parse(list);
}
} catch (Exception e) {
Bonza.err(usage());
System.exit(1);
}
return cfg;
}
public BonzaConfig(int port, Map<String, String> uri2HostMap, boolean logRequests) {
m_port = port;
m_uri2HostMap = uri2HostMap;
m_logRequests = logRequests;
}
public Map<String, String> getUri2HostMap() {
return m_uri2HostMap;
}
public int getPort() {
return m_port;
}
public String toString() {
String result = "Listening to port " + m_port;
for (String key : m_uri2HostMap.keySet()) {
result += "\n\t" + key + " -> " + m_uri2HostMap.get(key);
}
result += m_logRequests ? "\nLogging requests." : "\nNot logging requests";
return result;
}
/**
* Determine the appropriate url to make for this request and null if no request can be made.
*/
public String mapRequest(String requestUri) {
String uri = findMatchingURI(requestUri);
if (uri == null) {
return null;
}
String routeTo = m_uri2HostMap.get(uri);
String remapped = requestUri.substring(uri.length());
String newRequest = routeTo + remapped;
return newRequest;
}
/**
* Adjust the location header (if possible) so that redirects will pass back through the reverse proxy.
*/
public String reverseMap(String locationHeader, HttpServletRequest originalRequest) {
try {
URL url = new URL(locationHeader);
String uri2 = findMatchingURI(url.getPath());
if (uri2 != null) {
String remappedPath = url.getPath().substring(uri2.length());
URI newURI = new URI(originalRequest.getScheme(),
originalRequest.getHeader("Host"),
uri2 + remappedPath,
url.getQuery(),
url.getRef());
locationHeader = newURI.toString();
}
} catch (Exception e) {
err(e.getMessage());
}
return locationHeader;
}
public Logger requestStart(HttpServletRequest request, String requestURI, String matchedURL) {
Logger logger = new Logger(request, requestURI, matchedURL);
return logger;
}
private String findMatchingURI(String path) {
for (String uri : m_uri2HostMap.keySet()) {
if (path.startsWith(uri)) {
return uri;
}
}
return null;
}
private void watchSourceFile(final File file) throws Exception {
FileAlterationObserver observer = new FileAlterationObserver(file.getAbsoluteFile().getParentFile(), new FileFilter() {
@Override
public boolean accept(File pathname) {
return pathname.getAbsolutePath().equals(file.getAbsolutePath());
}
});
observer.addListener(new FileAlterationListenerAdaptor() {
@Override
public void onFileChange(File file) {
try {
BonzaConfig newCFG = parse(file, false);
if (configChanged(newCFG)) {
out("\nFair go!! Configuration change detected!!!");
m_uri2HostMap.entrySet().clear();
m_uri2HostMap.putAll(newCFG.getUri2HostMap());
m_logRequests = newCFG.m_logRequests;
out(BonzaConfig.this.toString());
}
if (newCFG.getPort() != m_port) {
err("\nPort changes require a restart!!!");
}
} catch (IOException e) {
err("Error parsing " + file.getName());
} catch (Exception e) {
err("Error parsing " + file.getName());
}
}
private boolean configChanged(BonzaConfig newCFG) {
return !newCFG.getUri2HostMap().equals(m_uri2HostMap) || newCFG.m_logRequests != m_logRequests;
}
});
FileAlterationMonitor monitor = new FileAlterationMonitor(2000);
monitor.addObserver(observer);
monitor.start();
}
private static BonzaConfig parse(File file, boolean addListener) throws Exception {
BonzaConfig cfg = parse(FileUtils.readLines(file));
if (addListener) {
cfg.watchSourceFile(file);
}
return cfg;
}
private static BonzaConfig parse(List<String> argsList) {
List<String> filtered = filterCommentsAndBlankLines(argsList);
boolean logRequests = true;
Map<String, String> mapping = new HashMap<String, String>();
int port = Integer.parseInt(filtered.get(0));
List<String> proxyExpressions = filtered.subList(1, filtered.size());
for (String arg : proxyExpressions) {
if (arg.equals("-quiet")) {
logRequests = false;
continue;
}
String[] parts = arg.split("=");
mapping.put(parts[0], parts[1]);
}
return new BonzaConfig(port, mapping, logRequests);
}
private static List<String> filterCommentsAndBlankLines(List<String> argsList) {
ArrayList<String> toReturn = new ArrayList<String>(argsList.size());
for (String arg : argsList) {
if (arg.indexOf('#') >= 0) {
arg = arg.substring(0,arg.indexOf('#'));
}
arg = arg.trim();
if (arg.length() == 0) {
continue;
}
toReturn.add(arg);
}
return toReturn;
}
private static boolean isFilename(String filename) {
File f = new File(filename);
return f.exists() && f.canRead();
}
private static String usage() {
String usage = "Usage: bonza [filename | port [-quiet] prefix1=resource1 [prefix2=resource2]...]" +
"\n\tIf invoked without arguments, reads configuration from a .bonza file in the same directory." +
"\n\tIf invoked with a single argument that is a filename, reads configuration from the named file." +
"\n\tOtherwise reads argumenst from the command line." +
"\n\n" +
"Proxy Mapping Expressions: uri_prefix=proxied_resource" +
"\n\tRoute requests with the given uri prefix to the proxied resource. " +
"The uri prefix of the request is replaced with the uri component of the proxied resource." +
"\n\ne.g. given a mapping expresssion /google=http://google.net/ then a request to /google/blah?q=foo will result in a request to " +
"http://google.net/blah?q=foo" +
"\n\n" +
"File Format:" +
"\n\tDelimited lines, the first line is the port to bind on with the remaining lines consist of proxy mapping " +
"expressions. The # character may be used as a comment character and will result in everything after it being ignored." +
"\n\tThe system will watch this file for changes and reload the configuration as necessary. " +
"\n\tNote - changes to the listening port are not supported under this mechanism." +
"\n\n" +
"Logging:" +
"\n\tThe system logs requests in the following format -" +
"\n\t\t {timestamp} {source_ip} {method} {uri} -> {proxied_url} > {response_code} in {request_time} ms" +
"\n\n\tLogging may be disabled by passing -quiet on the command line or in the file.";
return usage;
}
public void out(String msg) {
System.out.println(msg);
}
public void err(String emsg) {
System.err.println(emsg);
}
public class Logger {
private long m_requestStart;
private String m_remoteAddress;
private String m_method;
private String m_uri;
private String m_proxiedURL;
public Logger(HttpServletRequest request, String requestURI, String matchedURL) {
m_requestStart = System.currentTimeMillis();
m_remoteAddress = request.getRemoteAddr();
m_method = request.getMethod();
m_uri = requestURI;
m_proxiedURL = matchedURL != null ? matchedURL : "NO_MATCH";
}
public void logResponse(int statuscode) {
long elapsed = System.currentTimeMillis() - m_requestStart;
String logMessage = MessageFormat.format("{0} {1} {2} {3} -> {4} > {5} in {6} ms.",
timestampString(),
m_remoteAddress,
m_method,
m_uri,
m_proxiedURL,
statuscode,
elapsed);
if (m_logRequests) {
out(logMessage);
}
}
private String timestampString() {
Date now = new Date(m_requestStart);
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS");
return sdf.format(now);
}
}
}