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