]> git.basschouten.com Git - openhab-addons.git/blob
62dda2b0a81e1527fc5e2be50076fa8f2267c2c0
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2020 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
7  * This program and the accompanying materials are made available under the
8  * terms of the Eclipse Public License 2.0 which is available at
9  * http://www.eclipse.org/legal/epl-2.0
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.io.hueemulation.internal.upnp;
14
15 import java.io.BufferedReader;
16 import java.io.IOException;
17 import java.io.InputStream;
18 import java.io.InputStreamReader;
19 import java.io.PrintWriter;
20 import java.net.DatagramPacket;
21 import java.net.DatagramSocket;
22 import java.net.Inet4Address;
23 import java.net.Inet6Address;
24 import java.net.InetAddress;
25 import java.net.InetSocketAddress;
26 import java.net.NetworkInterface;
27 import java.net.SocketAddress;
28 import java.net.StandardProtocolFamily;
29 import java.net.StandardSocketOptions;
30 import java.net.UnknownHostException;
31 import java.nio.ByteBuffer;
32 import java.nio.channels.ClosedSelectorException;
33 import java.nio.channels.DatagramChannel;
34 import java.nio.channels.SelectionKey;
35 import java.nio.channels.Selector;
36 import java.nio.charset.StandardCharsets;
37 import java.time.Instant;
38 import java.util.ArrayList;
39 import java.util.Iterator;
40 import java.util.List;
41 import java.util.concurrent.CompletableFuture;
42 import java.util.concurrent.Executor;
43 import java.util.concurrent.ForkJoinPool;
44 import java.util.function.Consumer;
45 import java.util.stream.Collectors;
46
47 import javax.servlet.ServletException;
48 import javax.servlet.http.HttpServlet;
49 import javax.servlet.http.HttpServletRequest;
50 import javax.servlet.http.HttpServletResponse;
51 import javax.ws.rs.ProcessingException;
52 import javax.ws.rs.client.Client;
53 import javax.ws.rs.client.ClientBuilder;
54 import javax.ws.rs.core.Response;
55
56 import org.eclipse.jdt.annotation.NonNullByDefault;
57 import org.eclipse.jdt.annotation.Nullable;
58 import org.glassfish.jersey.client.ClientConfig;
59 import org.glassfish.jersey.client.ClientProperties;
60 import org.openhab.io.hueemulation.internal.ConfigStore;
61 import org.osgi.service.component.annotations.Activate;
62 import org.osgi.service.component.annotations.Component;
63 import org.osgi.service.component.annotations.ConfigurationPolicy;
64 import org.osgi.service.component.annotations.Deactivate;
65 import org.osgi.service.component.annotations.Reference;
66 import org.osgi.service.event.Event;
67 import org.osgi.service.event.EventAdmin;
68 import org.osgi.service.event.EventConstants;
69 import org.osgi.service.event.EventHandler;
70 import org.osgi.service.http.HttpService;
71 import org.osgi.service.http.NamespaceException;
72 import org.slf4j.Logger;
73 import org.slf4j.LoggerFactory;
74
75 /**
76  * Advertises a Hue compatible bridge via UPNP and provides the announced /description.xml http endpoint.
77  *
78  * @author Dan Cunningham - Initial contribution
79  * @author David Graeff - Rewritten
80  */
81 @SuppressWarnings("serial")
82 @NonNullByDefault
83 @Component(immediate = false, // Don't start the upnp server on its own. Must be pulled in by HueEmulationService.
84         configurationPolicy = ConfigurationPolicy.IGNORE, property = {
85                 EventConstants.EVENT_TOPIC + "=" + ConfigStore.EVENT_ADDRESS_CHANGED,
86                 "com.eclipsesource.jaxrs.publish=false" }, //
87         service = { UpnpServer.class, EventHandler.class })
88 public class UpnpServer extends HttpServlet implements Consumer<HueEmulationConfigWithRuntime>, EventHandler {
89     /**
90      * Used by async IO. This is our context object class.
91      */
92     static class ClientRecord {
93         public @Nullable SocketAddress clientAddress;
94         public ByteBuffer buffer = ByteBuffer.allocate(1000);
95     }
96
97     public static final String DISCOVERY_FILE = "/description.xml";
98
99     // jUPNP shares port 1900, but since this is multicast, we can also bind to it
100     public static final int UPNP_PORT = 1900;
101     /**
102      * Send a keep alive every 2 minutes
103      */
104     private static final int CACHE_MSECS = 120 * 1000;
105
106     private final Logger logger = LoggerFactory.getLogger(UpnpServer.class);
107
108     public final InetAddress MULTI_ADDR_IPV4;
109     public final InetAddress MULTI_ADDR_IPV6;
110     private String[] stVersions = { "", "", "" };
111     private String notifyMsg = "";
112
113     //// objects, set within activate()
114     protected @NonNullByDefault({}) String xmlDoc;
115     protected @NonNullByDefault({}) String xmlDocWithAddress;
116     private @NonNullByDefault({}) String baseurl;
117
118     //// services
119     @Reference
120     protected @NonNullByDefault({}) ConfigStore cs;
121     @Reference
122     protected @NonNullByDefault({}) HttpService httpService;
123
124     public boolean overwriteReadyToFalse = false;
125
126     private HueEmulationConfigWithRuntime config;
127     protected CompletableFuture<@Nullable HueEmulationConfigWithRuntime> configChangeFuture = CompletableFuture
128             .completedFuture(config);
129
130     private List<SelfTestReport> selfTests = new ArrayList<>();
131     private final Executor executor;
132
133     /**
134      * Creates a server instance.
135      * UPnP IPv4/v6 multicast addresses are determined.
136      */
137     public UpnpServer() {
138         this(ForkJoinPool.commonPool());
139     }
140
141     public UpnpServer(Executor executor) {
142         try {
143             MULTI_ADDR_IPV4 = InetAddress.getByName("239.255.255.250");
144             MULTI_ADDR_IPV6 = InetAddress.getByName("ff02::c");
145             config = new HueEmulationConfigWithRuntime(this, null, MULTI_ADDR_IPV4, MULTI_ADDR_IPV6);
146         } catch (UnknownHostException e) {
147             throw new IllegalStateException(e);
148         }
149
150         this.executor = executor;
151     }
152
153     /**
154      * this object is also a servlet for providing /description.xml, the UPnP discovery result
155      */
156     @NonNullByDefault({})
157     @Override
158     protected void service(HttpServletRequest req, HttpServletResponse resp) throws IOException {
159         if (xmlDocWithAddress == null || xmlDocWithAddress.isEmpty()) {
160             resp.sendError(HttpServletResponse.SC_NOT_FOUND);
161             return;
162         }
163         try (PrintWriter out = resp.getWriter()) {
164             resp.setContentType("application/xml");
165             out.write(xmlDocWithAddress);
166         }
167     }
168
169     /**
170      * Server to send UDP packets onto the network when requested by a Hue API compatible device.
171      *
172      * @param relativePath The URI path where the discovery xml document can be retrieved
173      * @param config The hue datastore. Contains the bridgeid and uuid.
174      * @param address IP to advertise for UPNP
175      * @throws IOException
176      * @throws NamespaceException
177      * @throws ServletException
178      */
179     @Activate
180     protected void activate() {
181         InputStream resourceAsStream = getClass().getClassLoader().getResourceAsStream("discovery.xml");
182         if (resourceAsStream == null) {
183             logger.warn("Could not start Hue Emulation service: discovery.xml not found");
184             return;
185         }
186         try (InputStreamReader r = new InputStreamReader(resourceAsStream, StandardCharsets.UTF_8);
187                 BufferedReader br = new BufferedReader(r)) {
188             xmlDoc = br.lines().collect(Collectors.joining("\n"));
189         } catch (IOException e) {
190             logger.warn("Could not start Hue Emulation UPNP server: {}", e.getMessage(), e);
191             return;
192         }
193
194         try {
195             httpService.unregister(DISCOVERY_FILE);
196         } catch (IllegalArgumentException ignore) {
197         }
198
199         try {
200             httpService.registerServlet(DISCOVERY_FILE, this, null, null);
201         } catch (ServletException | NamespaceException e) {
202             logger.warn("Could not start Hue Emulation UPNP server: {}", e.getMessage(), e);
203         }
204
205         if (cs.isReady() && !overwriteReadyToFalse) {
206             handleEvent(null);
207         }
208     }
209
210     private void useAddressPort(HueEmulationConfigWithRuntime r) {
211         final String urlBase = "http://" + r.addressString + ":" + r.port;
212         this.baseurl = urlBase + DISCOVERY_FILE;
213
214         final String[] stVersions = { "upnp:rootdevice", "urn:schemas-upnp-org:device:basic:1",
215                 "uuid:" + config.config.uuid };
216         for (int i = 0; i < stVersions.length; ++i) {
217             this.stVersions[i] = String.format(
218                     "HTTP/1.1 200 OK\r\n" + "HOST: %s:%d\r\n" + "EXT:\r\n" + "CACHE-CONTROL: max-age=%d\r\n"
219                             + "LOCATION: %s\r\n" + "SERVER: Linux/3.14.0 UPnP/1.0 IpBridge/%s\r\n"
220                             + "hue-bridgeid: %s\r\n" + "ST: %s\r\n" + "USN: uuid:%s\r\n\r\n",
221                     r.getMulticastAddress(), UPNP_PORT, CACHE_MSECS / 1000, baseurl, // host:port,
222                                                                                      // cache,location
223                     cs.ds.config.apiversion, cs.ds.config.bridgeid, // version, bridgeid
224                     stVersions[i], config.config.uuid);
225         }
226
227         this.notifyMsg = String.format(
228                 "NOTIFY * HTTP/1.1\r\n" + "HOST: %s:%d\r\n" + "CACHE-CONTROL: max-age=%d\r\n" + "LOCATION: %s\r\n"
229                         + "SERVER: Linux/3.14.0 UPnP/1.0 IpBridge/%s\r\nNTS: ssdp:alive\r\nNT: upnp:rootdevice\r\n"
230                         + "USN: uuid:%s::upnp:rootdevice\r\n" + "hue-bridgeid: %s\r\n\r\n",
231                 r.getMulticastAddress(), UPNP_PORT, CACHE_MSECS / 1000, baseurl, // host:port, cache,location
232                 cs.ds.config.apiversion, config.config.uuid, cs.ds.config.bridgeid);// version, uuid, bridgeid
233
234         xmlDocWithAddress = String.format(xmlDoc, urlBase, r.addressString, cs.ds.config.bridgeid, cs.ds.config.uuid,
235                 cs.ds.config.devicename);
236     }
237
238     protected @Nullable HueEmulationConfigWithRuntime performAddressTest(
239             @Nullable HueEmulationConfigWithRuntime config) {
240         if (config == null) {
241             return null; // Config hasn't changed
242         }
243
244         selfTests.clear();
245
246         ClientConfig configuration = new ClientConfig();
247         configuration = configuration.property(ClientProperties.CONNECT_TIMEOUT, 1000);
248         configuration = configuration.property(ClientProperties.READ_TIMEOUT, 1000);
249         Client client = ClientBuilder.newClient(configuration);
250         Response response;
251         String url = "";
252         try {
253             boolean selfTestOnPort80;
254             try {
255                 response = client.target("http://" + cs.ds.config.ipaddress + ":80" + DISCOVERY_FILE).request().get();
256                 selfTestOnPort80 = response.getStatus() == 200
257                         && response.readEntity(String.class).contains(cs.ds.config.bridgeid);
258             } catch (ProcessingException ignored) {
259                 selfTestOnPort80 = false;
260             }
261
262             // Prefer port 80 if possible and if not overwritten by discoveryHttpPort
263             config.port = config.config.discoveryHttpPort == 0 && selfTestOnPort80 ? 80 : config.port;
264
265             // Test on all assigned interface addresses on org.osgi.service.http.port as well as port 80
266             // Other services might run on port 80, so we search for our bridge ID in the returned document.
267             for (InetAddress address : cs.getDiscoveryIps()) {
268                 String ip = address.getHostAddress();
269                 if (address instanceof Inet6Address) {
270                     ip = "[" + ip.split("%")[0] + "]";
271                 }
272                 try {
273                     url = "http://" + ip + ":" + config.port + DISCOVERY_FILE;
274                     response = client.target(url).request().get();
275                     boolean isOurs = response.readEntity(String.class).contains(cs.ds.config.bridgeid);
276                     selfTests.add(new SelfTestReport(url, response.getStatus() == 200, isOurs));
277                 } catch (ProcessingException e) {
278                     logger.debug("Self test fail on {}: {}", url, e.getMessage());
279                     selfTests.add(SelfTestReport.failed(url));
280                 }
281                 try {
282                     url = "http://" + ip + DISCOVERY_FILE; // Port 80
283                     response = client.target(url).request().get();
284                     boolean isOurs = response.readEntity(String.class).contains(cs.ds.config.bridgeid);
285                     selfTests.add(new SelfTestReport(url, response.getStatus() == 200, isOurs));
286                 } catch (ProcessingException e) {
287                     logger.debug("Self test fail on {}: {}", url, e.getMessage());
288                     selfTests.add(SelfTestReport.failed(url));
289                 }
290             }
291         } finally {
292             client.close();
293         }
294         return config;
295     }
296
297     /**
298      * Create and return new runtime configuration based on {@link ConfigStore}s current configuration.
299      * Return null if the configuration has not changed compared to {@link #config}.
300      *
301      * @throws IllegalStateException If the {@link ConfigStore}s IP is invalid this exception is thrown.
302      */
303     protected @Nullable HueEmulationConfigWithRuntime createConfiguration(
304             @Nullable HueEmulationConfigWithRuntime ignoredParameter) throws IllegalStateException {
305         HueEmulationConfigWithRuntime r;
306         try {
307             r = new HueEmulationConfigWithRuntime(this, cs.getConfig(), cs.ds.config.ipaddress, MULTI_ADDR_IPV4,
308                     MULTI_ADDR_IPV6);
309         } catch (UnknownHostException e) {
310             logger.warn("The picked default IP address is not valid: {}", e.getMessage());
311             throw new IllegalStateException(e);
312         }
313         return r;
314     }
315
316     /**
317      * Apply the given runtime configuration by stopping the current udp thread, shutting down the socket and restarting
318      * the thread.
319      */
320     protected @Nullable HueEmulationConfigWithRuntime applyConfiguration(
321             @Nullable HueEmulationConfigWithRuntime newRuntimeConfig) {
322         if (newRuntimeConfig == null) {
323             return null;// Config hasn't changed
324         }
325         config.dispose();
326         this.config = newRuntimeConfig;
327         useAddressPort(config);
328         return config;
329     }
330
331     /**
332      * We have a hard dependency on the {@link ConfigStore} and that it has initialized the Hue DataStore config
333      * completely. That initialization happens asynchronously and therefore we cannot rely on OSGi activate/modified
334      * state changes. Instead the {@link EventAdmin} is used and we listen for the
335      * {@link ConfigStore#EVENT_ADDRESS_CHANGED} event that is fired as soon as the config is ready.
336      * <p>
337      * To be really sure that we are called here, this is also issued by the main service after it has received the
338      * configuration ready event and depending on service start order we are also called by our own activate() method
339      * when the configuration is already ready at that time.
340      * <p>
341      * Therefore this method is "synchronized" and chains a completable future for each call to re-evaluate the config
342      * after the former future has finished.
343      */
344     @Override
345     public synchronized void handleEvent(@Nullable Event event) {
346         CompletableFuture<@Nullable HueEmulationConfigWithRuntime> root;
347         // There is either already a running future, then chain a new one
348         if (!configChangeFuture.isDone()) {
349             root = configChangeFuture;
350         } else { // Or there is none -> create a new one
351             root = CompletableFuture.completedFuture(null);
352         }
353         configChangeFuture = root.thenApply(this::createConfiguration)
354                 .thenApplyAsync(this::performAddressTest, executor).thenApply(this::applyConfiguration)
355                 .thenCompose(config::startNow)
356                 .whenComplete((HueEmulationConfigWithRuntime config, @Nullable Throwable e) -> {
357                     if (e != null) {
358                         logger.warn("Upnp server: Address test failed", e);
359                     }
360                 });
361     }
362
363     /**
364      * Stops the upnp server from running
365      */
366     @Deactivate
367     public void deactivate() {
368         config.dispose();
369         try {
370             httpService.unregister(DISCOVERY_FILE);
371         } catch (IllegalArgumentException ignore) {
372         }
373     }
374
375     private void handleRead(SelectionKey key) throws IOException {
376         logger.trace("upnp thread handle received message");
377         DatagramChannel channel = (DatagramChannel) key.channel();
378         ClientRecord clntRec = (ClientRecord) key.attachment();
379         clntRec.buffer.clear(); // Prepare buffer for receiving
380         clntRec.clientAddress = channel.receive(clntRec.buffer);
381         InetSocketAddress recAddress = (InetSocketAddress) clntRec.clientAddress;
382         if (recAddress == null) { // Did we receive something?
383             return;
384         }
385         String data = new String(clntRec.buffer.array(), StandardCharsets.UTF_8);
386         if (!data.startsWith("M-SEARCH")) {
387             return;
388         }
389
390         try (DatagramSocket sendSocket = new DatagramSocket()) {
391             sendUPNPDatagrams(sendSocket, recAddress.getAddress(), recAddress.getPort());
392         }
393     }
394
395     private void sendUPNPDatagrams(DatagramSocket sendSocket, InetAddress address, int port) {
396         logger.trace("upnp thread send announcement");
397         for (String msg : stVersions) {
398             DatagramPacket response = new DatagramPacket(msg.getBytes(), msg.length(), address, port);
399             try {
400                 logger.trace("Sending to {}:{}", address.getHostAddress(), port);
401                 sendSocket.send(response);
402             } catch (IOException e) {
403                 logger.warn("Could not send UPNP response: {}", e.getMessage());
404             }
405         }
406     }
407
408     private void sendUPNPNotify(DatagramSocket sendSocket, InetAddress address, int port) {
409         DatagramPacket response = new DatagramPacket(notifyMsg.getBytes(), notifyMsg.length(), address, port);
410         try {
411             logger.trace("Sending to {}:{}", address.getHostAddress(), port);
412             sendSocket.send(response);
413         } catch (IOException e) {
414             logger.warn("Could not send UPNP response: {}", e.getMessage());
415         }
416     }
417
418     @Override
419     public void accept(HueEmulationConfigWithRuntime threadContext) {
420         logger.info("Hue Emulation UPNP server started on {}:{}", threadContext.addressString, threadContext.port);
421         boolean hasIPv4 = false;
422         boolean hasIPv6 = false;
423
424         try (Selector selector = Selector.open();
425                 DatagramChannel channelV4 = createBoundDataGramChannelOrNull(StandardProtocolFamily.INET);
426                 DatagramChannel channelV6 = createBoundDataGramChannelOrNull(StandardProtocolFamily.INET6)) {
427             // Set global config to thread local config. Otherwise upnpAnnouncementThreadRunning() will report wrong
428             // results.
429             config = threadContext;
430             threadContext.asyncIOselector = selector;
431
432             for (InetAddress address : cs.getDiscoveryIps()) {
433                 NetworkInterface networkInterface = NetworkInterface.getByInetAddress(address);
434                 if (networkInterface == null) {
435                     continue;
436                 }
437                 if (address instanceof Inet4Address && channelV4 != null) {
438                     channelV4.join(MULTI_ADDR_IPV4, networkInterface);
439                     hasIPv4 = true;
440                 } else if (channelV6 != null) {
441                     channelV6.join(MULTI_ADDR_IPV6, networkInterface);
442                     hasIPv6 = true;
443                 }
444             }
445             if (!hasIPv4 && !hasIPv6) {
446                 logger.warn("Could not join upnp multicast network!");
447                 threadContext.future
448                         .completeExceptionally(new IllegalStateException("Could not join upnp multicast network!"));
449                 return;
450             }
451
452             if (hasIPv4) {
453                 channelV4.configureBlocking(false);
454                 channelV4.register(selector, SelectionKey.OP_READ, new ClientRecord());
455                 try (DatagramSocket sendSocket = new DatagramSocket(new InetSocketAddress(config.address, 0))) {
456                     sendUPNPDatagrams(sendSocket, MULTI_ADDR_IPV4, UPNP_PORT);
457                 }
458             }
459             if (hasIPv6) {
460                 channelV6.configureBlocking(false);
461                 channelV6.register(selector, SelectionKey.OP_READ, new ClientRecord());
462                 try (DatagramSocket sendSocket = new DatagramSocket()) {
463                     sendUPNPDatagrams(sendSocket, MULTI_ADDR_IPV6, UPNP_PORT);
464                 }
465             }
466
467             threadContext.future.complete(threadContext);
468             Instant time = Instant.now();
469
470             while (selector.isOpen()) { // Run forever, receiving and echoing datagrams
471                 // Wait for task or until timeout expires
472                 selector.select(CACHE_MSECS);
473                 Iterator<SelectionKey> keyIter = selector.selectedKeys().iterator();
474                 while (keyIter.hasNext()) {
475                     SelectionKey key = keyIter.next();
476                     if (key.isReadable()) {
477                         handleRead(key);
478                     }
479                     keyIter.remove();
480                 }
481
482                 if (time.plusMillis(CACHE_MSECS - 200).isBefore(Instant.now())) {
483                     logger.trace("upnp thread send periodic announcement");
484                     time = Instant.now();
485                     if (hasIPv4) {
486                         try (DatagramSocket sendSocket = new DatagramSocket(new InetSocketAddress(config.address, 0))) {
487                             sendUPNPNotify(sendSocket, MULTI_ADDR_IPV4, UPNP_PORT);
488                         }
489                     }
490                     if (hasIPv6) {
491                         try (DatagramSocket sendSocket = new DatagramSocket()) {
492                             sendUPNPNotify(sendSocket, MULTI_ADDR_IPV6, UPNP_PORT);
493                         }
494                     }
495                 }
496             }
497         } catch (ClosedSelectorException ignored) {
498         } catch (IOException e) {
499             logger.warn("Socket error with UPNP server", e);
500             threadContext.future.completeExceptionally(e);
501         } finally {
502             threadContext.asyncIOselector = null;
503         }
504     }
505
506     @Nullable
507     private DatagramChannel createBoundDataGramChannelOrNull(StandardProtocolFamily family) throws IOException {
508         try {
509             return DatagramChannel.open(family).setOption(StandardSocketOptions.SO_REUSEADDR, true)
510                     .setOption(StandardSocketOptions.IP_MULTICAST_LOOP, true).bind(new InetSocketAddress(UPNP_PORT));
511         } catch (UnsupportedOperationException uoe) {
512             return null;
513         }
514     }
515
516     /**
517      * The upnp server performs some self-tests
518      *
519      * @return
520      */
521     public List<SelfTestReport> selfTests() {
522         return selfTests;
523     }
524
525     public String getBaseURL() {
526         return baseurl;
527     }
528
529     public int getDefaultport() {
530         return config.port;
531     }
532
533     public boolean upnpAnnouncementThreadRunning() {
534         return config.asyncIOselector != null;
535     }
536 }