/*
* Syncany, www.syncany.org
* Copyright (C) 2011-2013 Philipp C. Heckel <philipp.heckel@gmail.com>
*
* 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 3 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, see <http://www.gnu.org/licenses/>.
*/
package org.syncany.operations.daemon;
import static io.undertow.Handlers.path;
import static io.undertow.Handlers.websocket;
import io.undertow.Undertow;
import io.undertow.security.api.AuthenticationMechanism;
import io.undertow.security.api.AuthenticationMode;
import io.undertow.security.handlers.AuthenticationCallHandler;
import io.undertow.security.handlers.AuthenticationConstraintHandler;
import io.undertow.security.handlers.AuthenticationMechanismsHandler;
import io.undertow.security.handlers.SecurityInitialHandler;
import io.undertow.security.idm.IdentityManager;
import io.undertow.security.impl.BasicAuthenticationMechanism;
import io.undertow.server.HttpHandler;
import io.undertow.server.HttpServerExchange;
import io.undertow.websockets.core.WebSocketChannel;
import io.undertow.websockets.core.WebSockets;
import java.io.File;
import java.security.KeyPair;
import java.security.KeyStore;
import java.security.cert.Certificate;
import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.net.ssl.SSLContext;
import org.bouncycastle.asn1.x500.RDN;
import org.bouncycastle.asn1.x500.X500Name;
import org.bouncycastle.asn1.x500.style.BCStyle;
import org.bouncycastle.asn1.x500.style.IETFUtils;
import org.bouncycastle.cert.jcajce.JcaX509CertificateHolder;
import org.syncany.config.LocalEventBus;
import org.syncany.config.UserConfig;
import org.syncany.config.to.DaemonConfigTO;
import org.syncany.config.to.UserTO;
import org.syncany.config.to.WebServerTO;
import org.syncany.crypto.CipherParams;
import org.syncany.crypto.CipherUtil;
import org.syncany.operations.daemon.auth.MapIdentityManager;
import org.syncany.operations.daemon.handlers.InternalRestHandler;
import org.syncany.operations.daemon.handlers.InternalWebInterfaceHandler;
import org.syncany.operations.daemon.handlers.InternalWebSocketHandler;
import org.syncany.operations.daemon.messages.GetFileFolderResponse;
import org.syncany.operations.daemon.messages.GetFileFolderResponseInternal;
import org.syncany.operations.daemon.messages.api.ExternalEvent;
import org.syncany.operations.daemon.messages.api.MessageFactory;
import org.syncany.operations.daemon.messages.api.Response;
import org.syncany.plugins.web.WebInterfacePlugin;
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import com.google.common.eventbus.Subscribe;
/**
* The web server provides a HTTP/REST and WebSocket API to thin clients,
* as well as a mechanism to run a web interface by implementing a
* {@link WebInterfacePlugin}.
*
* @author Philipp C. Heckel <philipp.heckel@gmail.com>
*/
public class WebServer {
private static final Logger logger = Logger.getLogger(WebServer.class.getSimpleName());
private Undertow webServer;
private LocalEventBus eventBus;
private Cache<Integer, WebSocketChannel> requestIdWebSocketCache;
private Cache<Integer, HttpServerExchange> requestIdRestSocketCache;
private Cache<String, File> fileTokenTempFileCache;
private List<WebSocketChannel> clientChannels;
public WebServer(DaemonConfigTO daemonConfig) throws Exception {
this.clientChannels = new ArrayList<WebSocketChannel>();
initCaches();
initEventBus();
initServer(daemonConfig);
}
public void start() throws ServiceAlreadyStartedException {
webServer.start();
}
public void stop() {
try {
logger.log(Level.INFO, "Shutting down websocket server.");
webServer.stop();
}
catch (Exception e) {
logger.log(Level.SEVERE, "Could not stop websocket server.", e);
}
}
private void initCaches() {
requestIdWebSocketCache = CacheBuilder.newBuilder().maximumSize(10000)
.concurrencyLevel(2).expireAfterAccess(1, TimeUnit.MINUTES).build();
requestIdRestSocketCache = CacheBuilder.newBuilder().maximumSize(10000)
.concurrencyLevel(2).expireAfterAccess(1, TimeUnit.MINUTES).build();
fileTokenTempFileCache = CacheBuilder.newBuilder().maximumSize(10000)
.concurrencyLevel(2).expireAfterAccess(1, TimeUnit.MINUTES).build();
}
private void initEventBus() {
eventBus = LocalEventBus.getInstance();
eventBus.register(this);
}
private void initServer(DaemonConfigTO daemonConfigTO) throws Exception {
WebServerTO webServerConfig = daemonConfigTO.getWebServer();
// Bind address and port
String bindAddress = webServerConfig.getBindAddress();
int bindPort = webServerConfig.getBindPort();
// Users (incl. CLI user!)
List<UserTO> users = readWebServerUsers(daemonConfigTO);
IdentityManager identityManager = new MapIdentityManager(users);
// (Re-)generate keypair/certificate (if requested)
boolean certificateAutoGenerate = webServerConfig.isCertificateAutoGenerate();
String certificateCommonName = webServerConfig.getCertificateCommonName();
if (certificateAutoGenerate && certificateCommonNameChanged(certificateCommonName)) {
generateNewKeyPairAndCertificate(certificateCommonName);
}
// Set up the handlers for WebSocket, REST and the web interface
HttpHandler pathHttpHandler = path()
.addPrefixPath("/api/ws", websocket(new InternalWebSocketHandler(this, certificateCommonName)))
.addPrefixPath("/api/rs", new InternalRestHandler(this))
.addPrefixPath("/", new InternalWebInterfaceHandler());
// Add some security spices
HttpHandler securityPathHttpHandler = addSecurity(pathHttpHandler, identityManager);
SSLContext sslContext = UserConfig.createUserSSLContext();
// And go for it!
webServer = Undertow
.builder()
.addHttpsListener(bindPort, bindAddress, sslContext)
.setHandler(securityPathHttpHandler)
.build();
logger.log(Level.INFO, "Initialized web server.");
}
private List<UserTO> readWebServerUsers(DaemonConfigTO daemonConfigTO) {
List<UserTO> users = daemonConfigTO.getUsers();
if (users == null) {
users = new ArrayList<UserTO>();
}
// Add CLI credentials
if (daemonConfigTO.getPortTO() != null) {
users.add(daemonConfigTO.getPortTO().getUser());
}
return users;
}
private boolean certificateCommonNameChanged(String certificateCommonName) {
try {
KeyStore userKeyStore = UserConfig.getUserKeyStore();
X509Certificate currentCertificate = (X509Certificate) userKeyStore.getCertificate(CipherParams.CERTIFICATE_IDENTIFIER);
if (currentCertificate != null) {
X500Name currentCertificateSubject = new JcaX509CertificateHolder(currentCertificate).getSubject();
RDN currentCertificateSubjectCN = currentCertificateSubject.getRDNs(BCStyle.CN)[0];
String currentCertificateSubjectCnStr = IETFUtils.valueToString(currentCertificateSubjectCN.getFirst().getValue());
if (!certificateCommonName.equals(currentCertificateSubjectCnStr)) {
logger.log(Level.INFO, "- Certificate regeneration necessary: Cert common name in daemon config changed from " + currentCertificateSubjectCnStr + " to " + certificateCommonName + ".");
return true;
}
}
else {
logger.log(Level.INFO, "- Certificate regeneration necessary, because no certificate found in key store.");
return true;
}
return false;
}
catch (Exception e) {
throw new RuntimeException("Cannot (re-)generate server certificate for hostname: " + certificateCommonName, e);
}
}
public static void generateNewKeyPairAndCertificate(String certificateCommonName) {
try {
logger.log(Level.INFO, "(Re-)generating keypair and certificate for hostname " + certificateCommonName + " ...");
// Generate key pair and certificate
KeyPair keyPair = CipherUtil.generateRsaKeyPair();
X509Certificate certificate = CipherUtil.generateSelfSignedCertificate(certificateCommonName, keyPair);
// Add key and certificate to key store
UserConfig.getUserKeyStore().setKeyEntry(CipherParams.CERTIFICATE_IDENTIFIER, keyPair.getPrivate(), new char[0], new Certificate[] { certificate });
UserConfig.storeUserKeyStore();
// Add certificate to trust store (for CLI->API connection)
UserConfig.getUserTrustStore().setCertificateEntry(CipherParams.CERTIFICATE_IDENTIFIER, certificate);
UserConfig.storeTrustStore();
}
catch (Exception e) {
throw new RuntimeException("Unable to read key store or generate self-signed certificate.", e);
}
}
private static HttpHandler addSecurity(final HttpHandler toWrap, IdentityManager identityManager) {
List<AuthenticationMechanism> mechanisms =
Collections.<AuthenticationMechanism> singletonList(new BasicAuthenticationMechanism("Syncany"));
HttpHandler handler = toWrap;
handler = new AuthenticationCallHandler(handler);
handler = new AuthenticationConstraintHandler(handler);
handler = new AuthenticationMechanismsHandler(handler, mechanisms);
handler = new SecurityInitialHandler(AuthenticationMode.PRO_ACTIVE, identityManager, handler);
return handler;
}
@Subscribe
public void onGetFileResponseInternal(GetFileFolderResponseInternal fileResponseInternal) {
File tempFile = fileResponseInternal.getTempFile();
GetFileFolderResponse fileResponse = fileResponseInternal.getFileResponse();
fileTokenTempFileCache.asMap().put(fileResponse.getTempToken(), tempFile);
eventBus.post(fileResponse);
}
@Subscribe
public void onEvent(ExternalEvent event) {
try {
sendBroadcast(MessageFactory.toXml(event));
}
catch (Exception e) {
logger.log(Level.SEVERE, "Cannot send event.", e);
}
}
@Subscribe
public void onResponse(Response response) {
try {
// Serialize response
String responseMessage = MessageFactory.toXml(response);
// Send to one or many receivers
boolean responseWithoutRequest = response.getRequestId() == null || response.getRequestId() <= 0;
if (responseWithoutRequest) {
sendBroadcast(responseMessage);
}
else {
HttpServerExchange responseToHttpServerExchange = requestIdRestSocketCache.asMap().get(response.getRequestId());
WebSocketChannel responseToWebSocketChannel = requestIdWebSocketCache.asMap().get(response.getRequestId());
if (responseToHttpServerExchange != null) {
sendTo(responseToHttpServerExchange, responseMessage);
}
else if (responseToWebSocketChannel != null) {
sendTo(responseToWebSocketChannel, responseMessage);
}
else {
logger.log(Level.WARNING, "Cannot send message, because request ID in response is unknown or timed out." + responseMessage);
}
}
}
catch (Exception e) {
logger.log(Level.SEVERE, "Cannot send response.", e);
}
}
private void sendBroadcast(String message) {
logger.log(Level.INFO, "Sending broadcast message to " + clientChannels.size() + " websocket client(s)");
synchronized (clientChannels) {
for (WebSocketChannel clientChannel : clientChannels) {
sendTo(clientChannel, message);
}
}
}
private void sendTo(WebSocketChannel clientChannel, String message) {
logger.log(Level.INFO, "Sending message to " + clientChannel + ": " + message);
WebSockets.sendText(message, clientChannel, null);
}
private void sendTo(HttpServerExchange serverExchange, String message) {
logger.log(Level.INFO, "Sending message to " + serverExchange.getHostAndPort() + ": " + message);
serverExchange.getResponseSender().send(message);
serverExchange.endExchange();
}
// Client channel access methods
public void addClientChannel(WebSocketChannel clientChannel) {
synchronized (clientChannels) {
clientChannels.add(clientChannel);
}
}
public void removeClientChannel(WebSocketChannel clientChannel) {
synchronized (clientChannels) {
clientChannels.remove(clientChannel);
}
}
// Cache access methods
public void putCacheRestRequest(int id, HttpServerExchange exchange) {
synchronized (requestIdRestSocketCache) {
requestIdRestSocketCache.put(id, exchange);
}
}
public void putCacheWebSocketRequest(int id, WebSocketChannel clientSocket) {
synchronized (requestIdWebSocketCache) {
requestIdWebSocketCache.put(id, clientSocket);
}
}
public File getFileTokenTempFileFromCache(String fileToken) {
return fileTokenTempFileCache.asMap().get(fileToken);
}
}