/* * 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 static org.apache.commons.lang3.StringUtils.isBlank; import static org.apache.commons.lang3.StringUtils.isNotBlank; import java.io.IOException; import java.net.InetAddress; import java.net.InetSocketAddress; import java.nio.channels.ClosedChannelException; import java.nio.charset.Charset; import java.util.ArrayList; import java.util.Iterator; import java.util.Locale; import java.util.Map.Entry; import java.util.Set; import java.util.StringTokenizer; import java.util.regex.Matcher; import java.util.regex.Pattern; import javax.xml.parsers.ParserConfigurationException; import javax.xml.transform.TransformerException; import javax.xml.xpath.XPathExpressionException; import net.pms.PMS; import net.pms.configuration.RendererConfiguration; import net.pms.dlna.protocolinfo.PanasonicDmpProfiles; import net.pms.external.StartStopListenerDelegate; import net.pms.util.StringUtil; import org.apache.commons.lang3.StringUtils; import org.jboss.netty.buffer.ChannelBuffer; import org.jboss.netty.buffer.ChannelBuffers; import org.jboss.netty.channel.*; import org.jboss.netty.channel.group.ChannelGroup; import org.jboss.netty.handler.codec.frame.TooLongFrameException; import org.jboss.netty.handler.codec.http.*; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.xml.sax.SAXException; public class RequestHandlerV2 extends SimpleChannelUpstreamHandler { private static final Logger LOGGER = LoggerFactory.getLogger(RequestHandlerV2.class); private static final Pattern TIMERANGE_PATTERN = Pattern.compile( "timeseekrange\\.dlna\\.org\\W*npt\\W*=\\W*([\\d.:]+)?-?([\\d.:]+)?", Pattern.CASE_INSENSITIVE ); private volatile HttpRequest nettyRequest; private final ChannelGroup group; public RequestHandlerV2(ChannelGroup group) { this.group = group; } // Used to filter out known headers when the renderer is not recognized private final static String[] KNOWN_HEADERS = { "accept", "accept-language", "accept-encoding", "callback", "connection", "content-length", "content-type", "date", "host", "nt", "sid", "timeout", "user-agent" }; @Override public void messageReceived(ChannelHandlerContext ctx, MessageEvent event) throws Exception { RequestV2 request = null; RendererConfiguration renderer = null; String userAgentString = null; ArrayList<String> identifiers = new ArrayList<>(); HttpRequest nettyRequest = this.nettyRequest = (HttpRequest) event.getMessage(); InetSocketAddress remoteAddress = (InetSocketAddress) event.getChannel().getRemoteAddress(); InetAddress ia = remoteAddress.getAddress(); // Is the request from our own Cling service, i.e. self-originating? boolean isSelf = ia.getHostAddress().equals(PMS.get().getServer().getHost()) && nettyRequest.headers().get(HttpHeaders.Names.USER_AGENT) != null && nettyRequest.headers().get(HttpHeaders.Names.USER_AGENT).contains("UMS/"); // Filter if required if (isSelf || filterIp(ia)) { event.getChannel().close(); /*if (isSelf && LOGGER.isTraceEnabled()) { LOGGER.trace("Ignoring self-originating request from {}:{}", ia, remoteAddress.getPort()); }*/ return; } PMS.get().getRegistry().disableGoToSleep(); request = new RequestV2(nettyRequest.getMethod().getName(), nettyRequest.getUri().substring(1)); if (nettyRequest.getProtocolVersion().getMinorVersion() == 0) { request.setHttp10(true); } HttpHeaders headers = nettyRequest.headers(); // The handler makes a couple of attempts to recognize a renderer from its requests. // IP address matches from previous requests are preferred, when that fails request // header matches are attempted and if those fail as well we're stuck with the // default renderer. // Attempt 1: try to recognize the renderer by its socket address from previous requests renderer = RendererConfiguration.getRendererConfigurationBySocketAddress(ia); // If the renderer exists but isn't marked as loaded it means it's unrecognized // by upnp and we still need to attempt http recognition here. if (renderer == null || !renderer.loaded) { // Attempt 2: try to recognize the renderer by matching headers renderer = RendererConfiguration.getRendererConfigurationByHeaders(headers.entries(), ia); } if (renderer != null) { request.setMediaRenderer(renderer); } Set<String> headerNames = headers.names(); Iterator<String> iterator = headerNames.iterator(); while (iterator.hasNext()) { String name = iterator.next(); String headerLine = name + ": " + headers.get(name); if (headerLine.toUpperCase().startsWith("USER-AGENT")) { userAgentString = headerLine.substring(headerLine.indexOf(':') + 1).trim(); } else if (renderer != null && name.equals("X-PANASONIC-DMP-Profile")) { PanasonicDmpProfiles.parsePanasonicDmpProfiles(headers.get(name), renderer); } try { StringTokenizer s = new StringTokenizer(headerLine); String temp = s.nextToken(); if (temp.toUpperCase().equals("SOAPACTION:")) { request.setSoapaction(s.nextToken()); } else if (temp.toUpperCase().equals("CALLBACK:")) { request.setSoapaction(s.nextToken()); } else if (headerLine.toUpperCase().contains("RANGE: BYTES=")) { String nums = headerLine.substring( headerLine.toUpperCase().indexOf( "RANGE: BYTES=") + 13).trim(); StringTokenizer st = new StringTokenizer(nums, "-"); if (!nums.startsWith("-")) { request.setLowRange(Long.parseLong(st.nextToken())); } if (!nums.startsWith("-") && !nums.endsWith("-")) { request.setHighRange(Long.parseLong(st.nextToken())); } else { request.setHighRange(-1); } } else if (headerLine.toLowerCase().contains("transfermode.dlna.org:")) { request.setTransferMode(headerLine.substring(headerLine.toLowerCase().indexOf("transfermode.dlna.org:") + 22).trim()); } else if (headerLine.toLowerCase().contains("getcontentfeatures.dlna.org:")) { request.setContentFeatures(headerLine.substring(headerLine.toLowerCase().indexOf("getcontentfeatures.dlna.org:") + 28).trim()); } else { Matcher matcher = TIMERANGE_PATTERN.matcher(headerLine); if (matcher.find()) { String first = matcher.group(1); if (first != null) { request.setTimeRangeStartString(first); } String end = matcher.group(2); if (end != null) { request.setTimeRangeEndString(end); } } else { /** If we made it to here, none of the previous header checks matched. * Unknown headers make interesting logging info when we cannot recognize * the media renderer, so keep track of the truly unknown ones. */ boolean isKnown = false; // Try to match known headers. String lowerCaseHeaderLine = headerLine.toLowerCase(); for (String knownHeaderString : KNOWN_HEADERS) { if (lowerCaseHeaderLine.startsWith(knownHeaderString)) { isKnown = true; break; } } // It may be unusual but already known if (!isKnown && renderer != null) { String additionalHeader = renderer.getUserAgentAdditionalHttpHeader(); if (StringUtils.isNotBlank(additionalHeader) && lowerCaseHeaderLine.startsWith(additionalHeader)) { isKnown = true; } } if (!isKnown) { // Truly unknown header, therefore interesting. Save for later use. identifiers.add(headerLine); } } } } catch (Exception ee) { LOGGER.error("Error parsing HTTP headers: {}", ee.getMessage()); LOGGER.trace("", ee); } } // Still no media renderer recognized? if (renderer == null) { // Attempt 3: Not really an attempt; all other attempts to recognize // the renderer have failed. The only option left is to assume the // default renderer. renderer = RendererConfiguration.resolve(ia, null); request.setMediaRenderer(renderer); if (renderer != null) { LOGGER.debug("Using default media renderer \"{}\"", renderer.getConfName()); if (userAgentString != null && !userAgentString.equals("FDSSDP")) { // We have found an unknown renderer identifiers.add(0, "User-Agent: " + userAgentString); renderer.setIdentifiers(identifiers); LOGGER.info( "Media renderer was not recognized. Possible identifying HTTP headers:\n{}", StringUtils.join(identifiers, "\n") ); PMS.get().setRendererFound(renderer); } } else { // If RendererConfiguration.resolve() didn't return the default renderer // it means we know via upnp that it's not really a renderer. return; } } else if (LOGGER.isDebugEnabled()) { LOGGER.debug("Recognized media renderer \"{}\"", renderer.getRendererName()); } if (nettyRequest.headers().contains(HttpHeaders.Names.CONTENT_LENGTH)) { byte data[] = new byte[(int) HttpHeaders.getContentLength(nettyRequest)]; ChannelBuffer content = nettyRequest.getContent(); content.readBytes(data); String textContent = new String(data, "UTF-8"); request.setTextContent(textContent); if (LOGGER.isTraceEnabled()) { logMessageReceived(event, textContent, renderer); } } else if (LOGGER.isTraceEnabled() ){ logMessageReceived(event, null, renderer); } writeResponse(ctx, event, request, ia); } private static void logMessageReceived(MessageEvent event, String content, RendererConfiguration renderer) { StringBuilder header = new StringBuilder(); String soapAction = null; if (event.getMessage() instanceof HttpRequest) { header.append(((HttpRequest) event.getMessage()).getMethod()); header.append(" ").append(((HttpRequest) event.getMessage()).getUri()); } if (event.getMessage() instanceof HttpMessage) { if (header.length() > 0) { header.append(" "); } header.append(((HttpMessage) event.getMessage()).getProtocolVersion().getText()); header.append("\n\n"); header.append("HEADER:\n"); for (Entry<String, String> entry : ((HttpMessage) event.getMessage()).headers().entries()) { if (StringUtils.isNotBlank(entry.getKey())) { header.append(" ").append(entry.getKey()).append(": ").append(entry.getValue()).append("\n"); if ("SOAPACTION".equalsIgnoreCase(entry.getKey())) { soapAction = entry.getValue().toUpperCase(Locale.ROOT); } } } } else { header.append("Unknown class: ").append(event.getClass().getSimpleName()).append("\n"); header.append(event).append("\n"); } String formattedContent = null; if (StringUtils.isNotBlank(content)) { try { formattedContent = StringUtil.prettifyXML(content, 4); } catch (XPathExpressionException | SAXException | ParserConfigurationException | TransformerException e) { LOGGER.trace("XML parsing failed with:\n{}", e); formattedContent = " Content isn't valid XML, using text formatting: " + e.getMessage() + "\n"; formattedContent += " " + content.replaceAll("\n", "\n ") + "\n"; } } String requestType = ""; // Map known requests to request type if (soapAction != null) { if (soapAction.contains("CONTENTDIRECTORY:1#BROWSE")) { requestType = "browse "; } else if (soapAction.contains("CONTENTDIRECTORY:1#SEARCH")) { requestType = "search "; } } String rendererName; if (renderer != null) { if (isNotBlank(renderer.getRendererName())) { if (isBlank(renderer.getConfName()) || renderer.getRendererName().equals(renderer.getConfName())) { rendererName = renderer.getRendererName(); } else { rendererName = renderer.getRendererName() + " [" + renderer.getConfName() + "]"; } } else if (isNotBlank(renderer.getConfName())) { rendererName = renderer.getConfName(); } else { rendererName = "Unnamed"; } } else { rendererName = "Unknown"; } if (event.getChannel().getRemoteAddress() instanceof InetSocketAddress) { rendererName += " (" + ((InetSocketAddress) event.getChannel().getRemoteAddress()).getAddress().getHostAddress() + ":" + ((InetSocketAddress) event.getChannel().getRemoteAddress()).getPort() + ")"; } LOGGER.trace( "Received a {}request from {}:\n\n{}{}", requestType, rendererName, header, StringUtils.isNotBlank(formattedContent) ? "\nCONTENT:\n" + formattedContent : "" ); } /** * Applies the IP filter to the specified internet address. Returns true * if the address is not allowed and therefore should be filtered out, * false otherwise. * * @param inetAddress The internet address to verify. * @return True when not allowed, false otherwise. */ private static boolean filterIp(InetAddress inetAddress) { return !PMS.getConfiguration().getIpFiltering().allowed(inetAddress); } private void writeResponse(ChannelHandlerContext ctx, MessageEvent e, RequestV2 request, InetAddress ia) { // Decide whether to close the connection or not. boolean close = HttpHeaders.Values.CLOSE.equalsIgnoreCase(nettyRequest.headers().get(HttpHeaders.Names.CONNECTION)) || nettyRequest.getProtocolVersion().equals(HttpVersion.HTTP_1_0) && !HttpHeaders.Values.KEEP_ALIVE.equalsIgnoreCase(nettyRequest.headers().get(HttpHeaders.Names.CONNECTION)); // Build the response object. HttpResponse response; if (request.getLowRange() != 0 || request.getHighRange() != 0) { response = new DefaultHttpResponse( request.isHttp10() ? HttpVersion.HTTP_1_0 : HttpVersion.HTTP_1_1, HttpResponseStatus.PARTIAL_CONTENT ); } else { String soapAction = nettyRequest.headers().get("SOAPACTION"); if (soapAction != null && soapAction.contains("X_GetFeatureList")) { LOGGER.debug("Invalid action in SOAPACTION: " + soapAction); response = new DefaultHttpResponse( request.isHttp10() ? HttpVersion.HTTP_1_0 : HttpVersion.HTTP_1_1, HttpResponseStatus.INTERNAL_SERVER_ERROR ); } else { response = new DefaultHttpResponse( request.isHttp10() ? HttpVersion.HTTP_1_0 : HttpVersion.HTTP_1_1, HttpResponseStatus.OK ); } } StartStopListenerDelegate startStopListenerDelegate = new StartStopListenerDelegate(ia.getHostAddress()); // Attach it to the context so it can be invoked if connection is reset unexpectedly ctx.setAttachment(startStopListenerDelegate); try { request.answer(response, e, close, startStopListenerDelegate); } catch (IOException e1) { LOGGER.debug("HTTP request V2 IO error: " + e1.getMessage()); LOGGER.trace("", e1); // note: we don't call stop() here in a finally block as // answer() is non-blocking. we only (may) need to call it // here in the case of an exception. it's a no-op if it's // already been called startStopListenerDelegate.stop(); } } @Override public void exceptionCaught(ChannelHandlerContext ctx, ExceptionEvent e) throws Exception { Channel ch = e.getChannel(); Throwable cause = e.getCause(); if (cause instanceof TooLongFrameException) { sendError(ctx, HttpResponseStatus.BAD_REQUEST); return; } if (cause != null) { if (cause.getClass().equals(IOException.class)) { LOGGER.debug("Connection error: " + cause); StartStopListenerDelegate startStopListenerDelegate = (StartStopListenerDelegate)ctx.getAttachment(); if (startStopListenerDelegate != null) { LOGGER.debug("Premature end, stopping..."); startStopListenerDelegate.stop(); } } else if (!cause.getClass().equals(ClosedChannelException.class)) { LOGGER.debug("Caught exception: {}", cause.getMessage()); LOGGER.trace("", cause); } } if (ch.isConnected()) { sendError(ctx, HttpResponseStatus.INTERNAL_SERVER_ERROR); } ch.close(); } private static void sendError(ChannelHandlerContext ctx, HttpResponseStatus status) { HttpResponse response = new DefaultHttpResponse( HttpVersion.HTTP_1_1, status); response.headers().set( HttpHeaders.Names.CONTENT_TYPE, "text/plain; charset=UTF-8"); response.setContent(ChannelBuffers.copiedBuffer( "Failure: " + status.toString() + "\r\n", Charset.forName("UTF-8"))); // Close the connection as soon as the error message is sent. ctx.getChannel().write(response).addListener(ChannelFutureListener.CLOSE); } @Override public void channelOpen(ChannelHandlerContext ctx, ChannelStateEvent e) throws Exception { // as seen in http://www.jboss.org/netty/community.html#nabble-td2423020 super.channelOpen(ctx, e); if (group != null) { group.add(ctx.getChannel()); } } /* Uncomment to see channel events in the trace logs @Override public void handleUpstream(ChannelHandlerContext ctx, ChannelEvent e) throws Exception { // Log all channel events. LOGGER.trace("Channel upstream event: " + e); super.handleUpstream(ctx, e); } */ }