/* * Syncany, www.syncany.org * Copyright (C) 2011-2015 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 javax.net.ssl.SSLContext; 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.Map; import java.util.concurrent.TimeUnit; import java.util.logging.Level; import java.util.logging.Logger; 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.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.JsonMessageFactory; import org.syncany.operations.daemon.messages.api.Message; import org.syncany.operations.daemon.messages.api.Response; import org.syncany.operations.daemon.messages.api.XmlMessageFactory; import org.syncany.plugins.web.WebInterfacePlugin; import com.google.common.cache.Cache; import com.google.common.cache.CacheBuilder; import com.google.common.collect.Maps; import com.google.common.eventbus.Subscribe; 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; /** * 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 { public static final String API_ENDPOINT_WS_XML = "/api/ws/xml"; public static final String API_ENDPOINT_WS_JSON = "/api/ws/json"; public static final String API_ENDPOINT_REST_XML = "/api/rs/xml"; public static final String API_ENDPOINT_REST_JSON = "/api/rs/json"; public enum RequestFormatType { XML, JSON } private static final Logger logger = Logger.getLogger(WebServer.class.getSimpleName()); private static final RequestFormatType DEFAULT_RESPONSE_FORMAT = RequestFormatType.XML; private Undertow webServer; private LocalEventBus eventBus; private Cache<Integer, WebSocketChannel> requestIdWebSocketCache; private Cache<Integer, HttpServerExchange> requestIdRestSocketCache; private Cache<Integer, RequestFormatType> requestIdRestFormatCache; private Cache<String, File> fileTokenTempFileCache; private Map<WebSocketChannel, RequestFormatType> webSocketChannelRequestFormatMap; public WebServer(DaemonConfigTO daemonConfig) throws Exception { this.webSocketChannelRequestFormatMap = Maps.newConcurrentMap(); 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(); requestIdRestFormatCache = 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_ENDPOINT_WS_XML, websocket(new InternalWebSocketHandler(this, certificateCommonName, RequestFormatType.XML))) .addPrefixPath(API_ENDPOINT_WS_JSON, websocket(new InternalWebSocketHandler(this, certificateCommonName, RequestFormatType.JSON))) .addPrefixPath(API_ENDPOINT_REST_XML, new InternalRestHandler(this, RequestFormatType.XML)) .addPrefixPath(API_ENDPOINT_REST_JSON, new InternalRestHandler(this, RequestFormatType.JSON)) .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(event); } catch (Exception e) { logger.log(Level.SEVERE, "Cannot send event.", e); } } @Subscribe public void onResponse(Response response) { try { // Send to one or many receivers boolean responseWithoutRequest = response.getRequestId() == null || response.getRequestId() <= 0; if (responseWithoutRequest) { sendBroadcast(response); } else { HttpServerExchange responseToHttpServerExchange = requestIdRestSocketCache.asMap().get(response.getRequestId()); WebSocketChannel responseToWebSocketChannel = requestIdWebSocketCache.asMap().get(response.getRequestId()); if (responseToHttpServerExchange != null) { sendTo(responseToHttpServerExchange, response); } else if (responseToWebSocketChannel != null) { sendTo(responseToWebSocketChannel, response); } else { logger.log(Level.WARNING, "Cannot send message, because request ID in response is unknown or timed out." + response); } } } catch (Exception e) { logger.log(Level.SEVERE, "Cannot send response.", e); } } private void sendBroadcast(Message message) throws Exception { logger.log(Level.INFO, "Sending broadcast message to " + webSocketChannelRequestFormatMap.size() + " websocket client(s)"); synchronized (webSocketChannelRequestFormatMap) { for (WebSocketChannel clientChannel : webSocketChannelRequestFormatMap.keySet()) { sendTo(clientChannel, message); } } } private void sendTo(WebSocketChannel clientChannel, Message message) throws Exception { String messageStr = createMessageStr(clientChannel, message); logger.log(Level.INFO, "Sending message to " + clientChannel + ": " + messageStr); WebSockets.sendText(messageStr, clientChannel, null); } private void sendTo(HttpServerExchange serverExchange, Response response) throws Exception { String responseStr = createMessageStr(response); logger.log(Level.INFO, "Sending message to " + serverExchange.getHostAndPort() + ": " + responseStr); serverExchange.getResponseSender().send(responseStr); serverExchange.endExchange(); } private String createMessageStr(WebSocketChannel channel, Message message) throws Exception { RequestFormatType requestFormatType = webSocketChannelRequestFormatMap.get(channel); return createMessageStr(message, requestFormatType); } private String createMessageStr(Response response) throws Exception { RequestFormatType requestFormatType = requestIdRestFormatCache.getIfPresent(response.getRequestId()); return createMessageStr(response, requestFormatType); } private String createMessageStr(Message message, RequestFormatType outputFormat) throws Exception { if (outputFormat == null) { outputFormat = DEFAULT_RESPONSE_FORMAT; } switch (outputFormat) { case JSON: return JsonMessageFactory.toJson(message); case XML: default: return XmlMessageFactory.toXml(message); } } // Client channel access methods public void addClientChannel(WebSocketChannel clientChannel, RequestFormatType format) { webSocketChannelRequestFormatMap.put(clientChannel, format); } public void removeClientChannel(WebSocketChannel clientChannel) { webSocketChannelRequestFormatMap.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 void putRequestFormatType(int id, RequestFormatType requestFormatType) { synchronized (requestIdRestFormatCache) { requestIdRestFormatCache.put(id, requestFormatType); } } public File getFileTokenTempFileFromCache(String fileToken) { return fileTokenTempFileCache.asMap().get(fileToken); } }