001/*
002 * Syncany, www.syncany.org
003 * Copyright (C) 2011-2016 Philipp C. Heckel <philipp.heckel@gmail.com>
004 *
005 * This program is free software: you can redistribute it and/or modify
006 * it under the terms of the GNU General Public License as published by
007 * the Free Software Foundation, either version 3 of the License, or
008 * (at your option) any later version.
009 *
010 * This program is distributed in the hope that it will be useful,
011 * but WITHOUT ANY WARRANTY; without even the implied warranty of
012 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
013 * GNU General Public License for more details.
014 *
015 * You should have received a copy of the GNU General Public License
016 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
017 */
018package org.syncany.operations.daemon;
019
020import static io.undertow.Handlers.path;
021import static io.undertow.Handlers.websocket;
022
023import javax.net.ssl.SSLContext;
024
025import java.io.File;
026import java.security.KeyPair;
027import java.security.KeyStore;
028import java.security.cert.Certificate;
029import java.security.cert.X509Certificate;
030import java.util.ArrayList;
031import java.util.Collections;
032import java.util.List;
033import java.util.Map;
034import java.util.concurrent.TimeUnit;
035import java.util.logging.Level;
036import java.util.logging.Logger;
037
038import org.bouncycastle.asn1.x500.RDN;
039import org.bouncycastle.asn1.x500.X500Name;
040import org.bouncycastle.asn1.x500.style.BCStyle;
041import org.bouncycastle.asn1.x500.style.IETFUtils;
042import org.bouncycastle.cert.jcajce.JcaX509CertificateHolder;
043import org.syncany.config.LocalEventBus;
044import org.syncany.config.UserConfig;
045import org.syncany.config.to.DaemonConfigTO;
046import org.syncany.config.to.UserTO;
047import org.syncany.config.to.WebServerTO;
048import org.syncany.crypto.CipherParams;
049import org.syncany.crypto.CipherUtil;
050import org.syncany.operations.daemon.handlers.InternalRestHandler;
051import org.syncany.operations.daemon.handlers.InternalWebInterfaceHandler;
052import org.syncany.operations.daemon.handlers.InternalWebSocketHandler;
053import org.syncany.operations.daemon.messages.GetFileFolderResponse;
054import org.syncany.operations.daemon.messages.GetFileFolderResponseInternal;
055import org.syncany.operations.daemon.messages.api.ExternalEvent;
056import org.syncany.operations.daemon.messages.api.JsonMessageFactory;
057import org.syncany.operations.daemon.messages.api.Message;
058import org.syncany.operations.daemon.messages.api.Response;
059import org.syncany.operations.daemon.messages.api.XmlMessageFactory;
060import org.syncany.plugins.web.WebInterfacePlugin;
061
062import com.google.common.cache.Cache;
063import com.google.common.cache.CacheBuilder;
064import com.google.common.collect.Maps;
065import com.google.common.eventbus.Subscribe;
066
067import io.undertow.Undertow;
068import io.undertow.security.api.AuthenticationMechanism;
069import io.undertow.security.api.AuthenticationMode;
070import io.undertow.security.handlers.AuthenticationCallHandler;
071import io.undertow.security.handlers.AuthenticationConstraintHandler;
072import io.undertow.security.handlers.AuthenticationMechanismsHandler;
073import io.undertow.security.handlers.SecurityInitialHandler;
074import io.undertow.security.idm.IdentityManager;
075import io.undertow.security.impl.BasicAuthenticationMechanism;
076import io.undertow.server.HttpHandler;
077import io.undertow.server.HttpServerExchange;
078import io.undertow.websockets.core.WebSocketChannel;
079import io.undertow.websockets.core.WebSockets;
080
081/**
082 * The web server provides a HTTP/REST and WebSocket API to thin clients,
083 * as well as a mechanism to run a web interface by implementing a
084 * {@link WebInterfacePlugin}.
085 *
086 * @author Philipp C. Heckel (philipp.heckel@gmail.com)
087 */
088public class WebServer {
089        public static final String API_ENDPOINT_WS_XML = "/api/ws/xml";
090        public static final String API_ENDPOINT_WS_JSON = "/api/ws/json";
091        public static final String API_ENDPOINT_REST_XML = "/api/rs/xml";
092        public static final String API_ENDPOINT_REST_JSON = "/api/rs/json";
093        
094        public enum RequestFormatType {
095                XML, JSON
096        }
097
098        private static final Logger logger = Logger.getLogger(WebServer.class.getSimpleName());
099        private static final RequestFormatType DEFAULT_RESPONSE_FORMAT = RequestFormatType.XML;
100
101        private Undertow webServer;
102        private LocalEventBus eventBus;
103
104        private Cache<Integer, WebSocketChannel> requestIdWebSocketCache;
105        private Cache<Integer, HttpServerExchange> requestIdRestSocketCache;
106        private Cache<Integer, RequestFormatType> requestIdRestFormatCache;
107        private Cache<String, File> fileTokenTempFileCache;
108
109        private Map<WebSocketChannel, RequestFormatType> webSocketChannelRequestFormatMap;
110
111        public WebServer(DaemonConfigTO daemonConfig) throws Exception {
112                this.webSocketChannelRequestFormatMap = Maps.newConcurrentMap();
113                
114                initCaches();
115                initEventBus();
116                initServer(daemonConfig);
117        }
118
119        public void start() throws ServiceAlreadyStartedException {
120                webServer.start();
121        }
122
123        public void stop() {
124                try {
125                        logger.log(Level.INFO, "Shutting down websocket server.");
126                        webServer.stop();
127                }
128                catch (Exception e) {
129                        logger.log(Level.SEVERE, "Could not stop websocket server.", e);
130                }
131        }
132
133        private void initCaches() {
134                requestIdWebSocketCache = CacheBuilder.newBuilder().maximumSize(10000)
135                                                .concurrencyLevel(2).expireAfterAccess(1, TimeUnit.MINUTES).build();
136
137                requestIdRestSocketCache = CacheBuilder.newBuilder().maximumSize(10000)
138                                                .concurrencyLevel(2).expireAfterAccess(1, TimeUnit.MINUTES).build();
139
140                fileTokenTempFileCache = CacheBuilder.newBuilder().maximumSize(10000)
141                                                .concurrencyLevel(2).expireAfterAccess(1, TimeUnit.MINUTES).build();
142
143                requestIdRestFormatCache = CacheBuilder.newBuilder().maximumSize(10000)
144                                                .concurrencyLevel(2).expireAfterAccess(1, TimeUnit.MINUTES).build();
145        }
146
147        private void initEventBus() {
148                eventBus = LocalEventBus.getInstance();
149                eventBus.register(this);
150        }
151
152        private void initServer(DaemonConfigTO daemonConfigTO) throws Exception {
153                WebServerTO webServerConfig = daemonConfigTO.getWebServer();
154
155                // Bind address and port
156                String bindAddress = webServerConfig.getBindAddress();
157                int bindPort = webServerConfig.getBindPort();
158
159                // Users (incl. CLI user!)
160                List<UserTO> users = readWebServerUsers(daemonConfigTO);
161                IdentityManager identityManager = new MapIdentityManager(users);
162
163                // (Re-)generate keypair/certificate (if requested)
164                boolean certificateAutoGenerate = webServerConfig.isCertificateAutoGenerate();
165                String certificateCommonName = webServerConfig.getCertificateCommonName();
166
167                if (certificateAutoGenerate && certificateCommonNameChanged(certificateCommonName)) {
168                        generateNewKeyPairAndCertificate(certificateCommonName);
169                }
170
171                // Set up the handlers for WebSocket, REST and the web interface
172                HttpHandler pathHttpHandler = path()
173                                                .addPrefixPath(API_ENDPOINT_WS_XML, websocket(new InternalWebSocketHandler(this, certificateCommonName, RequestFormatType.XML)))
174                                                .addPrefixPath(API_ENDPOINT_WS_JSON, websocket(new InternalWebSocketHandler(this, certificateCommonName, RequestFormatType.JSON)))
175                                                .addPrefixPath(API_ENDPOINT_REST_XML, new InternalRestHandler(this, RequestFormatType.XML))
176                                                .addPrefixPath(API_ENDPOINT_REST_JSON, new InternalRestHandler(this, RequestFormatType.JSON))
177                                                .addPrefixPath("/", new InternalWebInterfaceHandler());
178
179                // Add some security spices
180                HttpHandler securityPathHttpHandler = addSecurity(pathHttpHandler, identityManager);
181                SSLContext sslContext = UserConfig.createUserSSLContext();
182
183                // And go for it!
184                webServer = Undertow
185                                                .builder()
186                                                .addHttpsListener(bindPort, bindAddress, sslContext)
187                                                .setHandler(securityPathHttpHandler)
188                                                .build();
189
190                logger.log(Level.INFO, "Initialized web server.");
191        }
192
193        private List<UserTO> readWebServerUsers(DaemonConfigTO daemonConfigTO) {
194                List<UserTO> users = daemonConfigTO.getUsers();
195
196                if (users == null) {
197                        users = new ArrayList<UserTO>();
198                }
199
200                // Add CLI credentials
201                if (daemonConfigTO.getPortTO() != null) {
202                        users.add(daemonConfigTO.getPortTO().getUser());
203                }
204
205                return users;
206        }
207
208        private boolean certificateCommonNameChanged(String certificateCommonName) {
209                try {
210                        KeyStore userKeyStore = UserConfig.getUserKeyStore();
211                        X509Certificate currentCertificate = (X509Certificate) userKeyStore.getCertificate(CipherParams.CERTIFICATE_IDENTIFIER);
212
213                        if (currentCertificate != null) {
214                                X500Name currentCertificateSubject = new JcaX509CertificateHolder(currentCertificate).getSubject();
215                                RDN currentCertificateSubjectCN = currentCertificateSubject.getRDNs(BCStyle.CN)[0];
216
217                                String currentCertificateSubjectCnStr = IETFUtils.valueToString(currentCertificateSubjectCN.getFirst().getValue());
218
219                                if (!certificateCommonName.equals(currentCertificateSubjectCnStr)) {
220                                        logger.log(Level.INFO, "- Certificate regeneration necessary: Cert common name in daemon config changed from " + currentCertificateSubjectCnStr + " to " + certificateCommonName + ".");
221                                        return true;
222                                }
223                        }
224                        else {
225                                logger.log(Level.INFO, "- Certificate regeneration necessary, because no certificate found in key store.");
226                                return true;
227                        }
228
229                        return false;
230                }
231                catch (Exception e) {
232                        throw new RuntimeException("Cannot (re-)generate server certificate for hostname: " + certificateCommonName, e);
233                }
234        }
235
236        public static void generateNewKeyPairAndCertificate(String certificateCommonName) {
237                try {
238                        logger.log(Level.INFO, "(Re-)generating keypair and certificate for hostname " + certificateCommonName + " ...");
239
240                        // Generate key pair and certificate
241                        KeyPair keyPair = CipherUtil.generateRsaKeyPair();
242                        X509Certificate certificate = CipherUtil.generateSelfSignedCertificate(certificateCommonName, keyPair);
243
244                        // Add key and certificate to key store
245                        UserConfig.getUserKeyStore().setKeyEntry(CipherParams.CERTIFICATE_IDENTIFIER, keyPair.getPrivate(), new char[0], new Certificate[]{certificate});
246                        UserConfig.storeUserKeyStore();
247
248                        // Add certificate to trust store (for CLI->API connection)
249                        UserConfig.getUserTrustStore().setCertificateEntry(CipherParams.CERTIFICATE_IDENTIFIER, certificate);
250                        UserConfig.storeTrustStore();
251                }
252                catch (Exception e) {
253                        throw new RuntimeException("Unable to read key store or generate self-signed certificate.", e);
254                }
255        }
256
257        private static HttpHandler addSecurity(final HttpHandler toWrap, IdentityManager identityManager) {
258                List<AuthenticationMechanism> mechanisms =
259                                                Collections.<AuthenticationMechanism>singletonList(new BasicAuthenticationMechanism("Syncany"));
260
261                HttpHandler handler = toWrap;
262
263                handler = new AuthenticationCallHandler(handler);
264                handler = new AuthenticationConstraintHandler(handler);
265                handler = new AuthenticationMechanismsHandler(handler, mechanisms);
266                handler = new SecurityInitialHandler(AuthenticationMode.PRO_ACTIVE, identityManager, handler);
267
268                return handler;
269        }
270
271        @Subscribe
272        public void onGetFileResponseInternal(GetFileFolderResponseInternal fileResponseInternal) {
273                File tempFile = fileResponseInternal.getTempFile();
274                GetFileFolderResponse fileResponse = fileResponseInternal.getFileResponse();
275
276                fileTokenTempFileCache.asMap().put(fileResponse.getTempToken(), tempFile);
277                eventBus.post(fileResponse);
278        }
279
280        @Subscribe
281        public void onEvent(ExternalEvent event) {
282                try {
283                        sendBroadcast(event);
284                }
285                catch (Exception e) {
286                        logger.log(Level.SEVERE, "Cannot send event.", e);
287                }
288        }
289
290        @Subscribe
291        public void onResponse(Response response) {
292                try {
293                        // Send to one or many receivers
294                        boolean responseWithoutRequest = response.getRequestId() == null || response.getRequestId() <= 0;
295
296                        if (responseWithoutRequest) {
297                                sendBroadcast(response);
298                        }
299                        else {
300                                HttpServerExchange responseToHttpServerExchange = requestIdRestSocketCache.asMap().get(response.getRequestId());
301                                WebSocketChannel responseToWebSocketChannel = requestIdWebSocketCache.asMap().get(response.getRequestId());
302
303                                if (responseToHttpServerExchange != null) {
304                                        sendTo(responseToHttpServerExchange, response);
305                                }
306                                else if (responseToWebSocketChannel != null) {
307                                        sendTo(responseToWebSocketChannel, response);
308                                }
309                                else {
310                                        logger.log(Level.WARNING, "Cannot send message, because request ID in response is unknown or timed out." + response);
311                                }
312                        }
313                }
314                catch (Exception e) {
315                        logger.log(Level.SEVERE, "Cannot send response.", e);
316                }
317        }
318
319        private void sendBroadcast(Message message) throws Exception {
320                logger.log(Level.INFO, "Sending broadcast message to " + webSocketChannelRequestFormatMap.size() + " websocket client(s)");
321
322                synchronized (webSocketChannelRequestFormatMap) {
323                        for (WebSocketChannel clientChannel : webSocketChannelRequestFormatMap.keySet()) {
324                                sendTo(clientChannel, message);
325                        }
326                }
327        }
328
329        private void sendTo(WebSocketChannel clientChannel, Message message) throws Exception {
330                String messageStr = createMessageStr(clientChannel, message);
331                
332                logger.log(Level.INFO, "Sending message to " + clientChannel + ": " + messageStr);
333                WebSockets.sendText(messageStr, clientChannel, null);
334        }
335
336        private void sendTo(HttpServerExchange serverExchange, Response response) throws Exception {
337                String responseStr = createMessageStr(response);
338
339                logger.log(Level.INFO, "Sending message to " + serverExchange.getHostAndPort() + ": " + responseStr);
340
341                serverExchange.getResponseSender().send(responseStr);
342                serverExchange.endExchange();
343        }
344        
345        private String createMessageStr(WebSocketChannel channel, Message message) throws Exception {
346                RequestFormatType requestFormatType = webSocketChannelRequestFormatMap.get(channel);
347                return createMessageStr(message, requestFormatType);
348        }
349
350        private String createMessageStr(Response response) throws Exception {
351                RequestFormatType requestFormatType = requestIdRestFormatCache.getIfPresent(response.getRequestId());
352                return createMessageStr(response, requestFormatType);           
353        }
354
355        private String createMessageStr(Message message, RequestFormatType outputFormat) throws Exception {
356                if (outputFormat == null) {
357                        outputFormat = DEFAULT_RESPONSE_FORMAT;
358                }
359
360                switch (outputFormat) {
361                        case JSON:
362                                return JsonMessageFactory.toJson(message);
363
364                        case XML:
365                        default:
366                                return XmlMessageFactory.toXml(message);
367                }
368        }
369
370        // Client channel access methods
371
372        public void addClientChannel(WebSocketChannel clientChannel, RequestFormatType format) {
373                webSocketChannelRequestFormatMap.put(clientChannel, format);
374        }
375
376        public void removeClientChannel(WebSocketChannel clientChannel) {
377                webSocketChannelRequestFormatMap.remove(clientChannel);
378        }
379
380        // Cache access methods
381
382        public void putCacheRestRequest(int id, HttpServerExchange exchange) {
383                synchronized (requestIdRestSocketCache) {
384                        requestIdRestSocketCache.put(id, exchange);
385                }
386        }
387
388        public void putCacheWebSocketRequest(int id, WebSocketChannel clientSocket) {
389                synchronized (requestIdWebSocketCache) {
390                        requestIdWebSocketCache.put(id, clientSocket);
391                }
392        }
393
394        public void putRequestFormatType(int id, RequestFormatType requestFormatType) {
395                synchronized (requestIdRestFormatCache) {
396                        requestIdRestFormatCache.put(id, requestFormatType);
397                }
398        }
399
400        public File getFileTokenTempFileFromCache(String fileToken) {
401                return fileTokenTempFileCache.asMap().get(fileToken);
402        }
403}