/*******************************************************************************
* This file is part of OpenNMS(R).
*
* Copyright (C) 2009-2011 The OpenNMS Group, Inc.
* OpenNMS(R) is Copyright (C) 1999-2011 The OpenNMS Group, Inc.
*
* OpenNMS(R) is a registered trademark of The OpenNMS Group, Inc.
*
* OpenNMS(R) is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published
* by the Free Software Foundation, either version 3 of the License,
* or (at your option) any later version.
*
* OpenNMS(R) 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 General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with OpenNMS(R). If not, see:
* http://www.gnu.org/licenses/
*
* For more information contact:
* OpenNMS(R) Licensing <license@opennms.org>
* http://www.opennms.org/
* http://www.opennms.com/
*******************************************************************************/
package org.opennms.netmgt.provision.service.dns;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.UnsupportedEncodingException;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLConnection;
import java.net.URLDecoder;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.xml.bind.JAXBException;
import org.apache.commons.io.IOExceptionWithCause;
import org.apache.commons.lang.StringUtils;
import org.opennms.core.utils.LogUtils;
import org.opennms.core.utils.ThreadCategory;
import org.opennms.core.xml.JaxbUtils;
import org.opennms.netmgt.model.PrimaryType;
import org.opennms.netmgt.provision.persist.requisition.Requisition;
import org.opennms.netmgt.provision.persist.requisition.RequisitionInterface;
import org.opennms.netmgt.provision.persist.requisition.RequisitionMonitoredService;
import org.opennms.netmgt.provision.persist.requisition.RequisitionNode;
import org.xbill.DNS.ARecord;
import org.xbill.DNS.AAAARecord;
import org.xbill.DNS.Name;
import org.xbill.DNS.Record;
import org.xbill.DNS.TSIG;
import org.xbill.DNS.Type;
import org.xbill.DNS.ZoneTransferException;
import org.xbill.DNS.ZoneTransferIn;
/**
* Implementation of <code>java.net.URLConnection</code> for handling
* URLs specified in the Provisiond configuration requesting an import
* requisition based on the A records of a zone transfer for a DNS server.
*
* @author <a href="mailto:david@opennms.org">David Hustace</a>
* @version $Id: $
*/
public class DnsRequisitionUrlConnection extends URLConnection {
private static final String EXPRESSION_ARG = "expression";
private static final String SERVICES_ARG = "services";
private static final String FID_HASH_SRC_ARG = "foreignidhashsource";
private static final String[] HASH_IP_KEYWORDS = { "ip", "addr" };
private static final String[] HASH_LABEL_KEYWORDS = { "name", "label" };
private static final String QUERY_ARG_SEPARATOR = "&";
/** Constant <code>URL_SCHEME="dns://"</code> */
public static final String URL_SCHEME = "dns://";
/** Constant <code>PROTOCOL="dns"</code> */
public static final String PROTOCOL = "dns";
private String m_zone;
//TODO implement this
private Long m_serial;
//TODO implement this
private Boolean m_fallback;
//TODO implement this
private TSIG m_key;
private URL m_url;
private int m_port;
private String m_foreignSource;
private int m_foreignIdHashSource;
private String[] m_services;
private static Map<String, String> m_args;
/**
* <p>Constructor for DnsRequisitionUrlConnection.</p>
*
* @param url a {@link java.net.URL} object.
* @throws java.net.MalformedURLException if any.
*/
protected DnsRequisitionUrlConnection(URL url) throws MalformedURLException {
super(url);
m_args = getUrlArgs(url);
validateDnsUrl(url);
m_url = url;
m_port = url.getPort() == -1 ? 53 : url.getPort();
m_zone = parseZone(url);
m_foreignSource = parseForeignSource(url);
m_foreignIdHashSource = getForeignIdHashSource();
m_services = getServices();
if (m_zone == null) {
throw new IllegalArgumentException("Specified Zone is null");
}
m_serial = Long.valueOf(0L);
m_fallback = Boolean.FALSE;
m_key = null;
}
/**
* Determine services to be provisioned from URL
*
* @return a String[] of opennms service names
*/
private String[] getServices() {
// TODO validate services against service table of database
String[] services = new String[] { "ICMP", "SNMP" };
if (getArgs() != null && getArgs().get(SERVICES_ARG) != null) {
services = getArgs().get(SERVICES_ARG).split(",");
}
return services;
}
/**
* Determine source for computing hash for foreignId from URL
*
* @return a String of "ipAddress" or "nodeLabel"
*/
private int getForeignIdHashSource() {
int result = 0;
if (getArgs() != null && getArgs().get(FID_HASH_SRC_ARG) != null) {
String hashSourceArg = getArgs().get(FID_HASH_SRC_ARG).toLowerCase();
for (String keyword : HASH_IP_KEYWORDS) {
if (hashSourceArg.contains(keyword)) {
result = 2;
break;
}
}
for (String keyword : HASH_LABEL_KEYWORDS) {
if (hashSourceArg.contains(keyword)) {
result++;
break;
}
}
}
return result;
}
/**
* {@inheritDoc}
*
* This is a no op.
*/
@Override
public void connect() throws IOException {
}
/**
* {@inheritDoc}
*
* Creates a ByteArrayInputStream implementation of InputStream of the XML marshaled version
* of the Requisition class. Calling close on this stream is safe.
*/
@Override
public InputStream getInputStream() throws IOException {
InputStream stream = null;
try {
Requisition r = buildRequisitionFromZoneTransfer();
stream = new ByteArrayInputStream(jaxBMarshal(r).getBytes());
} catch (IOException e) {
log().warn("getInputStream: Problem getting input stream: "+e, e);
throw e;
} catch (Throwable e) {
String message = "Problem getting input stream: "+e;
log().warn(message, e);
throw new IOExceptionWithCause(message,e );
}
return stream;
}
/**
* Builds a Requisition based on the A records returned in a zone transfer from the
* specified zone.
*
* @return an instance of the JaxB annotated Requisition class than can be marshaled
* into the XML and streamed to the Provisioner
*
* @throws IOException
* @throws ZoneTransferException
*/
private Requisition buildRequisitionFromZoneTransfer() throws IOException, ZoneTransferException {
ZoneTransferIn xfer = null;
List<Record> records = null;
LogUtils.debugf(this, "connecting to host %s:%d", m_url.getHost(), m_port);
try {
xfer = ZoneTransferIn.newIXFR(new Name(m_zone),
m_serial.longValue(),
m_fallback.booleanValue(),
m_url.getHost(),
m_port,
m_key);
records = getRecords(xfer);
} catch (ZoneTransferException e) // Fallbacking to AXFR
{
String message = "IXFR not supported trying AXFR: "+e;
log().warn(message, e);
xfer = ZoneTransferIn.newAXFR(new Name(m_zone), m_url.getHost(), m_key);
records = getRecords(xfer);
}
Requisition r = null;
if (records.size() > 0) {
//for now, set the foreign source to the specified dns zone
r = new Requisition(getForeignSource());
for (Record rec : records) {
if (matchingRecord(rec)) {
r.insertNode(createRequisitionNode(rec));
}
}
}
return r;
}
@SuppressWarnings("unchecked")
private List<Record> getRecords(ZoneTransferIn xfer) throws IOException, ZoneTransferException {
return (List<Record>) xfer.run();
}
/**
* Creates an instance of the JaxB annotated RequisionNode class.
*
* @param rec
* @return a populated RequisitionNode based on defaults and data from the
* A record returned from a DNS zone transfer query.
*/
private RequisitionNode createRequisitionNode(Record rec) {
String addr = null;
if ("A".equals(Type.string(rec.getType()))) {
ARecord arec = (ARecord)rec;
addr = StringUtils.stripStart(arec.getAddress().toString(), "/");
} else if ("AAAA".equals(Type.string(rec.getType()))) {
AAAARecord aaaarec = (AAAARecord)rec;
addr = aaaarec.rdataToString();
} else {
throw new IllegalArgumentException("Invalid record type " + Type.string(rec.getType()) + ". A or AAAA expected.");
}
RequisitionNode n = new RequisitionNode();
String host = rec.getName().toString();
String nodeLabel = StringUtils.stripEnd(StringUtils.stripStart(host, "."), ".");
n.setBuilding(getForeignSource());
switch(m_foreignIdHashSource) {
case 1:
n.setForeignId(computeHashCode(nodeLabel));
log().debug("Generating foreignId from hash of nodelabel " + nodeLabel);
break;
case 2:
n.setForeignId(computeHashCode(addr));
log().debug("Generating foreignId from hash of ipAddress " + addr);
break;
case 3:
n.setForeignId(computeHashCode(nodeLabel+addr));
log().debug("Generating foreignId from hash of nodelabel+ipAddress " + nodeLabel + addr);
break;
default:
n.setForeignId(computeHashCode(nodeLabel));
log().debug("Default case: Generating foreignId from hash of nodelabel " + nodeLabel);
break;
}
n.setNodeLabel(nodeLabel);
RequisitionInterface i = new RequisitionInterface();
i.setDescr("DNS-" + Type.string(rec.getType()));
i.setIpAddr(addr);
i.setSnmpPrimary(PrimaryType.PRIMARY);
i.setManaged(Boolean.TRUE);
i.setStatus(Integer.valueOf(1));
for (String service : m_services) {
service = service.trim();
i.insertMonitoredService(new RequisitionMonitoredService(service));
log().debug("Adding provisioned service " + service);
}
n.putInterface(i);
return n;
}
/**
* Determines if the record is an A record and if the canonical name
* matches the expression supplied in the URL, if one was supplied.
*
* @param rec
* @return boolean if rec should be included in the import requisition
*/
private boolean matchingRecord(Record rec) {
log().info("matchingRecord: checking rec: "+rec+" to see if it should be imported...");
boolean matches = false;
if ("A".equals(Type.string(rec.getType())) || "AAAA".equals(Type.string(rec.getType()))) {
log().debug("matchingRecord: record is an " + Type.string(rec.getType()) + " record, continuing...");
String expression = determineExpressionFromUrl(getUrl());
if (expression != null) {
Pattern p = Pattern.compile(expression);
Matcher m = p.matcher(rec.getName().toString());
// Try matching on host name only for backwards compatibility
log().debug("matchingRecord: attempting to match hostname: ["+rec.getName().toString()+"] with expression: ["+expression+"]");
if (m.matches()) {
matches = true;
} else {
// include the IP address and try again
log().debug("matchingRecord: attempting to match record: ["+rec.getName().toString()
+" "+rec.rdataToString()+"] with expression: ["+expression+"]");
m = p.matcher(rec.getName().toString() + " " + rec.rdataToString());
if (m.matches()) {
matches = true;
}
}
log().debug("matchingRecord: record matches expression: "+matches);
} else {
log().debug("matchingRecord: no expression for this zone, returning valid match for this " + Type.string(rec.getType()) + " record...");
matches = true;
}
}
log().info("matchingRecord: record: "+rec+" matches: "+matches);
return matches;
}
/**
* Created this in the case that we decide to every do something different with the hashing
* to have a lesser likely hood of duplicate foreign ids
* @param hashSource
* @return
*/
private String computeHashCode(String hashSource) {
String hash = String.valueOf(hashSource.hashCode());
return hash;
}
/**
* Utility to marshal the Requisition class into XML.
*
* @param r
* @return a String of XML encoding the Requisition class
*
* @throws JAXBException
*/
private String jaxBMarshal(Requisition r) throws JAXBException {
return JaxbUtils.marshal(r);
}
/**
* <p>getZone</p>
*
* @return a {@link java.lang.String} object.
*/
public String getZone() {
return m_zone;
}
/**
* <p>getSerial</p>
*
* @return a {@link java.lang.Long} object.
*/
public Long getSerial() {
return m_serial;
}
/**
* <p>setSerial</p>
*
* @param serial a {@link java.lang.Long} object.
*/
public void setSerial(Long serial) {
m_serial = serial;
}
/**
* <p>getFallback</p>
*
* @return a {@link java.lang.Boolean} object.
*/
public Boolean getFallback() {
return m_fallback;
}
/**
* <p>setFallback</p>
*
* @param fallback a {@link java.lang.Boolean} object.
*/
public void setFallback(Boolean fallback) {
m_fallback = fallback;
}
/**
* <p>getKey</p>
*
* @return a {@link org.xbill.DNS.TSIG} object.
*/
public TSIG getKey() {
return m_key;
}
/**
* <p>setKey</p>
*
* @param key a {@link org.xbill.DNS.TSIG} object.
*/
public void setKey(TSIG key) {
m_key = key;
}
/**
* <p>getDescription</p>
*
* @return a {@link java.lang.String} object.
*/
public String getDescription() {
return m_url.toString();
}
/**
* <p>toString</p>
*
* @return a {@link java.lang.String} object.
*/
public String toString() {
return getDescription();
}
/**
* <p>getUrl</p>
*
* @return a {@link java.net.URL} object.
*/
public URL getUrl() {
return m_url;
}
public static Map<String, String> getArgs() {
return m_args;
}
/**
* <p>determineExpressionFromUrl</p>
*
* @param url a {@link java.net.URL} object.
* @return a {@link java.lang.String} object.
*/
protected static String determineExpressionFromUrl(URL url) {
log().info("determineExpressionFromUrl: finding regex as parameter in query string of URL: "+url);
if(getUrlArgs(url) == null) {
return null;
} else {
return getUrlArgs(url).get(EXPRESSION_ARG);
}
}
private static List<String> tokenizeQueryArgs(String query) throws IllegalArgumentException {
if (query == null) {
throw new IllegalArgumentException("The URL query is null");
}
List<String> queryArgs = Arrays.asList(StringUtils.split(query, QUERY_ARG_SEPARATOR));
return queryArgs;
}
/**
* <p>decodeQueryString</p>
*
* @param url a {@link java.net.URL} object.
* @return a {@link java.lang.String} object.
*/
protected static String decodeQueryString(URL url) {
if (url == null || url.getQuery() == null) {
throw new IllegalArgumentException("The URL or the URL query is null: "+url);
}
String query = null;
try {
query = URLDecoder.decode(url.getQuery(), "UTF-8");
} catch (UnsupportedEncodingException e) {
log().error("decodeQueryString: "+e, e);
}
return query;
}
/**
* Validate the format is:
* dns://<host>/<zone>/?expression=<regex>
*
* there should be only one arguement in the path
* there should only be one query parameter
*
* @param url a {@link java.net.URL} object.
* @throws java.net.MalformedURLException if any.
*/
protected static void validateDnsUrl(URL url) throws MalformedURLException {
String path = url.getPath();
path = StringUtils.removeStart(path, "/");
path = StringUtils.removeEnd(path, "/");
if (path == null || StringUtils.countMatches(path, "/") > 1) {
throw new MalformedURLException("The specified DNS URL contains invalid path: "+url);
}
String query = url.getQuery();
if ((query != null) && (determineExpressionFromUrl(url) == null) && (getArgs().get(SERVICES_ARG) == null) && (getArgs().get(FID_HASH_SRC_ARG) == null)) {
throw new MalformedURLException("The specified DNS URL contains an invalid query string: "+url);
}
}
/**
* Zone should be the first path entity
*
* dns://<host>/<zone>[/<foreign source>][/<?expression=<regex>>
*
* @param url a {@link java.net.URL} object.
* @return a {@link java.lang.String} object.
*/
protected static String parseZone(URL url) {
String path = url.getPath();
path = StringUtils.removeStart(path, "/");
path = StringUtils.removeEnd(path, "/");
String zone = path;
if (path != null && StringUtils.countMatches(path, "/") == 1) {
String[] paths = path.split("/");
zone = paths[0];
}
return zone;
}
/**
* Foreign Source should be the second path entity, if it exists, otherwise it is
* set to the value of the zone.
*
* dns://<host>/<zone>[/<foreign source>][/<?expression=<regex>>
*
* @param url a {@link java.net.URL} object.
* @return a {@link java.lang.String} object.
*/
protected static String parseForeignSource(URL url) {
String path = url.getPath();
path = StringUtils.removeStart(path, "/");
path = StringUtils.removeEnd(path, "/");
String foreignSource = path;
if (path != null && StringUtils.countMatches(path, "/") == 1) {
String[] paths = path.split("/");
foreignSource = paths[1];
}
return foreignSource;
}
protected static Map<String, String> getUrlArgs(URL url) {
if (url.getQuery() == null) {
return null;
}
//TODO: need to throw exception if query is null
String query = decodeQueryString(url);
//TODO: need to handle exception
List<String> queryArgs = tokenizeQueryArgs(query);
Map<String, String> args = new HashMap<String, String>();
for (String queryArg : queryArgs) {
String[] argTokens = StringUtils.split(queryArg, '=');
if (argTokens.length < 2) {
log().warn("getUrlArgs: syntax error in URL query string, missing '=' in query argument: "+queryArg);
} else {
log().debug("adding arg tokens " + argTokens[0].toLowerCase() + ", " + argTokens[1]);
args.put(argTokens[0].toLowerCase(), argTokens[1]);
}
}
return args;
}
private static ThreadCategory log() {
return ThreadCategory.getInstance(DnsRequisitionUrlConnection.class);
}
/**
* <p>setForeignSource</p>
*
* @param foreignSource a {@link java.lang.String} object.
*/
public void setForeignSource(String foreignSource) {
m_foreignSource = foreignSource;
}
/**
* <p>getForeignSource</p>
*
* @return a {@link java.lang.String} object.
*/
public String getForeignSource() {
return m_foreignSource;
}
}