/* ==================================================================
* DefaultSetupService.java - Jun 1, 2010 2:19:02 PM
*
* Copyright 2007-2010 SolarNetwork.net Dev Team
*
* This program 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 2 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
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA
* 02111-1307 USA
* ==================================================================
* $Id$
* ==================================================================
*/
package net.solarnetwork.node.setup.impl;
import static net.solarnetwork.node.SetupSettings.KEY_CONFIRMATION_CODE;
import static net.solarnetwork.node.SetupSettings.KEY_NODE_ID;
import static net.solarnetwork.node.SetupSettings.KEY_SOLARNETWORK_FORCE_TLS;
import static net.solarnetwork.node.SetupSettings.KEY_SOLARNETWORK_HOST_NAME;
import static net.solarnetwork.node.SetupSettings.KEY_SOLARNETWORK_HOST_PORT;
import static net.solarnetwork.node.SetupSettings.SETUP_TYPE_KEY;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.security.Principal;
import java.security.cert.X509Certificate;
import java.util.HashMap;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.zip.GZIPInputStream;
import javax.xml.xpath.XPathExpression;
import org.apache.commons.codec.binary.Base64InputStream;
import org.joda.time.DateTime;
import org.joda.time.format.DateTimeFormatter;
import org.joda.time.format.ISODateTimeFormat;
import org.osgi.service.event.Event;
import org.osgi.service.event.EventAdmin;
import org.springframework.beans.PropertyAccessorFactory;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.support.TransactionCallbackWithoutResult;
import org.springframework.transaction.support.TransactionTemplate;
import net.solarnetwork.domain.NetworkAssociation;
import net.solarnetwork.domain.NetworkAssociationDetails;
import net.solarnetwork.domain.NetworkCertificate;
import net.solarnetwork.node.IdentityService;
import net.solarnetwork.node.SetupSettings;
import net.solarnetwork.node.backup.BackupManager;
import net.solarnetwork.node.dao.SettingDao;
import net.solarnetwork.node.reactor.Instruction;
import net.solarnetwork.node.reactor.InstructionHandler;
import net.solarnetwork.node.reactor.InstructionStatus.InstructionState;
import net.solarnetwork.node.setup.InvalidVerificationCodeException;
import net.solarnetwork.node.setup.PKIService;
import net.solarnetwork.node.setup.SetupException;
import net.solarnetwork.node.setup.SetupService;
import net.solarnetwork.node.support.XmlServiceSupport;
import net.solarnetwork.support.CertificateException;
import net.solarnetwork.util.JavaBeanXmlSerializer;
import net.solarnetwork.util.OptionalService;
/**
* Implementation of {@link SetupService}.
*
* <p>
* The configurable properties of this class are:
* </p>
*
* <dl class="class-properties">
* <dt>backupManager</dt>
* <dd>An optional {@link BackupManager} to trigger an immediate backup after
* associating.</dd>
*
* <dt>settingDao</dt>
* <dd>The {@link SettingDao} to use for querying/storing application state
* information.</dd>
*
* <dt>hostName</dt>
* <dd>The host name to use for the SolarNet remote service. Defaults to
* {@link #DEFAULT_HOST_NAME}. This will be overridden by the application
* setting value for the key
* {@link SetupSettings#KEY_SOLARNETWORK_HOST_NAME}.</dd>
*
* <dt>hostPort</dt>
* <dd>The host port to use for the SolarNet remote service. Defaults to
* {@link #DEFAULT_HOST_PORT}. This will be overridden by the application
* setting value for the key
* {@link SetupSettings#KEY_SOLARNETWORK_HOST_PORT}.</dd>
*
* <dt>forceTLS</dt>
* <dd>If <em>true</em> then use TLS (SSL) even on a port other than {@code 443}
* (the default TLS port). Defaults to <em>false</em>.</dd>
*
* <dt>solarInUrlPrefix</dt>
* <dd>The URL prefix for the SolarIn service. Defaults to
* {@link DEFAULT_SOLARIN_URL_PREFIX}.</dd>
* </dl>
*
* @author matt
* @version 1.6
*/
public class DefaultSetupService extends XmlServiceSupport
implements SetupService, IdentityService, InstructionHandler {
/** The default value for the {@code hostName} property. */
public static final String DEFAULT_HOST_NAME = "in.solarnetwork.net";
/** The default value for the {@code hostPort} property. */
public static final Integer DEFAULT_HOST_PORT = 443;
/** The default value for the {@code solarInUrlPrefix} property. */
public static final String DEFAULT_SOLARIN_URL_PREFIX = "/solarin";
/**
* Instruction topic for sending a renewed certificate to a node.
*
* @since 1.5
*/
public static final String INSTRUCTION_TOPIC_RENEW_CERTIFICATE = "RenewCertificate";
/**
* Instruction parameter for certificate data. Since instruction parameters
* are limited in length, there can be more than one parameter of the same
* key, with the full data being the concatenation of all parameter values.
*
* @since 1.5
*/
public static final String INSTRUCTION_PARAM_CERTIFICATE = "Certificate";
// The keys used in the verification code xml
private static final String VERIFICATION_CODE_HOST_NAME = "host";
private static final String VERIFICATION_CODE_HOST_PORT = "port";
private static final String VERIFICATION_CODE_CONFIRMATION_KEY = "confirmationKey";
private static final String VERIFICATION_CODE_IDENTITY_KEY = "identityKey";
private static final String VERIFICATION_CODE_TERMS_OF_SERVICE = "termsOfService";
private static final String VERIFICATION_CODE_EXPIRATION_KEY = "expiration";
private static final String VERIFICATION_CODE_SECURITY_PHRASE = "securityPhrase";
private static final String VERIFICATION_CODE_NODE_ID_KEY = "networkId";
private static final String VERIFICATION_CODE_NODE_CERT = "networkCertificate";
private static final String VERIFICATION_CODE_NODE_CERT_STATUS = "networkCertificateStatus";
private static final String VERIFICATION_CODE_NODE_CERT_DN_KEY = "networkCertificateSubjectDN";
private static final String VERIFICATION_CODE_USER_NAME_KEY = "username";
private static final String VERIFICATION_CODE_FORCE_TLS = "forceTLS";
private static final String VERIFICATION_URL_SOLARUSER = "solarUserServiceURL";
private static final String VERIFICATION_URL_SOLARQUERY = "solarQueryServiceURL";
private static final String SOLAR_NET_IDENTITY_URL = "/solarin/identity.do";
private static final String SOLAR_NET_REG_URL = "/solaruser/associate.xml";
private static final String SOLAR_IN_RENEW_CERT_URL = "/api/v1/sec/cert/renew";
private OptionalService<BackupManager> backupManager;
private PKIService pkiService;
private PlatformTransactionManager transactionManager;
private SettingDao settingDao;
private String solarInUrlPrefix = DEFAULT_SOLARIN_URL_PREFIX;
/**
* Default constructor.
*/
public DefaultSetupService() {
super();
setConnectionTimeout(60000);
}
private Map<String, XPathExpression> getNodeAssociationPropertyMapping() {
Map<String, String> xpathMap = new HashMap<String, String>();
xpathMap.put(VERIFICATION_CODE_NODE_ID_KEY, "/*/@networkId");
xpathMap.put(VERIFICATION_CODE_NODE_CERT_DN_KEY, "/*/@networkCertificateSubjectDN");
xpathMap.put(VERIFICATION_CODE_NODE_CERT_STATUS, "/*/@networkCertificateStatus");
xpathMap.put(VERIFICATION_CODE_NODE_CERT, "/*/@networkCertificate");
xpathMap.put(VERIFICATION_CODE_USER_NAME_KEY, "/*/@username");
xpathMap.put(VERIFICATION_CODE_CONFIRMATION_KEY, "/*/@confirmationKey");
return getXPathExpressionMap(xpathMap);
}
private Map<String, XPathExpression> getIdentityPropertyMapping() {
Map<String, String> identityXpathMap = new HashMap<String, String>();
identityXpathMap.put(VERIFICATION_CODE_HOST_NAME, "/*/@host");
identityXpathMap.put(VERIFICATION_CODE_HOST_PORT, "/*/@port");
identityXpathMap.put(VERIFICATION_CODE_FORCE_TLS, "/*/@forceTLS");
identityXpathMap.put(VERIFICATION_CODE_IDENTITY_KEY, "/*/@identityKey");
identityXpathMap.put(VERIFICATION_CODE_TERMS_OF_SERVICE, "/*/@termsOfService");
identityXpathMap.put(VERIFICATION_CODE_SECURITY_PHRASE, "/*/@securityPhrase");
identityXpathMap.put(VERIFICATION_URL_SOLARUSER,
"/*/networkServiceURLs/entry[@key='solaruser']/value/@value");
identityXpathMap.put(VERIFICATION_URL_SOLARQUERY,
"/*/networkServiceURLs/entry[@key='solarquery']/value/@value");
return getXPathExpressionMap(identityXpathMap);
}
private boolean isForceTLS() {
String force = getSetting(KEY_SOLARNETWORK_FORCE_TLS);
if ( force == null ) {
return false;
}
return Boolean.parseBoolean(force);
}
private int getPort() {
Integer port = getSolarNetHostPort();
if ( port == null ) {
return 443;
}
return port.intValue();
}
@Override
public Long getNodeId() {
String nodeId = getSetting(KEY_NODE_ID);
if ( nodeId == null ) {
return null;
}
return Long.valueOf(nodeId);
}
@Override
public Principal getNodePrincipal() {
if ( pkiService == null ) {
return null;
}
X509Certificate nodeCert = pkiService.getNodeCertificate();
if ( nodeCert == null ) {
log.debug("No node certificate available, cannot get node principal");
return null;
}
return nodeCert.getSubjectX500Principal();
}
@Override
public String getSolarNetHostName() {
return getSetting(KEY_SOLARNETWORK_HOST_NAME);
}
@Override
public Integer getSolarNetHostPort() {
String port = getSetting(KEY_SOLARNETWORK_HOST_PORT);
if ( port == null ) {
return 443;
}
return Integer.valueOf(port);
}
@Override
public String getSolarNetSolarInUrlPrefix() {
return solarInUrlPrefix;
}
@Override
public String getSolarInBaseUrl() {
final int port = getPort();
final String host = getSolarNetHostName();
if ( host == null ) {
throw new SetupException(
"SolarNet host not configured. Perhaps this node is not yet set up?");
}
return "http" + (port == 443 || isForceTLS() ? "s" : "") + "://" + host
+ (port == 443 || port == 80 ? "" : (":" + port)) + solarInUrlPrefix;
}
@Override
public NetworkAssociationDetails decodeVerificationCode(String verificationCode)
throws InvalidVerificationCodeException {
log.debug("Decoding verification code {}", verificationCode);
NetworkAssociationDetails details = new NetworkAssociationDetails();
try {
JavaBeanXmlSerializer helper = new JavaBeanXmlSerializer();
InputStream in = new GZIPInputStream(
new Base64InputStream(new ByteArrayInputStream(verificationCode.getBytes())));
Map<String, Object> result = helper.parseXml(in);
// Get the host server
String hostName = (String) result.get(VERIFICATION_CODE_HOST_NAME);
if ( hostName == null ) {
// Use the default
log.debug("Property {} not found in verfication code", VERIFICATION_CODE_HOST_NAME);
throw new InvalidVerificationCodeException("Missing host");
}
details.setHost(hostName);
// Get the host port
String hostPort = (String) result.get(VERIFICATION_CODE_HOST_PORT);
if ( hostPort == null ) {
log.debug("Property {} not found in verfication code", VERIFICATION_CODE_HOST_PORT);
throw new InvalidVerificationCodeException("Missing port");
}
try {
details.setPort(Integer.valueOf(hostPort));
} catch ( NumberFormatException e ) {
throw new InvalidVerificationCodeException("Invalid host port value: " + hostPort, e);
}
// Get the confirmation Key
String confirmationKey = (String) result.get(VERIFICATION_CODE_CONFIRMATION_KEY);
if ( confirmationKey == null ) {
throw new InvalidVerificationCodeException("Missing confirmation code");
}
details.setConfirmationKey(confirmationKey);
// Get the identity key
String identityKey = (String) result.get(VERIFICATION_CODE_IDENTITY_KEY);
if ( identityKey == null ) {
throw new InvalidVerificationCodeException("Missing identity key");
}
details.setIdentityKey(identityKey);
// Get the user name
String userName = (String) result.get(VERIFICATION_CODE_USER_NAME_KEY);
if ( userName == null ) {
throw new InvalidVerificationCodeException("Missing username");
}
details.setUsername(userName);
// Get the expiration
String expiration = (String) result.get(VERIFICATION_CODE_EXPIRATION_KEY);
if ( expiration == null ) {
throw new InvalidVerificationCodeException(VERIFICATION_CODE_EXPIRATION_KEY
+ " not found in verification code: " + verificationCode);
}
try {
DateTimeFormatter fmt = ISODateTimeFormat.dateTime();
DateTime expirationDate = fmt.parseDateTime(expiration);
details.setExpiration(expirationDate.toDate());
} catch ( IllegalArgumentException e ) {
throw new InvalidVerificationCodeException("Invalid expiration date value", e);
}
// Get the TLS setting
String forceSSL = (String) result.get(VERIFICATION_CODE_FORCE_TLS);
details.setForceTLS(forceSSL == null ? false : Boolean.valueOf(forceSSL));
return details;
} catch ( InvalidVerificationCodeException e ) {
throw e;
} catch ( Exception e ) {
// Runtime/IO errors can come from webFormGetForBean
throw new InvalidVerificationCodeException(
"Error while trying to decode verfication code: " + verificationCode, e);
}
}
@Override
public NetworkAssociation retrieveNetworkAssociation(NetworkAssociationDetails details) {
NetworkAssociationDetails association = new NetworkAssociationDetails();
NetworkAssociationRequest req = new NetworkAssociationRequest();
req.setUsername(details.getUsername());
req.setKey(details.getConfirmationKey());
webFormGetForBean(PropertyAccessorFactory.forBeanPropertyAccess(req), association,
getAbsoluteUrl(details, SOLAR_NET_IDENTITY_URL), null, getIdentityPropertyMapping());
return association;
}
private String getAbsoluteUrl(NetworkAssociationDetails details, String url) {
return "http" + (details.getPort() == 443 || details.isForceTLS() ? "s" : "") + "://"
+ details.getHost() + ":" + details.getPort() + url;
}
@Override
public NetworkCertificate acceptNetworkAssociation(final NetworkAssociationDetails details)
throws SetupException {
log.debug("Associating with SolarNet service {}", details);
try {
// Get confirmation code from the server
NetworkAssociationDetails req = new NetworkAssociationDetails(details.getUsername(),
details.getConfirmationKey(), details.getKeystorePassword());
final NetworkCertificate result = new NetworkAssociationDetails();
webFormPostForBean(PropertyAccessorFactory.forBeanPropertyAccess(req), result,
getAbsoluteUrl(details, SOLAR_NET_REG_URL), null,
getNodeAssociationPropertyMapping());
final TransactionTemplate tt = new TransactionTemplate(transactionManager);
tt.execute(new TransactionCallbackWithoutResult() {
@Override
protected void doInTransactionWithoutResult(TransactionStatus status) {
// Store the confirmation code and settings on the node
saveSetting(KEY_CONFIRMATION_CODE, result.getConfirmationKey());
saveSetting(KEY_NODE_ID, result.getNetworkId().toString());
saveSetting(KEY_SOLARNETWORK_HOST_NAME, details.getHost());
saveSetting(KEY_SOLARNETWORK_HOST_PORT, details.getPort().toString());
saveSetting(KEY_SOLARNETWORK_FORCE_TLS, String.valueOf(details.isForceTLS()));
}
});
if ( result.getNetworkCertificateStatus() == null ) {
// create the node's CSR based on the given subjectDN
log.debug("Creating node CSR for subject {}", result.getNetworkCertificateSubjectDN());
pkiService.generateNodeSelfSignedCertificate(result.getNetworkCertificateSubjectDN());
} else if ( details.getKeystorePassword() != null ) {
log.debug("Saving node certificate for subject {}",
result.getNetworkCertificateSubjectDN());
pkiService.savePKCS12Keystore(result.getNetworkCertificate(),
details.getKeystorePassword());
}
makeBackup();
// post NETWORK_ASSOCIATION_ACCEPTED event
Map<String, Object> props = new HashMap<String, Object>(2);
if ( result.getNetworkId() != null ) {
props.put(KEY_NODE_ID, result.getNetworkId());
}
postEvent(new Event(SetupService.TOPIC_NETWORK_ASSOCIATION_ACCEPTED, props));
return result;
} catch ( Exception e ) {
log.error("Error while confirming server details: {}", details, e);
// Runtime errors can come from webFormGetForBean
throw new SetupException("Error while confirming server details: " + details, e);
}
}
private void postEvent(Event event) {
OptionalService<EventAdmin> eventAdmin = getEventAdmin();
EventAdmin ea = (eventAdmin == null ? null : eventAdmin.service());
if ( ea == null || event == null ) {
return;
}
ea.postEvent(event);
}
private void makeBackup() {
BackupManager mgr = (backupManager == null ? null : backupManager.service());
if ( mgr == null ) {
return;
}
log.info("Requesting background backup.");
mgr.createAsynchronousBackup();
}
@Override
public boolean handlesTopic(String topic) {
return INSTRUCTION_TOPIC_RENEW_CERTIFICATE.equalsIgnoreCase(topic);
}
@Override
public InstructionState processInstruction(Instruction instruction) {
if ( !INSTRUCTION_TOPIC_RENEW_CERTIFICATE.equalsIgnoreCase(instruction.getTopic()) ) {
return null;
}
PKIService pki = pkiService;
if ( pki == null ) {
return null;
}
String[] certParts = instruction.getAllParameterValues(INSTRUCTION_PARAM_CERTIFICATE);
if ( certParts == null ) {
log.warn("Certificate not provided with renew instruction");
return InstructionState.Declined;
}
String cert = org.springframework.util.StringUtils.arrayToDelimitedString(certParts, "");
log.debug("Got certificate renewal instruction with certificate data: {}", cert);
try {
pki.saveNodeSignedCertificate(cert);
if ( log.isInfoEnabled() ) {
X509Certificate nodeCert = pki.getNodeCertificate();
log.info("Installed node certificate {}, valid to {}", nodeCert.getSerialNumber(),
nodeCert.getNotAfter());
}
return InstructionState.Completed;
} catch ( CertificateException e ) {
log.error("Failed to install renewed certificate", e);
}
return null;
}
@Override
public void renewNetworkCertificate(String password) throws SetupException {
final String keystore = pkiService.generatePKCS12KeystoreString(password);
final String url = getSolarInBaseUrl() + SOLAR_IN_RENEW_CERT_URL;
Map<String, String> data = new HashMap<String, String>(2);
data.put("keystore", keystore);
data.put("password", password);
try {
String result = postXWWWFormURLEncodedDataForString(url, data);
if ( result == null || !result.matches(".*(?i)\"success\"\\s*:\\s*true.*") ) {
String message = "Unknown error.";
if ( result != null ) {
Pattern pat = Pattern.compile("\"message\"\\s*:\\s*\"([^\"]+)\"",
Pattern.CASE_INSENSITIVE);
Matcher m = pat.matcher(message);
if ( m.find() ) {
message = m.group(1);
}
}
throw new SetupException(message);
}
} catch ( IOException e ) {
throw new SetupException("Error communicating with SolarNet: " + e.getMessage());
}
}
private String getSetting(String key) {
return (settingDao == null ? null : settingDao.getSetting(key, SETUP_TYPE_KEY));
}
private void saveSetting(String key, String value) {
if ( settingDao == null ) {
return;
}
settingDao.storeSetting(key, SETUP_TYPE_KEY, value);
}
public void setSettingDao(SettingDao settingDao) {
this.settingDao = settingDao;
}
public void setSolarInUrlPrefix(String solarInUrlPrefix) {
this.solarInUrlPrefix = solarInUrlPrefix;
}
public void setTransactionManager(PlatformTransactionManager transactionManager) {
this.transactionManager = transactionManager;
}
public void setPkiService(PKIService pkiService) {
this.pkiService = pkiService;
}
public void setBackupManager(OptionalService<BackupManager> backupManager) {
this.backupManager = backupManager;
}
}