/*
* PS3 Media Server, for streaming any medias to your PS3.
* Copyright (C) 2008 A.Brochard
*
* 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; version 2
* of the License only.
*
* 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, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
* 02110-1301, USA.
*/
package net.pms.network;
import java.io.IOException;
import java.net.BindException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.Inet4Address;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.MulticastSocket;
import java.net.NetworkInterface;
import java.net.SocketException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import java.util.Locale;
import java.util.TimeZone;
import net.pms.PMS;
import net.pms.configuration.PmsConfiguration;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Helper class to handle the UPnP traffic that makes PMS discoverable by
* other clients.
* See http://upnp.org/specs/arch/UPnP-arch-DeviceArchitecture-v1.0.pdf
* and http://upnp.org/specs/arch/UPnP-arch-DeviceArchitecture-v1.1-AnnexA.pdf
* for the specifications.
*/
public class UPNPHelper {
/** Logger instance to write messages to the logs. */
private static final Logger logger = LoggerFactory.getLogger(UPNPHelper.class);
/** Carriage return and line feed. */
private static final String CRLF = "\r\n";
/** The Constant ALIVE. */
private static final String ALIVE = "ssdp:alive";
/**
* IPv4 Multicast channel reserved for SSDP by Internet Assigned Numbers Authority (IANA).
* MUST be 239.255.255.250.
*/
private static final String IPV4_UPNP_HOST = "239.255.255.250";
/**
* IPv6 Multicast channel reserved for SSDP by Internet Assigned Numbers Authority (IANA).
* MUST be [FF02::C].
*/
private static final String IPV6_UPNP_HOST = "[FF02::C]";
/**
* Multicast channel reserved for SSDP by Internet Assigned Numbers Authority (IANA).
* MUST be 1900.
*/
private static final int UPNP_PORT = 1900;
/** The Constant BYEBYE. */
private static final String BYEBYE = "ssdp:byebye";
/** The listener. */
private static Thread listenerThread;
/** The alive thread. */
private static Thread aliveThread;
private static final PmsConfiguration configuration = PMS.getConfiguration();
/**
* This utility class is not meant to be instantiated.
*/
private UPNPHelper() { }
/**
* Send UPnP discovery search message to discover devices of interest on
* the network.
*
* @param host The multicast channel
* @param port The multicast port
* @param st The search target string
* @throws IOException Signals that an I/O exception has occurred.
*/
private static void sendDiscover(String host, int port, String st) throws IOException {
String usn = PMS.get().usn();
String serverHost = PMS.get().getServer().getHost();
int serverPort = PMS.get().getServer().getPort();
SimpleDateFormat sdf = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss", Locale.US);
sdf.setTimeZone(TimeZone.getTimeZone("GMT"));
if (st.equals(usn)) {
usn = "";
} else {
usn += "::";
}
StringBuilder discovery = new StringBuilder();
discovery.append("HTTP/1.1 200 OK").append(CRLF);
discovery.append("CACHE-CONTROL: max-age=1200").append(CRLF);
discovery.append("DATE: ").append(sdf.format(new Date(System.currentTimeMillis()))).append(" GMT").append(CRLF);
discovery.append("LOCATION: http://").append(serverHost).append(":").append(serverPort).append("/description/fetch").append(CRLF);
discovery.append("SERVER: ").append(PMS.get().getServerName()).append(CRLF);
discovery.append("ST: ").append(st).append(CRLF);
discovery.append("EXT: ").append(CRLF);
discovery.append("USN: ").append(usn).append(st).append(CRLF);
discovery.append("Content-Length: 0").append(CRLF).append(CRLF);
sendReply(host, port, discovery.toString());
}
/**
* Send reply.
*
* @param host the host
* @param port the port
* @param msg the msg
* @throws IOException Signals that an I/O exception has occurred.
*/
private static void sendReply(String host, int port, String msg) {
DatagramSocket datagramSocket = null;
try {
datagramSocket = new DatagramSocket();
InetAddress inetAddr = InetAddress.getByName(host);
DatagramPacket dgmPacket = new DatagramPacket(msg.getBytes(), msg.length(), inetAddr, port);
logger.trace("Sending this reply [" + host + ":" + port + "]: " + StringUtils.replace(msg, CRLF, "<CRLF>"));
datagramSocket.send(dgmPacket);
} catch (Exception e) {
logger.info(e.getMessage());
logger.debug("Error sending reply", e);
} finally {
if (datagramSocket != null) {
datagramSocket.close();
}
}
}
/**
* Send alive.
*/
public static void sendAlive() {
logger.debug("Sending ALIVE...");
MulticastSocket multicastSocket = null;
try {
multicastSocket = getNewMulticastSocket();
InetAddress upnpAddress = getUPNPAddress();
multicastSocket.joinGroup(upnpAddress);
sendMessage(multicastSocket, "upnp:rootdevice", ALIVE);
sendMessage(multicastSocket, PMS.get().usn(), ALIVE);
sendMessage(multicastSocket, "urn:schemas-upnp-org:device:MediaServer:1", ALIVE);
sendMessage(multicastSocket, "urn:schemas-upnp-org:service:ContentDirectory:1", ALIVE);
sendMessage(multicastSocket, "urn:schemas-upnp-org:service:ConnectionManager:1", ALIVE);
} catch (IOException e) {
logger.debug("Error sending ALIVE message", e);
} finally {
if (multicastSocket != null) {
// Clean up the multicast socket nicely
try {
InetAddress upnpAddress = getUPNPAddress();
multicastSocket.leaveGroup(upnpAddress);
} catch (IOException e) {
}
multicastSocket.disconnect();
multicastSocket.close();
}
}
}
/**
* Gets the new multicast socket.
*
* @return the new multicast socket
* @throws IOException Signals that an I/O exception has occurred.
*/
private static MulticastSocket getNewMulticastSocket() throws IOException {
NetworkInterface networkInterface = NetworkConfiguration.getInstance().getNetworkInterfaceByServerName();
if (networkInterface == null) {
networkInterface = PMS.get().getServer().getNetworkInterface();
}
if (networkInterface == null) {
throw new IOException("No usable network interface found for UPnP multicast");
}
List<InetAddress> usableAddresses = new ArrayList<InetAddress>();
List<InetAddress> networkInterfaceAddresses = Collections.list(networkInterface.getInetAddresses());
for (InetAddress inetAddress : networkInterfaceAddresses) {
if (inetAddress != null && inetAddress instanceof Inet4Address && !inetAddress.isLoopbackAddress()) {
usableAddresses.add(inetAddress);
}
}
if (usableAddresses.isEmpty()) {
throw new IOException("No usable addresses found for UPnP multicast");
}
InetSocketAddress localAddress = new InetSocketAddress(usableAddresses.get(0), 0);
MulticastSocket ssdpSocket = new MulticastSocket(localAddress);
ssdpSocket.setReuseAddress(true);
logger.trace("Sending message from multicast socket on network interface: " + ssdpSocket.getNetworkInterface());
logger.trace("Multicast socket is on interface: " + ssdpSocket.getInterface());
ssdpSocket.setTimeToLive(32);
logger.trace("Socket Timeout: " + ssdpSocket.getSoTimeout());
logger.trace("Socket TTL: " + ssdpSocket.getTimeToLive());
return ssdpSocket;
}
/**
* Send the UPnP BYEBYE message.
*/
public static void sendByeBye() {
logger.info("Sending BYEBYE...");
MulticastSocket multicastSocket = null;
try {
multicastSocket = getNewMulticastSocket();
InetAddress upnpAddress = getUPNPAddress();
multicastSocket.joinGroup(upnpAddress);
sendMessage(multicastSocket, "upnp:rootdevice", BYEBYE);
sendMessage(multicastSocket, "urn:schemas-upnp-org:device:MediaServer:1", BYEBYE);
sendMessage(multicastSocket, "urn:schemas-upnp-org:service:ContentDirectory:1", BYEBYE);
sendMessage(multicastSocket, "urn:schemas-upnp-org:service:ConnectionManager:1", BYEBYE);
} catch (IOException e) {
logger.debug("Error sending BYEBYE message", e);
} finally {
if (multicastSocket != null) {
// Clean up the multicast socket nicely
try {
InetAddress upnpAddress = getUPNPAddress();
multicastSocket.leaveGroup(upnpAddress);
} catch (IOException e) {
}
multicastSocket.disconnect();
multicastSocket.close();
}
}
}
/**
* Utility method to call {@link Thread#sleep(long)} without having to
* catch the InterruptedException.
*
* @param delay the delay
*/
private static void sleep(int delay) {
try {
Thread.sleep(delay);
} catch (InterruptedException e) {
}
}
/**
* Send the provided message to the socket.
*
* @param socket the socket
* @param nt the nt
* @param message the message
* @throws IOException Signals that an I/O exception has occurred.
*/
private static void sendMessage(DatagramSocket socket, String nt, String message) throws IOException {
String msg = buildMsg(nt, message);
//Random rand = new Random();
// logger.trace( "Sending this SSDP packet: " + CRLF + StringUtils.replace(msg, CRLF, "<CRLF>")));
InetAddress upnpAddress = getUPNPAddress();
DatagramPacket ssdpPacket = new DatagramPacket(msg.getBytes(), msg.length(), upnpAddress, UPNP_PORT);
socket.send(ssdpPacket);
// XXX Why is it necessary to sleep for this random time? What would happen when random equals 0?
//sleep(rand.nextInt(1800 / 2));
// XXX Why send the same packet twice?
//socket.send(ssdpPacket);
// XXX Why is it necessary to sleep for this random time (again)?
//sleep(rand.nextInt(1800 / 2));
}
/**
* Starts up two threads: one to broadcast UPnP ALIVE messages and another
* to listen for responses.
*
* @throws IOException Signals that an I/O exception has occurred.
*/
public static void listen() throws IOException {
Runnable rAlive = new Runnable() {
@Override
public void run() {
int delay = 10000;
while (true) {
sleep(delay);
sendAlive();
// The first delay for sending an ALIVE message is 10 seconds,
// the second delay is for 20 seconds. From then on, all other
// delays are for 180 seconds.
switch (delay) {
case 10000:
delay = 20000;
break;
case 20000:
delay = 180000;
break;
}
}
}
};
aliveThread = new Thread(rAlive, "UPNP-AliveMessageSender");
aliveThread.start();
Runnable r = new Runnable() {
@Override
public void run() {
boolean bindErrorReported = false;
while (true) {
MulticastSocket multicastSocket = null;
try {
// Use configurable source port as per http://code.google.com/p/ps3mediaserver/issues/detail?id=1166
multicastSocket = new MulticastSocket(configuration.getUpnpPort());
if (bindErrorReported) {
logger.warn("Finally, acquiring port " + configuration.getUpnpPort() + " was successful!");
}
NetworkInterface ni = NetworkConfiguration.getInstance().getNetworkInterfaceByServerName();
try {
// Setting the network interface will throw a SocketException on Mac OS X
// with Java 1.6.0_45 or higher, but if we don't do it some Windows
// configurations will not listen at all.
if (ni != null) {
multicastSocket.setNetworkInterface(ni);
} else if (PMS.get().getServer().getNetworkInterface() != null) {
multicastSocket.setNetworkInterface(PMS.get().getServer().getNetworkInterface());
logger.trace("Setting multicast network interface: " + PMS.get().getServer().getNetworkInterface());
}
} catch (SocketException e) {
// Not setting the network interface will work just fine on Mac OS X.
}
multicastSocket.setTimeToLive(4);
multicastSocket.setReuseAddress(true);
InetAddress upnpAddress = getUPNPAddress();
multicastSocket.joinGroup(upnpAddress);
while (true) {
byte[] buf = new byte[1024];
DatagramPacket receivePacket = new DatagramPacket(buf, buf.length);
multicastSocket.receive(receivePacket);
String s = new String(receivePacket.getData());
InetAddress address = receivePacket.getAddress();
if (s.startsWith("M-SEARCH")) {
String remoteAddr = address.getHostAddress();
int remotePort = receivePacket.getPort();
if (configuration.getIpFiltering().allowed(address)) {
logger.trace("Receiving a M-SEARCH from [" + remoteAddr + ":" + remotePort + "]");
if (StringUtils.indexOf(s, "urn:schemas-upnp-org:service:ContentDirectory:1") > 0) {
sendDiscover(remoteAddr, remotePort, "urn:schemas-upnp-org:service:ContentDirectory:1");
}
if (StringUtils.indexOf(s, "upnp:rootdevice") > 0) {
sendDiscover(remoteAddr, remotePort, "upnp:rootdevice");
}
if (StringUtils.indexOf(s, "urn:schemas-upnp-org:device:MediaServer:1") > 0) {
sendDiscover(remoteAddr, remotePort, "urn:schemas-upnp-org:device:MediaServer:1");
}
if (StringUtils.indexOf(s, "ssdp:all") > 0) {
sendDiscover(remoteAddr, remotePort, "urn:schemas-upnp-org:device:MediaServer:1");
}
if (StringUtils.indexOf(s, PMS.get().usn()) > 0) {
sendDiscover(remoteAddr, remotePort, PMS.get().usn());
}
}
} else if (s.startsWith("NOTIFY")) {
String remoteAddr = address.getHostAddress();
int remotePort = receivePacket.getPort();
logger.trace("Receiving a NOTIFY from [" + remoteAddr + ":" + remotePort + "]");
}
}
} catch (BindException e) {
if (!bindErrorReported) {
logger.error("Unable to bind to " + configuration.getUpnpPort()
+ ", which means that PMS will not automatically appear on your renderer! "
+ "This usually means that another program occupies the port. Please "
+ "stop the other program and free up the port. "
+ "PMS will keep trying to bind to it...[" + e.getMessage() + "]");
}
bindErrorReported = true;
sleep(5000);
} catch (IOException e) {
logger.error("UPNP network exception", e);
sleep(1000);
} finally {
if (multicastSocket != null) {
// Clean up the multicast socket nicely
try {
InetAddress upnpAddress = getUPNPAddress();
multicastSocket.leaveGroup(upnpAddress);
} catch (IOException e) {
}
multicastSocket.disconnect();
multicastSocket.close();
}
}
}
}
};
listenerThread = new Thread(r, "UPNPHelper");
listenerThread.start();
}
/**
* Shut down the threads that send ALIVE messages and listen to responses.
*/
public static void shutDownListener() {
listenerThread.interrupt();
aliveThread.interrupt();
}
/**
* Builds a UPnP message string based on a message.
*
* @param nt the nt
* @param message the message
* @return the string
*/
private static String buildMsg(String nt, String message) {
StringBuilder sb = new StringBuilder();
sb.append("NOTIFY * HTTP/1.1").append(CRLF);
sb.append("HOST: ").append(IPV4_UPNP_HOST).append(":").append(UPNP_PORT).append(CRLF);
sb.append("NT: ").append(nt).append(CRLF);
sb.append("NTS: ").append(message).append(CRLF);
if (message.equals(ALIVE)) {
sb.append("LOCATION: http://").append(PMS.get().getServer().getHost()).append(":").append(PMS.get().getServer().getPort()).append("/description/fetch").append(CRLF);
}
sb.append("USN: ").append(PMS.get().usn());
if (!nt.equals(PMS.get().usn())) {
sb.append("::").append(nt);
}
sb.append(CRLF);
if (message.equals(ALIVE)) {
sb.append("CACHE-CONTROL: max-age=1800").append(CRLF);
}
if (message.equals(ALIVE)) {
sb.append("SERVER: ").append(PMS.get().getServerName()).append(CRLF);
}
sb.append(CRLF);
return sb.toString();
}
/**
* Gets the uPNP address.
*
* @return the uPNP address
* @throws IOException Signals that an I/O exception has occurred.
*/
private static InetAddress getUPNPAddress() throws IOException {
return InetAddress.getByName(IPV4_UPNP_HOST);
}
}