blob: e7cb59087a1d166ebeb4151f17dd009093b534b3 [file] [log] [blame]
package brooklyn.entity.proxy.nginx;
import static java.lang.String.format;
import java.io.ByteArrayInputStream;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import brooklyn.entity.Entity;
import brooklyn.entity.Group;
import brooklyn.entity.basic.Description;
import brooklyn.entity.basic.Lifecycle;
import brooklyn.entity.basic.MethodEffector;
import brooklyn.entity.basic.SoftwareProcessEntity;
import brooklyn.entity.group.AbstractMembershipTrackingPolicy;
import brooklyn.entity.proxy.AbstractController;
import brooklyn.entity.proxy.ProxySslConfig;
import brooklyn.event.SensorEvent;
import brooklyn.event.SensorEventListener;
import brooklyn.event.basic.BasicConfigKey;
import brooklyn.event.feed.ConfigToAttributes;
import brooklyn.event.feed.http.HttpFeed;
import brooklyn.event.feed.http.HttpPollConfig;
import brooklyn.event.feed.http.HttpPollValue;
import brooklyn.util.MutableMap;
import brooklyn.util.ResourceUtils;
import brooklyn.util.flags.SetFromFlag;
import brooklyn.util.internal.TimeExtras;
import brooklyn.util.text.Strings;
import com.google.common.base.Function;
import com.google.common.base.Functions;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Iterables;
import com.google.common.collect.LinkedHashMultimap;
import com.google.common.collect.Multimap;
import com.google.common.collect.Sets;
/**
* An entity that represents an Nginx proxy (e.g. for routing requests to servers in a cluster).
* <p>
* The default driver *builds* nginx from source (because binaries are not reliably available, esp not with sticky sessions).
* This requires gcc and other build tools installed. The code attempts to install them but inevitably
* this entity may be more finicky about the OS/image where it runs than others.
* <p>
* Paritcularly on OS X we require Xcode and command-line gcc installed and on the path.
* <p>
* See {@link http://library.linode.com/web-servers/nginx/configuration/basic} for useful info/examples
* of configuring nginx.
* <p>
* https configuration is supported, with the certificates providable on a per-UrlMapping basis or a global basis.
* (not supported to define in both places.)
* per-Url is useful if different certificates are used for different server names,
* or different ports if that is supported.
* see more info on Ssl in {@link ProxySslConfig}.
*/
public class NginxController extends AbstractController {
private static final Logger LOG = LoggerFactory.getLogger(NginxController.class);
static { TimeExtras.init(); }
public static final MethodEffector<Void> GET_CURRENT_CONFIGURATION =
new MethodEffector<Void>(NginxController.class, "getCurrentConfiguration");
@SetFromFlag("version")
public static final BasicConfigKey<String> SUGGESTED_VERSION =
new BasicConfigKey<String>(SoftwareProcessEntity.SUGGESTED_VERSION, "1.3.7");
@SetFromFlag("sticky")
public static final BasicConfigKey<Boolean> STICKY =
new BasicConfigKey<Boolean>(Boolean.class, "nginx.sticky", "whether to use sticky sessions", true);
@SetFromFlag("httpPollPeriod")
public static final BasicConfigKey<Long> HTTP_POLL_PERIOD =
new BasicConfigKey<Long>(Long.class, "nginx.sensorpoll.http", "poll period (in milliseconds)", 1000L);
private HttpFeed httpFeed;
public NginxController(Entity parent) {
this(MutableMap.of(), parent);
}
public NginxController(Map properties){
this(properties, null);
}
public NginxController(Map properties, Entity parent) {
super(properties, parent);
}
@Override
public void reload() {
NginxSshDriver driver = (NginxSshDriver)getDriver();
if (driver==null) {
Lifecycle state = getAttribute(NginxController.SERVICE_STATE);
throw new IllegalStateException("Cannot reload (no driver instance; stopped? (state="+state+")");
}
driver.reload();
}
public boolean isSticky() {
return getConfig(STICKY);
}
@Override
public void connectSensors() {
super.connectSensors();
ConfigToAttributes.apply(this);
// "up" is defined as returning a valid HTTP response from nginx (including a 404 etc)
httpFeed = HttpFeed.builder()
.entity(this)
.period(getConfig(HTTP_POLL_PERIOD))
.baseUri(getAttribute(AbstractController.ROOT_URL))
.baseUriVars(ImmutableMap.of("include-runtime","true"))
.poll(new HttpPollConfig<Boolean>(SERVICE_UP)
.onSuccess(new Function<HttpPollValue, Boolean>() {
@Override public Boolean apply(HttpPollValue input) {
// Accept any nginx response (don't assert specific version), so that sub-classing
// for a custom nginx build is not strict about custom version numbers in headers
List<String> actual = input.getHeaderLists().get("Server");
return actual != null && actual.size() == 1 && actual.get(0).startsWith("nginx");
}})
.onError(Functions.constant(false)))
.build();
// Can guarantee that parent/managementContext has been set
Group urlMappings = getConfig(URL_MAPPINGS);
if (urlMappings != null) {
// Listen to the targets of each url-mapping changing
subscribeToMembers(urlMappings, UrlMapping.TARGET_ADDRESSES, new SensorEventListener<Collection<String>>() {
@Override public void onEvent(SensorEvent<Collection<String>> event) {
update();
}});
// Listen to url-mappings being added and removed
AbstractMembershipTrackingPolicy policy = new AbstractMembershipTrackingPolicy() {
@Override protected void onEntityChange(Entity member) { update(); }
@Override protected void onEntityAdded(Entity member) { update(); }
@Override protected void onEntityRemoved(Entity member) { update(); }
};
addPolicy(policy);
policy.setGroup(urlMappings);
}
}
@Override
public void stop() {
// TODO Want http.poll to set SERVICE_UP to false on IOException. How?
// And don't want stop to race with the last poll.
super.stop();
setAttribute(SERVICE_UP, false);
}
@Override
protected void disconnectSensors() {
super.disconnectSensors();
if (httpFeed != null) httpFeed.stop();
}
@Override
public Class getDriverInterface() {
return NginxDriver.class;
}
public void doExtraConfigurationDuringStart() {
reconfigureService();
}
@Description("Gets the current server configuration (by brooklyn recalculating what the config should be); does not affect the server")
public String getCurrentConfiguration() {
return getConfigFile();
}
@Override
protected void reconfigureService() {
String cfg = getConfigFile();
if (cfg==null) return;
if (LOG.isDebugEnabled()) LOG.debug("Reconfiguring {}, targetting {} and {}", new Object[] {this, serverPoolAddresses, findUrlMappings()});
if (LOG.isTraceEnabled()) LOG.trace("Reconfiguring {}, config file:\n{}", this, cfg);
NginxSshDriver driver = (NginxSshDriver)getDriver();
if (!driver.isCustomizationCompleted()) {
if (LOG.isDebugEnabled()) LOG.debug("Reconfiguring {}, but driver's customization not yet complete so aborting");
return;
}
driver.getMachine().copyTo(new ByteArrayInputStream(cfg.getBytes()), driver.getRunDir()+"/conf/server.conf");
installSslKeys("global", getConfig(SSL_CONFIG));
for (UrlMapping mapping: findUrlMappings()) {
//cache ensures only the first is installed, which is what is assumed below
installSslKeys(mapping.getDomain(), mapping.getConfig(UrlMapping.SSL_CONFIG));
}
}
private final Set<String> installedKeysCache = Sets.newLinkedHashSet();
/** installs SSL keys named as ID.{crt,key} where nginx can find them;
* currently skips re-installs (does not support changing)
*/
protected void installSslKeys(String id, ProxySslConfig ssl) {
if (ssl == null) return;
if (installedKeysCache.contains(id)) return;
NginxSshDriver driver = (NginxSshDriver) getDriver();
if (!Strings.isEmpty(ssl.getCertificateSourceUrl())) {
String certificateDestination = Strings.isEmpty(ssl.getCertificateDestination()) ? driver.getRunDir() + "/conf/" + id + ".crt" : ssl.getCertificateDestination();
driver.getMachine().copyTo(ImmutableMap.of("permissions", "0400"),
new ResourceUtils(this).getResourceFromUrl(ssl.getCertificateSourceUrl()),
certificateDestination);
}
if (!Strings.isEmpty(ssl.getKeySourceUrl())) {
String keyDestination = Strings.isEmpty(ssl.getKeyDestination()) ? driver.getRunDir() + "/conf/" + id + ".key" : ssl.getKeyDestination();
driver.getMachine().copyTo(ImmutableMap.of("permissions", "0400"),
new ResourceUtils(this).getResourceFromUrl(ssl.getKeySourceUrl()),
keyDestination);
}
installedKeysCache.add(id);
}
public String getConfigFile() {
// TODO should refactor this method to a new class with methods e.g. NginxConfigFileGenerator...
NginxSshDriver driver = (NginxSshDriver)getDriver();
if (driver==null) {
if (LOG.isDebugEnabled()) LOG.debug("No driver for {}, so not generating config file (is entity stopping? state={})",
this, getAttribute(NginxController.SERVICE_STATE));
return null;
}
StringBuilder config = new StringBuilder();
config.append("\n");
config.append(format("pid %s/logs/nginx.pid;\n",driver.getRunDir()));
config.append("events {\n");
config.append(" worker_connections 8196;\n");
config.append("}\n");
config.append("http {\n");
ProxySslConfig globalSslConfig = getConfig(SSL_CONFIG);
boolean ssl = globalSslConfig != null;
if (ssl) {
verifyConfig(globalSslConfig);
appendSslConfig("global", config, " ", globalSslConfig, true, true);
};
// If no servers, then defaults to returning 404
// TODO Give nicer page back
if (getDomain()!=null || serverPoolAddresses==null || serverPoolAddresses.isEmpty()) {
config.append(" server {\n");
config.append(getCodeForServerConfig());
config.append(" listen "+getPort()+";\n");
config.append(getCodeFor404());
config.append(" }\n");
}
// For basic round-robin across the server-pool
if (serverPoolAddresses != null && serverPoolAddresses.size() > 0) {
config.append(format(" upstream "+getId()+" {\n"));
if (isSticky()){
config.append(" sticky;\n");
}
for (String address: serverPoolAddresses){
config.append(" server "+address+";\n");
}
config.append(" }\n");
config.append(" server {\n");
config.append(getCodeForServerConfig());
config.append(" listen "+getPort()+";\n");
if (getDomain()!=null)
config.append(" server_name "+getDomain()+";\n");
config.append(" location / {\n");
config.append(" proxy_pass "+(globalSslConfig != null && globalSslConfig.getTargetIsSsl() ? "https" : "http")+"://"+getId()+";\n");
config.append(" }\n");
config.append(" }\n");
}
// For mapping by URL
Iterable<UrlMapping> mappings = findUrlMappings();
Multimap<String, UrlMapping> mappingsByDomain = LinkedHashMultimap.create();
for (UrlMapping mapping : mappings) {
Collection<String> addrs = mapping.getAttribute(UrlMapping.TARGET_ADDRESSES);
if (addrs != null && addrs.size() > 0) {
mappingsByDomain.put(mapping.getDomain(), mapping);
}
}
for (UrlMapping um : mappings) {
Collection<String> addrs = um.getAttribute(UrlMapping.TARGET_ADDRESSES);
if (addrs != null && addrs.size() > 0) {
String location = um.getPath() != null ? um.getPath() : "/";
config.append(format(" upstream "+um.getUniqueLabel()+" {\n"));
if (isSticky()){
config.append(" sticky;\n");
}
for (String address: addrs) {
config.append(" server "+address+";\n");
}
config.append(" }\n");
}
}
for (String domain : mappingsByDomain.keySet()) {
config.append(" server {\n");
config.append(getCodeForServerConfig());
config.append(" listen "+getPort()+";\n");
config.append(" server_name "+domain+";\n");
boolean hasRoot = false;
// set up SSL
ProxySslConfig localSslConfig = null;
for (UrlMapping mappingInDomain : mappingsByDomain.get(domain)) {
ProxySslConfig sslConfig = mappingInDomain.getConfig(UrlMapping.SSL_CONFIG);
if (sslConfig!=null) {
verifyConfig(sslConfig);
if (localSslConfig!=null) {
if (localSslConfig.equals(sslConfig)) {
//ignore identical config specified on multiple mappings
} else {
LOG.warn("{} mapping {} provides SSL config for {} when a different config had already been provided by another mapping, ignoring this one",
new Object[] {this, mappingInDomain, domain});
}
} else if (globalSslConfig!=null) {
if (globalSslConfig.equals(sslConfig)) {
//ignore identical config specified on multiple mappings
} else {
LOG.warn("{} mapping {} provides SSL config for {} when a different config had been provided at root nginx scope, ignoring this one",
new Object[] {this, mappingInDomain, domain});
}
} else {
//new config, is okay
localSslConfig = sslConfig;
}
}
}
boolean serverSsl;
if (localSslConfig!=null) {
serverSsl = appendSslConfig(""+domain, config, " ", localSslConfig, true, true);
} else if (globalSslConfig!=null) {
// can't set ssl_certificate globally, so do it per server
serverSsl = true;
}
for (UrlMapping mappingInDomain : mappingsByDomain.get(domain)) {
// TODO Currently only supports "~" for regex. Could add support for other options,
// such as "~*", "^~", literals, etc.
boolean isRoot = mappingInDomain.getPath()==null || mappingInDomain.getPath().length()==0 || mappingInDomain.getPath().equals("/");
if (isRoot && hasRoot) {
LOG.warn(""+this+" mapping "+mappingInDomain+" provides a duplicate / proxy, ignoring");
} else {
hasRoot |= isRoot;
String location = isRoot ? "/" : "~ " + mappingInDomain.getPath();
config.append(" location "+location+" {\n");
Collection<UrlRewriteRule> rewrites = mappingInDomain.getConfig(UrlMapping.REWRITES);
if (rewrites != null && rewrites.size() > 0) {
for (UrlRewriteRule rule: rewrites) {
config.append(" rewrite \"^"+rule.getFrom()+"$\" \""+rule.getTo()+"\"");
if (rule.isBreak()) config.append(" break");
config.append(" ;\n");
}
}
config.append(" proxy_pass "+
(localSslConfig != null && localSslConfig.getTargetIsSsl() ? "https" :
(localSslConfig == null && globalSslConfig != null && globalSslConfig.getTargetIsSsl()) ? "https" :
"http")+
"://"+mappingInDomain.getUniqueLabel()+" ;\n");
config.append(" }\n");
}
}
if (!hasRoot) {
//provide a root block giving 404 if there isn't one for this server
config.append(" location / { \n"+getCodeFor404()+" }\n");
}
config.append(" }\n");
}
config.append("}\n");
return config.toString();
}
protected String getCodeForServerConfig() {
return ""+
// this prevents nginx from reporting version number on error pages
" server_tokens off;\n"+
// this prevents nginx from using the internal proxy_pass codename as Host header passed upstream
" proxy_set_header Host $http_host;\n";
}
protected String getCodeFor404() {
return " return 404;\n";
}
void verifyConfig(ProxySslConfig proxySslConfig) {
if(Strings.isEmpty(proxySslConfig.getCertificateDestination()) && Strings.isEmpty(proxySslConfig.getCertificateSourceUrl())){
throw new IllegalStateException("ProxySslConfig can't have a null certificateDestination and null certificateSourceUrl. One or both need to be set");
}
}
public boolean appendSslConfig(String id, StringBuilder out, String prefix, ProxySslConfig ssl,
boolean sslBlock, boolean certificateBlock) {
if (ssl == null) return false;
if (sslBlock) {
out.append(prefix);
out.append("ssl on;\n");
}
if (ssl.getReuseSessions()) {
out.append(prefix);
out.append("proxy_ssl_session_reuse on;");
}
if (certificateBlock) {
String cert;
if (Strings.isEmpty(ssl.getCertificateDestination())) {
cert = "" + id + ".crt";
} else {
cert = ssl.getCertificateDestination();
}
out.append(prefix);
out.append("ssl_certificate " + cert + ";\n");
String key;
if (!Strings.isEmpty(ssl.getKeyDestination())) {
key = ssl.getKeyDestination();
} else if (!Strings.isEmpty(ssl.getKeySourceUrl())) {
key = "" + id + ".key";
} else {
key = null;
}
if (key != null) {
out.append(prefix);
out.append("ssl_certificate_key " + key + ";\n");
}
}
return true;
}
protected Iterable<UrlMapping> findUrlMappings() {
// For mapping by URL
Group urlMappingGroup = getConfig(URL_MAPPINGS);
if (urlMappingGroup != null) {
return Iterables.filter(urlMappingGroup.getMembers(), UrlMapping.class);
} else {
return Collections.<UrlMapping>emptyList();
}
}
}